{
  "id": "mg_sci_ideal_gas",
  "name": "Ideal Gas Law",
  "width": 1440,
  "height": 810,
  "cornerRadius": 0,
  "fit": "Layout",
  "backgroundColor": "#06080b",
  "opacity": 1,
  "x": -720,
  "y": -405,
  "responsive": {
    "enabled": true,
    "mode": "constraint",
    "width": {
      "type": "fixed"
    },
    "height": {
      "type": "fixed"
    },
    "maintainAspectRatio": false,
    "scaleObjects": false,
    "breakpoints": [
      {
        "id": "bp_mobile",
        "name": "Mobile",
        "minWidth": 0,
        "maxWidth": 599
      },
      {
        "id": "bp_tablet",
        "name": "Tablet",
        "minWidth": 600,
        "maxWidth": 1023
      },
      {
        "id": "bp_desktop",
        "name": "Desktop",
        "minWidth": 1024
      }
    ]
  },
  "layoutProps": {
    "mode": "linear",
    "direction": "vertical",
    "gap": 0,
    "padding": {
      "top": 0,
      "right": 0,
      "bottom": 0,
      "left": 0
    },
    "alignItems": "stretch",
    "layoutSizingHorizontal": "fixed",
    "layoutSizingVertical": "fixed"
  },
  "seo": {
    "title": "Ideal Gas Law — Exode Motion Graphics",
    "description": "Argon atoms in a cylinder with a draggable piston. Change temperature, count and volume and watch pressure, PV/NkT and the Maxwell–Boltzmann distribution respond live."
  },
  "stateMachine": {
    "inputs": [
      {
        "id": "in_title",
        "name": "title",
        "value": {
          "type": "Text",
          "value": "Ideal Gas Law",
          "defaultValue": "Ideal Gas Law"
        }
      },
      {
        "id": "in_temp",
        "name": "temp",
        "value": {
          "type": "Number",
          "value": 300,
          "defaultValue": 300
        }
      },
      {
        "id": "in_count",
        "name": "count",
        "value": {
          "type": "Number",
          "value": 160,
          "defaultValue": 160
        }
      },
      {
        "id": "in_volume",
        "name": "volume",
        "value": {
          "type": "Number",
          "value": 100,
          "defaultValue": 100
        }
      },
      {
        "id": "in_isothermal",
        "name": "isothermal",
        "value": {
          "type": "Boolean",
          "value": true,
          "defaultValue": true
        }
      },
      {
        "id": "in_tracer",
        "name": "tracer",
        "value": {
          "type": "Boolean",
          "value": false,
          "defaultValue": false
        }
      },
      {
        "id": "in_speed",
        "name": "speed",
        "value": {
          "type": "Number",
          "value": 1,
          "defaultValue": 1
        }
      },
      {
        "id": "in_accent",
        "name": "accent",
        "value": {
          "type": "Text",
          "value": "#5ee0ff",
          "defaultValue": "#5ee0ff"
        }
      },
      {
        "id": "in_accent2",
        "name": "accent2",
        "value": {
          "type": "Text",
          "value": "#ffb454",
          "defaultValue": "#ffb454"
        }
      },
      {
        "id": "in_bg",
        "name": "bg",
        "value": {
          "type": "Text",
          "value": "#06080b",
          "defaultValue": "#06080b"
        }
      },
      {
        "id": "in_text",
        "name": "text",
        "value": {
          "type": "Text",
          "value": "#e6edf3",
          "defaultValue": "#e6edf3"
        }
      }
    ],
    "layers": [
      {
        "name": "Base",
        "entryStateId": "s_idle",
        "states": [
          {
            "id": "s_idle",
            "name": "Idle",
            "loop": true,
            "transitions": []
          }
        ]
      }
    ],
    "logicNodes": []
  },
  "animations": [],
  "physics": {
    "enabled": false,
    "gravity": {
      "x": 0,
      "y": 9.8
    }
  },
  "animationFlow": [],
  "navigation": [],
  "stateVariables": [],
  "objects": [
    {
      "id": "stage",
      "name": "Stage",
      "type": "Group",
      "transform": {
        "x": 0,
        "y": 0,
        "rotation": 0,
        "scale_x": 1,
        "scale_y": 1,
        "opacity": 1,
        "skew_x": 0,
        "skew_y": 0
      },
      "width": 1440,
      "height": 810,
      "geometry": {
        "type": "Rectangle",
        "width": 1440,
        "height": 810,
        "cornerRadius": 0
      },
      "cornerRadius": 0,
      "children": [],
      "clipContent": true,
      "style": {
        "fill": {
          "type": "Solid",
          "color": "#06080b",
          "opacity": 1
        },
        "effects": [
          {
            "id": "fx_sci-ideal-gas",
            "type": "CustomEffect",
            "name": "Ideal Gas Law",
            "isEnabled": true,
            "params": {},
            "code": "/* Shared by every motion-ui effect (prepended to each one).\n   Rules for this file and the effects: the .bexode compiler minifies effect\n   code, so use explicit semicolons, block comments only, and never write a\n   minus followed by a negative value (it collapses to a decrement). */\n\n/* Read a live state-machine input, falling back to a default. */\nfunction inp(env, name, fallback) {\n  const v = env.inputs ? env.inputs[name] : undefined;\n  if (v === undefined || v === null || v === '') return fallback;\n  if (typeof fallback === 'number') {\n    const n = Number(v);\n    return isFinite(n) ? n : fallback;\n  }\n  if (typeof fallback === 'boolean') return v === true || v === 1 || v === 'true';\n  return v;\n}\n\n/* '#rgb' / '#rrggbb' to [r, g, b]; anything else falls back to white. */\nfunction rgbOf(hex) {\n  let h = String(hex || '').trim().replace('#', '');\n  if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];\n  if (!/^[0-9a-fA-F]{6}$/.test(h)) return [255, 255, 255];\n  const n = parseInt(h, 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction rgba(hex, a) {\n  const c = rgbOf(hex);\n  return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + Math.max(0, Math.min(1, a)) + ')';\n}\n\nfunction mix(a, b, t) {\n  const x = rgbOf(a), y = rgbOf(b);\n  const r = Math.round(x[0] + (y[0] - x[0]) * t);\n  const g = Math.round(x[1] + (y[1] - x[1]) * t);\n  const bl = Math.round(x[2] + (y[2] - x[2]) * t);\n  return '#' + ((1 << 24) + (r << 16) + (g << 8) + bl).toString(16).slice(1);\n}\n\n/* A soft round glow, rendered once per colour and blitted with drawImage\n   (much cheaper than shadowBlur on every particle). */\nconst __glows = {};\nfunction glowSprite(hex) {\n  if (__glows[hex]) return __glows[hex];\n  const s = 64;\n  const c = document.createElement('canvas');\n  c.width = s; c.height = s;\n  const g = c.getContext('2d');\n  const gr = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);\n  gr.addColorStop(0, rgba(hex, 1));\n  gr.addColorStop(0.18, rgba(hex, 0.55));\n  gr.addColorStop(0.45, rgba(hex, 0.14));\n  gr.addColorStop(1, rgba(hex, 0));\n  g.fillStyle = gr;\n  g.fillRect(0, 0, s, s);\n  __glows[hex] = c;\n  return c;\n}\n\nfunction glow(ctx, hex, x, y, r, a) {\n  if (r <= 0 || a <= 0) return;\n  ctx.globalAlpha = Math.min(1, a);\n  ctx.drawImage(glowSprite(hex), x - r, y - r, r * 2, r * 2);\n  ctx.globalAlpha = 1;\n}\n\n/* Frame clock: seconds since start and a clamped delta. */\nfunction tick(S) {\n  const now = performance.now();\n  if (!S.t0) { S.t0 = now; S.last = now; }\n  S.dt = Math.min(0.05, Math.max(0.001, (now - S.last) / 1000));\n  S.last = now;\n  S.age = (now - S.t0) / 1000;\n  return S.dt;\n}\n\n/* Deterministic pseudo-random so layouts are stable across resizes. */\nfunction rng(seed) {\n  let s = seed >>> 0 || 1;\n  return function () {\n    s = (s * 1664525 + 1013904223) >>> 0;\n    return s / 4294967296;\n  };\n}\n\nfunction ease(x) { return x < 0.5 ? 4 * x * x * x : 1 + (Math.pow(2 * x - 2, 3)) / 2; }\nfunction clamp(v, a, b) { return v < a ? a : v > b ? b : v; }\n\nconst SANS = 'Inter,\\u0020Helvetica,\\u0020Arial,\\u0020sans-serif';\nconst MONO = 'ui-monospace,\\u0020Menlo,\\u0020monospace';\n\nfunction roundRect(ctx, x, y, w, h, r) {\n  r = Math.min(r, w / 2, h / 2);\n  ctx.beginPath();\n  ctx.moveTo(x + r, y);\n  ctx.arcTo(x + w, y, x + w, y + h, r);\n  ctx.arcTo(x + w, y + h, x, y + h, r);\n  ctx.arcTo(x, y + h, x, y, r);\n  ctx.arcTo(x, y, x + w, y, r);\n  ctx.closePath();\n}\n\n/* Shared backdrop: flat colour, soft vignette and fine grain. */\nfunction backdrop(ctx, W, H, bg, tint, amt) {\n  ctx.fillStyle = bg;\n  ctx.fillRect(0, 0, W, H);\n  if (tint && amt > 0) {\n    const gr = ctx.createRadialGradient(W / 2, H * 0.45, 0, W / 2, H * 0.45, Math.max(W, H) * 0.7);\n    gr.addColorStop(0, rgba(tint, 0.10 * amt));\n    gr.addColorStop(1, rgba(tint, 0));\n    ctx.fillStyle = gr;\n    ctx.fillRect(0, 0, W, H);\n  }\n  const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.3, W / 2, H / 2, Math.max(W, H) * 0.8);\n  vg.addColorStop(0, 'rgba(0,0,0,0)');\n  vg.addColorStop(1, 'rgba(0,0,0,0.45)');\n  ctx.fillStyle = vg;\n  ctx.fillRect(0, 0, W, H);\n}\n\n/* Greedy word wrap with the current ctx.font. */\nfunction wrap(ctx, text, maxW) {\n  const out = [];\n  String(text).split('\\n').forEach(function (para) {\n    const words = para.split('\\u0020');\n    let cur = '';\n    for (let i = 0; i < words.length; i++) {\n      const next = cur ? cur + '\\u0020' + words[i] : words[i];\n      if (cur && ctx.measureText(next).width > maxW) { out.push(cur); cur = words[i]; } else cur = next;\n    }\n    out.push(cur);\n  });\n  return out;\n}\n\n/* Largest font size (up to max) at which text wraps into maxLines within maxW. */\nfunction fitFont(ctx, text, weight, family, maxW, maxLines, max, min) {\n  let fs = max;\n  while (fs > min) {\n    ctx.font = weight + '\\u0020' + fs + 'px\\u0020' + family;\n    if (wrap(ctx, text, maxW).length <= maxLines) break;\n    fs = fs * 0.92;\n  }\n  ctx.font = weight + '\\u0020' + fs + 'px\\u0020' + family;\n  return fs;\n}\n\n/* Smoothed pointer that idles along a slow figure-eight when the cursor is away. */\nfunction follow(S, env, W, H, k) {\n  const pt = env.pointer || {};\n  const t = S.age || 0;\n  const tx = pt.isHovered ? pt.localX : W / 2 + Math.sin(t * 0.37) * W * 0.3;\n  const ty = pt.isHovered ? pt.localY : H / 2 + Math.sin(t * 0.74) * H * 0.22;\n  if (S.px === undefined) { S.px = tx; S.py = ty; }\n  S.px += (tx - S.px) * Math.min(1, S.dt * (k || 6));\n  S.py += (ty - S.py) * Math.min(1, S.dt * (k || 6));\n  S.inside = !!pt.isHovered;\n  return pt;\n}\n\n/* Optional centred headline over a background effect. */\nfunction title(ctx, W, H, text, sub, ink, accent, bg) {\n  if (!text) return;\n  if (bg) {\n    /* Soft scrim so the headline reads over busy motion. */\n    const sc = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.min(W * 0.5, 560));\n    sc.addColorStop(0, rgba(bg, 0.72));\n    sc.addColorStop(1, rgba(bg, 0));\n    ctx.fillStyle = sc;\n    ctx.fillRect(0, 0, W, H);\n  }\n  ctx.textAlign = 'center';\n  ctx.textBaseline = 'alphabetic';\n  const maxW = Math.min(W * 0.86, 900);\n  const fs = fitFont(ctx, text, 600, SANS, maxW, 2, clamp(W * 0.065, 26, 72), 16);\n  const lines = wrap(ctx, text, maxW);\n  const lh = fs * 1.08;\n  const y0 = H / 2 - (lines.length * lh) / 2 + fs * 0.8 - (sub ? fs * 0.35 : 0);\n  ctx.fillStyle = ink;\n  lines.forEach(function (l, i) { ctx.fillText(l, W / 2, y0 + i * lh); });\n  if (sub) {\n    ctx.font = '500\\u0020' + clamp(fs * 0.24, 11, 15) + 'px\\u0020' + MONO;\n    ctx.fillStyle = rgba(accent, 0.85);\n    ctx.fillText(String(sub).toUpperCase(), W / 2, y0 + (lines.length - 1) * lh + fs * 0.9);\n  }\n}\n\nconst PIECE = { n: '42', total: '57' };\n/* Shared by every motion-graphics piece (prepended after motion-ui/_kit.js).\n   Same minifier rules as the kit: explicit semicolons, block comments only,\n   never a minus followed by a negative value. */\n\nconst SERIF = 'Georgia,\\u0020serif';\n\n/* Value noise and fractal noise, deterministic. */\nconst NZP = (function () {\n  const r = rng(1337);\n  const p = [];\n  for (let i = 0; i < 512; i++) p.push(Math.floor(r() * 256));\n  return p;\n})();\nfunction hash2(x, y) { return NZP[(x & 255) + NZP[y & 255]] / 255; }\nfunction noise2(x, y) {\n  const xi = Math.floor(x), yi = Math.floor(y);\n  const xf = x - xi, yf = y - yi;\n  const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);\n  const a = hash2(xi, yi), b = hash2(xi + 1, yi), c = hash2(xi, yi + 1), d = hash2(xi + 1, yi + 1);\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;\n}\nfunction fbm(x, y) {\n  return noise2(x, y) * 0.55 + noise2(x * 2.03 + 17.1, y * 2.03 + 3.7) * 0.3 + noise2(x * 4.1 + 5.3, y * 4.1 + 41.9) * 0.15;\n}\n\n/* Film grain: one noise tile, re-offset every frame. */\nlet GRAIN = null;\nfunction grain(ctx, W, H, a) {\n  if (a <= 0) return;\n  if (!GRAIN) {\n    const c = document.createElement('canvas');\n    c.width = 160; c.height = 160;\n    const g = c.getContext('2d');\n    const im = g.createImageData(160, 160);\n    for (let i = 0; i < im.data.length; i += 4) {\n      const v = Math.random() * 255;\n      im.data[i] = v; im.data[i + 1] = v; im.data[i + 2] = v; im.data[i + 3] = 255;\n    }\n    g.putImageData(im, 0, 0);\n    GRAIN = ctx.createPattern(c, 'repeat');\n  }\n  const ox = Math.floor(Math.random() * 160), oy = Math.floor(Math.random() * 160);\n  ctx.save();\n  ctx.globalAlpha = a;\n  ctx.globalCompositeOperation = 'overlay';\n  ctx.translate(ox, oy);\n  ctx.fillStyle = GRAIN;\n  ctx.fillRect(0 - ox, 0 - oy, W, H);\n  ctx.restore();\n}\n\n/* Soft edge vignette. */\nfunction vignette(ctx, W, H, a) {\n  const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.75);\n  vg.addColorStop(0, 'rgba(0,0,0,0)');\n  vg.addColorStop(1, 'rgba(0,0,0,' + a + ')');\n  ctx.fillStyle = vg;\n  ctx.fillRect(0, 0, W, H);\n}\n\n/* Portfolio chrome: four mono captions and hairline registration marks. */\nfunction chrome(ctx, W, H, ink, name, caption) {\n  const s = Math.min(W, H);\n  const pad = clamp(s * 0.05, 14, 38);\n  const fs = clamp(s * 0.017, 9, 11);\n  ctx.save();\n  ctx.font = '500\\u0020' + fs + 'px\\u0020' + MONO;\n  ctx.textBaseline = 'middle';\n  ctx.fillStyle = rgba(ink, 0.62);\n  ctx.textAlign = 'left';\n  ctx.fillText(String(name).toUpperCase(), pad, pad);\n  ctx.textAlign = 'right';\n  ctx.fillText(PIECE.n + '\\u0020/\\u0020' + PIECE.total, W - pad, pad);\n  ctx.fillStyle = rgba(ink, 0.4);\n  ctx.textAlign = 'left';\n  if (W > 520) ctx.fillText(String(caption).toUpperCase(), pad, H - pad);\n  ctx.textAlign = 'right';\n  ctx.fillText('EXODE MOTION · 2026', W - pad, H - pad);\n  ctx.strokeStyle = rgba(ink, 0.22);\n  ctx.lineWidth = 1;\n  const m = pad * 0.45, k = 7;\n  ctx.beginPath();\n  ctx.moveTo(m, m + k); ctx.lineTo(m, m); ctx.lineTo(m + k, m);\n  ctx.moveTo(W - m - k, m); ctx.lineTo(W - m, m); ctx.lineTo(W - m, m + k);\n  ctx.moveTo(m, H - m - k); ctx.lineTo(m, H - m); ctx.lineTo(m + k, H - m);\n  ctx.moveTo(W - m - k, H - m); ctx.lineTo(W - m, H - m); ctx.lineTo(W - m, H - m - k);\n  ctx.stroke();\n  ctx.restore();\n}\n\n/* Spaced display text (letterSpacing where supported). */\nfunction spaced(ctx, text, x, y, em) {\n  ctx.letterSpacing = em + 'em';\n  ctx.fillText(text, x, y);\n  ctx.letterSpacing = '0px';\n}\n\n/* Backing-store scale of the canvas, for offscreen buffers (max 2). */\nfunction dprOf(ctx) {\n  const m = ctx.getTransform ? ctx.getTransform() : null;\n  return m ? clamp(Math.sqrt(m.a * m.a + m.b * m.b), 1, 2) : 1;\n}\n\n/* One pointer press per click. */\nfunction pressed(S, pt) {\n  const p = !!pt.isDown && !S.wasDown && !!pt.isHovered;\n  S.wasDown = !!pt.isDown;\n  return p;\n}\n\n/* ---------- Product launch helpers ---------- */\n\n/* Where the product sits: right of the copy on wide frames, above it on tall ones. */\nfunction productStage(W, H) {\n  const land = W > H * 1.05;\n  return land\n    ? { land: true, cx: W * 0.67, cy: H * 0.52, size: Math.min(W * 0.5, H * 0.78) }\n    : { land: false, cx: W / 2, cy: H * 0.32, size: Math.min(W * 0.88, H * 0.46) };\n}\n\n/* Studio sweep: spotlight behind the product and a soft floor. */\nfunction studio(ctx, W, H, bg, tint, st) {\n  ctx.fillStyle = bg;\n  ctx.fillRect(0, 0, W, H);\n  const r = st.size * 1.1;\n  const g = ctx.createRadialGradient(st.cx, st.cy, 0, st.cx, st.cy, r);\n  g.addColorStop(0, rgba(tint, 0.22));\n  g.addColorStop(0.5, rgba(tint, 0.06));\n  g.addColorStop(1, rgba(tint, 0));\n  ctx.fillStyle = g;\n  ctx.fillRect(0, 0, W, H);\n  const fy = st.cy + st.size * 0.42;\n  const fl = ctx.createLinearGradient(0, fy, 0, H);\n  fl.addColorStop(0, rgba(tint, 0.05));\n  fl.addColorStop(1, rgba(tint, 0));\n  ctx.fillStyle = fl;\n  ctx.fillRect(0, fy, W, H - fy);\n}\n\n/* Soft contact shadow. */\nfunction floorShadow(ctx, x, y, w, a) {\n  ctx.save();\n  ctx.translate(x, y);\n  ctx.scale(1, 0.16);\n  const g = ctx.createRadialGradient(0, 0, 0, 0, 0, w);\n  g.addColorStop(0, 'rgba(0,0,0,' + a + ')');\n  g.addColorStop(1, 'rgba(0,0,0,0)');\n  ctx.fillStyle = g;\n  ctx.fillRect(0 - w, 0 - w, w * 2, w * 2);\n  ctx.restore();\n}\n\n/* Launch copy block: eyebrow, name, tagline, three specs and a CTA pill.\n   Staggers in with `appear` (seconds since start) and records CTA hover on S. */\nfunction productCopy(ctx, W, H, o, S, env) {\n  const s = Math.min(W, H);\n  const land = W > H * 1.05;\n  const pad = clamp(s * 0.09, 20, 96);\n  const cw = land ? Math.min(W * 0.34, 500) : W - pad * 2;\n  /* Small frames (cards, thumbnails) drop the specs and shorten the tagline. */\n  const compact = cw < 220 || H < 300;\n  const fe = clamp(s * 0.016, 7, 12);\n  const nameFs = fitFont(ctx, o.name, 600, SANS, cw, 2, clamp(s * 0.085, 16, 78), 12);\n  const nl = wrap(ctx, o.name, cw);\n  const ft = clamp(s * 0.024, 9, 18);\n  ctx.font = '400\\u0020' + ft + 'px\\u0020' + SANS;\n  const tl = wrap(ctx, o.tagline, cw).slice(0, compact ? 2 : 3);\n  const fv = clamp(s * 0.034, 12, 26);\n  const specs = compact ? [] : (o.specs || []).slice(0, 3);\n  const ch = fe * 3.6;\n  const total = fe * 2.6 + nl.length * nameFs * 1.02 + ft * 0.9 + tl.length * ft * 1.5 + (specs.length ? fv * 3.4 : 0) + ch + fe;\n  let y = land ? H / 2 - total / 2 : H - pad - total;\n  const x = pad;\n  const reveal = function (i) {\n    const k = ease(clamp(((o.appear || 9) - 0.15 - i * 0.12) / 0.7, 0, 1));\n    ctx.globalAlpha = k;\n    return (1 - k) * 18;\n  };\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'alphabetic';\n\n  let d = reveal(0);\n  ctx.font = '600\\u0020' + fe + 'px\\u0020' + MONO;\n  ctx.fillStyle = o.accent;\n  spaced(ctx, String(o.eyebrow).toUpperCase(), x, y + fe + d, 0.22);\n  y += fe * 2.6;\n\n  d = reveal(1);\n  ctx.font = '600\\u0020' + nameFs + 'px\\u0020' + SANS;\n  ctx.fillStyle = o.ink;\n  ctx.letterSpacing = '-0.025em';\n  for (let i = 0; i < nl.length; i++) ctx.fillText(nl[i], x, y + nameFs * 0.86 + i * nameFs * 1.02 + d);\n  ctx.letterSpacing = '0px';\n  y += nl.length * nameFs * 1.02 + ft * 0.9;\n\n  d = reveal(2);\n  ctx.font = '400\\u0020' + ft + 'px\\u0020' + SANS;\n  ctx.fillStyle = rgba(o.ink, 0.62);\n  for (let i = 0; i < tl.length; i++) ctx.fillText(tl[i], x, y + ft + i * ft * 1.5 + d);\n  y += tl.length * ft * 1.5;\n\n  if (specs.length) {\n    d = reveal(3);\n    y += fv * 0.9;\n    ctx.fillStyle = rgba(o.ink, 0.14);\n    ctx.fillRect(x, y + d, cw, 1);\n    const colw = cw / 3;\n    for (let i = 0; i < specs.length; i++) {\n      ctx.font = '600\\u0020' + fv + 'px\\u0020' + SANS;\n      ctx.fillStyle = o.ink;\n      ctx.fillText(String(specs[i][0]), x + i * colw, y + fv * 1.5 + d);\n      ctx.font = '500\\u0020' + fe * 0.9 + 'px\\u0020' + MONO;\n      ctx.fillStyle = rgba(o.ink, 0.45);\n      ctx.fillText(String(specs[i][1]).toUpperCase(), x + i * colw, y + fv * 1.5 + fe * 1.7 + d);\n    }\n    y += fv * 2.5;\n  }\n\n  d = reveal(4);\n  ctx.font = '600\\u0020' + fe * 1.2 + 'px\\u0020' + SANS;\n  const label = String(o.cta);\n  const bw = ctx.measureText(label).width + fe * 4;\n  const by = y + d;\n  const pt = env.pointer || {};\n  const hot = !!pt.isHovered && pt.localX >= x && pt.localX <= x + bw && pt.localY >= by && pt.localY <= by + ch;\n  S.ctaHot = hot;\n  S.ctaK = (S.ctaK || 0) + ((hot ? 1 : 0) - (S.ctaK || 0)) * 0.2;\n  ctx.fillStyle = mix(o.accent, '#ffffff', S.ctaK * 0.18);\n  roundRect(ctx, x, by, bw, ch, ch / 2);\n  ctx.fill();\n  ctx.fillStyle = o.ctaInk;\n  ctx.textBaseline = 'middle';\n  ctx.fillText(label, x + fe * 2, by + ch / 2 + 1);\n  ctx.font = '500\\u0020' + fe * 1.1 + 'px\\u0020' + SANS;\n  ctx.fillStyle = rgba(o.ink, 0.7);\n  if (cw > bw + 120) ctx.fillText('Learn more\\u0020\\u0020→', x + bw + fe * 2, by + ch / 2 + 1);\n  ctx.textBaseline = 'alphabetic';\n  ctx.globalAlpha = 1;\n}\n\nconst PRESET = {\"buttons\":[{\"id\":\"reset\",\"label\":\"Reset\"}],\"mode\":\"gas\",\"eyebrow\":\"Molecular dynamics · Kinetic theory\",\"sub\":\"Argon atoms in a piston. Pressure is nothing but wall impacts,\\u0020and the speed histogram settles onto the Maxwell–Boltzmann curve.\",\"hint\":\"Drag the piston · tap to heat · grab a molecule\",\"title\":\"Ideal Gas Law\",\"no\":\"001\"};\nconst CONTROLS = [{\"name\":\"temp\",\"label\":\"Temperature\",\"type\":\"number\",\"value\":300,\"min\":40,\"max\":900,\"step\":10,\"unit\":\"K\"},{\"name\":\"count\",\"label\":\"Molecules\",\"type\":\"number\",\"value\":160,\"min\":20,\"max\":500,\"step\":10},{\"name\":\"volume\",\"label\":\"Volume\",\"type\":\"number\",\"value\":100,\"min\":30,\"max\":100,\"step\":1,\"unit\":\"%\"},{\"name\":\"isothermal\",\"label\":\"Thermostat\\u0020(isothermal)\",\"type\":\"boolean\",\"value\":true},{\"name\":\"tracer\",\"label\":\"Trace one molecule\",\"type\":\"boolean\",\"value\":false},{\"name\":\"speed\",\"label\":\"Sim speed\",\"type\":\"number\",\"value\":1,\"min\":0.1,\"max\":3,\"step\":0.1,\"unit\":\"×\"}];\n/* Science series kit (prepended after _frame.js, before each engine).\n   A lab-instrument layout: the simulation viewport plus a control panel\n   (right on wide frames, below on tall ones) with live sliders, switches,\n   segmented choices, buttons, readouts and a plot. Every control mirrors a\n   state-machine input of the same name, so values can also be set from the\n   editor or a host page. CONTROLS and PRESET are injected per template. */\n\nfunction sciLayout(W, H) {\n  const land = W > H * 1.05;\n  const s = Math.min(W, H);\n  if (land) {\n    const pw = Math.round(clamp(W * 0.25, 168, 340));\n    return { land: true, s: s, W: W, H: H, sim: { x: 0, y: 0, w: W - pw, h: H }, panel: { x: W - pw, y: 0, w: pw, h: H } };\n  }\n  const ph = Math.round(clamp(H * 0.42, 190, 520));\n  return { land: false, s: s, W: W, H: H, sim: { x: 0, y: 0, w: W, h: H - ph }, panel: { x: 0, y: H - ph, w: W, h: ph } };\n}\n\n/* Where the experiment sits inside the viewport, below the header. */\nfunction sciStage(L) {\n  const s = Math.min(L.sim.w, L.sim.h);\n  const pad = clamp(s * 0.06, 12, 44);\n  const top = L.sim.y + clamp(L.sim.h * 0.2, 54, 170);\n  const bot = L.sim.y + L.sim.h - clamp(s * 0.12, 30, 84);\n  return { x: L.sim.x + pad, y: top, w: L.sim.w - pad * 2, h: Math.max(40, bot - top) };\n}\n\n/* Values (synced from inputs whenever an input changes) and pointer routing:\n   a press that starts in the panel belongs to the panel until release. */\nfunction sciFrame(S, env, L) {\n  if (!S.ui) S.ui = { v: {}, seen: {}, drag: null, cap: null, was: false, fire: null, fired: null };\n  const U = S.ui;\n  for (let i = 0; i < CONTROLS.length; i++) {\n    const c = CONTROLS[i];\n    const iv = inp(env, c.name, c.type === 'boolean' ? !!c.value : c.value);\n    if (U.seen[c.name] !== iv) { U.seen[c.name] = iv; U.v[c.name] = iv; }\n  }\n  const pt = env.pointer || {};\n  const hov = !!pt.isHovered;\n  const down = hov && !!pt.isDown;\n  const x = pt.localX || 0, y = pt.localY || 0;\n  const P = L.panel;\n  const inPanel = hov && x >= P.x && x <= P.x + P.w && y >= P.y && y <= P.y + P.h;\n  U.click = down && !U.was;\n  const up = !down && U.was;\n  if (U.click) U.cap = inPanel ? 'panel' : 'sim';\n  const cap = U.cap;\n  if (!down) { U.cap = null; U.drag = null; }\n  U.was = down;\n  U.fired = U.fire;\n  U.fire = null;\n  U.x = x; U.y = y; U.hov = hov; U.down = down;\n  return {\n    v: U.v,\n    fired: U.fired,\n    p: { x: x, y: y, over: hov && !inPanel && cap !== 'panel', down: down && cap === 'sim', click: U.click && cap === 'sim', up: up && cap === 'sim' },\n  };\n}\n\nfunction sciDec(step) { return step >= 1 ? 0 : step >= 0.1 ? 1 : step >= 0.01 ? 2 : 3; }\nfunction sciFmt(v, d) {\n  if (typeof v !== 'number') return String(v);\n  if (!isFinite(v)) return '—';\n  const a = Math.abs(v);\n  if (d === undefined) d = a >= 100 ? 0 : a >= 10 ? 1 : 2;\n  return v.toFixed(d);\n}\n\n/* Cached shaded sphere sprite per colour. */\nconst __balls = {};\nfunction ballSprite(hex) {\n  if (__balls[hex]) return __balls[hex];\n  const n = 48;\n  const c = document.createElement('canvas');\n  c.width = n; c.height = n;\n  const g = c.getContext('2d');\n  const gr = g.createRadialGradient(n * 0.36, n * 0.34, n * 0.02, n / 2, n / 2, n / 2);\n  gr.addColorStop(0, mix(hex, '#ffffff', 0.7));\n  gr.addColorStop(0.35, hex);\n  gr.addColorStop(1, mix(hex, '#000000', 0.55));\n  g.fillStyle = gr;\n  g.beginPath(); g.arc(n / 2, n / 2, n / 2 - 0.5, 0, Math.PI * 2); g.fill();\n  __balls[hex] = c;\n  return c;\n}\nfunction ball(ctx, hex, x, y, r) {\n  if (r < 1.1) { ctx.fillStyle = hex; ctx.fillRect(x - r, y - r, r * 2, r * 2); return; }\n  ctx.drawImage(ballSprite(hex), x - r, y - r, r * 2, r * 2);\n}\n\n/* Perceptual cold-to-hot ramp, quantised so sprites stay cached. */\nconst HEAT = ['#2f5bff', '#3fa2ff', '#4fe0ff', '#9ef5c8', '#ffe27a', '#ffa64a', '#ff5a3c'];\nfunction heat(t) {\n  const u = clamp(t, 0, 0.999) * (HEAT.length - 1);\n  const i = Math.floor(u);\n  return mix(HEAT[i], HEAT[i + 1], Math.round((u - i) * 4) / 4);\n}\n\n/* Instrument backdrop: flat field, faint measuring grid, vignette. */\nfunction sciBack(ctx, L, bg, ink) {\n  ctx.fillStyle = bg;\n  ctx.fillRect(0, 0, L.W, L.H);\n  const g = clamp(L.s * 0.05, 16, 40);\n  ctx.fillStyle = rgba(ink, 0.045);\n  for (let x = L.sim.x + g; x < L.sim.x + L.sim.w; x += g) for (let y = L.sim.y + g; y < L.sim.y + L.sim.h; y += g) ctx.fillRect(x, y, 1, 1);\n  const vg = ctx.createRadialGradient(L.sim.x + L.sim.w / 2, L.sim.y + L.sim.h / 2, Math.min(L.sim.w, L.sim.h) * 0.3, L.sim.x + L.sim.w / 2, L.sim.y + L.sim.h / 2, Math.max(L.sim.w, L.sim.h) * 0.75);\n  vg.addColorStop(0, 'rgba(0,0,0,0)');\n  vg.addColorStop(1, 'rgba(0,0,0,0.35)');\n  ctx.fillStyle = vg;\n  ctx.fillRect(L.sim.x, L.sim.y, L.sim.w, L.sim.h);\n}\n\n/* Header over the viewport: field eyebrow, title, one-line description, and\n   an interaction hint at the bottom. */\nfunction sciHeader(ctx, L, o) {\n  const s = Math.min(L.sim.w, L.sim.h);\n  const pad = clamp(s * 0.06, 12, 44);\n  const fe = clamp(s * 0.018, 8, 11);\n  const x = L.sim.x + pad;\n  let y = L.sim.y + pad;\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'alphabetic';\n  ctx.font = '600\\u0020' + fe + 'px\\u0020' + MONO;\n  ctx.fillStyle = o.accent;\n  spaced(ctx, String(o.eyebrow).toUpperCase(), x, y + fe, 0.18);\n  ctx.textAlign = 'right';\n  ctx.fillStyle = rgba(o.ink, 0.4);\n  if (L.sim.w > 360) spaced(ctx, 'SCI ·\\u0020' + PRESET.no + '\\u0020/\\u0020100', L.sim.x + L.sim.w - pad, y + fe, 0.14);\n  ctx.textAlign = 'left';\n  y += fe * 1.6;\n  const maxW = Math.min(L.sim.w - pad * 2, Math.max(260, L.sim.w * 0.62));\n  const fs = fitFont(ctx, o.title, 600, SANS, maxW, 1, clamp(s * 0.06, 16, 44), 12);\n  ctx.fillStyle = o.ink;\n  ctx.letterSpacing = '-0.02em';\n  ctx.fillText(o.title, x, y + fs * 0.95);\n  ctx.letterSpacing = '0px';\n  y += fs * 1.15;\n  const fsub = clamp(s * 0.022, 10, 15);\n  ctx.font = '400\\u0020' + fsub + 'px\\u0020' + SANS;\n  ctx.fillStyle = rgba(o.ink, 0.55);\n  if (o.sub && L.sim.h > 220) {\n    const lines = wrap(ctx, o.sub, Math.min(maxW, 660)).slice(0, L.sim.w > 520 ? 2 : 1);\n    for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], x, y + fsub * 1.1 + i * fsub * 1.4);\n  }\n  if (o.hint && L.sim.h > 200) {\n    ctx.font = '500\\u0020' + fe + 'px\\u0020' + MONO;\n    ctx.fillStyle = rgba(o.ink, 0.38);\n    spaced(ctx, String(o.hint).toUpperCase(), x, L.sim.y + L.sim.h - pad * 0.7, 0.14);\n  }\n}\n\n/* A small label chip used on the viewport (annotations, callouts). */\nfunction sciTag(ctx, text, x, y, ink, bg, fs, align) {\n  ctx.font = '500\\u0020' + fs + 'px\\u0020' + MONO;\n  const w = ctx.measureText(text).width + fs * 1.2;\n  const h = fs * 1.9;\n  const x0 = align === 'right' ? x - w : align === 'center' ? x - w / 2 : x;\n  ctx.fillStyle = rgba(bg, 0.82);\n  roundRect(ctx, x0, y - h / 2, w, h, h / 2);\n  ctx.fill();\n  ctx.strokeStyle = rgba(ink, 0.18);\n  ctx.lineWidth = 1;\n  ctx.stroke();\n  ctx.fillStyle = ink;\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'middle';\n  ctx.fillText(text, x0 + fs * 0.6, y + 0.5);\n  ctx.textBaseline = 'alphabetic';\n}\n\n/* Plot: line series (uniform `d` or `pts` pairs), bar series, and an optional\n   reference curve. G: { title, kind, series:[{d|pts|bars, c, name}], y0, y1, x0, x1, xl, yl } */\nfunction sciPlot(ctx, x, y, w, h, G, ink, fl) {\n  ctx.save();\n  ctx.font = '500\\u0020' + fl * 0.85 + 'px\\u0020' + MONO;\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'alphabetic';\n  ctx.fillStyle = rgba(ink, 0.5);\n  spaced(ctx, String(G.title || '').toUpperCase(), x, y + fl * 0.85, 0.12);\n  /* Legend, right-aligned; drops to its own line when it would hit the title. */\n  const tw = ctx.measureText(String(G.title || '').toUpperCase()).width * 1.25 + fl;\n  let lw = 0;\n  (G.series || []).forEach(function (sr) { if (sr.name) lw += ctx.measureText(sr.name).width + fl * 1.9; });\n  const below = tw + lw > w;\n  const ly = below ? y + fl * 2.1 : y + fl * 0.85;\n  ctx.textAlign = 'right';\n  let lx = x + w;\n  for (let i = (G.series || []).length - 1; i >= 0; i--) {\n    const sr = G.series[i];\n    if (!sr.name) continue;\n    ctx.fillStyle = rgba(ink, 0.55);\n    ctx.fillText(sr.name, lx, ly);\n    lx -= ctx.measureText(sr.name).width + fl * 0.5;\n    ctx.fillStyle = sr.c;\n    ctx.fillRect(lx - fl * 0.6, ly - fl * 0.55, fl * 0.6, fl * 0.6);\n    lx -= fl * 1.4;\n  }\n  if (below) { y += fl * 1.25; h -= fl * 1.25; }\n  const py = y + fl * 1.6, ph = h - fl * 2.9, px = x, pw = w;\n  if (ph < 12) { ctx.restore(); return; }\n  ctx.fillStyle = rgba(ink, 0.03);\n  ctx.fillRect(px, py, pw, ph);\n  ctx.strokeStyle = rgba(ink, 0.08);\n  ctx.lineWidth = 1;\n  ctx.beginPath();\n  for (let k = 1; k < 4; k++) { ctx.moveTo(px, py + ph * k / 4); ctx.lineTo(px + pw, py + ph * k / 4); }\n  ctx.stroke();\n  ctx.strokeStyle = rgba(ink, 0.22);\n  ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, py + ph); ctx.lineTo(px + pw, py + ph); ctx.stroke();\n  /* Ranges. */\n  let y0 = G.y0, y1 = G.y1;\n  if (y0 === undefined || y1 === undefined) {\n    let lo = 1e9, hi = -1e9;\n    (G.series || []).forEach(function (sr) {\n      const arr = sr.d || sr.bars || (sr.pts ? sr.pts.map(function (q) { return q[1]; }) : []);\n      for (let i = 0; i < arr.length; i++) { if (arr[i] < lo) lo = arr[i]; if (arr[i] > hi) hi = arr[i]; }\n    });\n    if (lo > hi) { lo = 0; hi = 1; }\n    if (hi - lo < 1e-9) { hi += 0.5; lo -= 0.5; }\n    if (y0 === undefined) y0 = lo - (hi - lo) * 0.08;\n    if (y1 === undefined) y1 = hi + (hi - lo) * 0.08;\n  }\n  const x0 = G.x0 === undefined ? 0 : G.x0, x1 = G.x1 === undefined ? 1 : G.x1;\n  const Y = function (v) { return py + ph - clamp((v - y0) / (y1 - y0), 0, 1) * ph; };\n  const X = function (v) { return px + clamp((v - x0) / (x1 - x0), 0, 1) * pw; };\n  ctx.beginPath(); ctx.rect(px, py, pw, ph); ctx.clip();\n  (G.series || []).forEach(function (sr) {\n    if (sr.bars) {\n      const n = sr.bars.length, bw = pw / n;\n      ctx.fillStyle = rgba(sr.c, sr.a === undefined ? 0.55 : sr.a);\n      for (let i = 0; i < n; i++) { const t = Y(sr.bars[i]); ctx.fillRect(px + i * bw + 0.5, t, Math.max(1, bw - 1), py + ph - t); }\n      return;\n    }\n    ctx.strokeStyle = sr.c;\n    ctx.lineWidth = sr.w || 1.6;\n    ctx.setLineDash(sr.dash ? [4, 4] : []);\n    ctx.beginPath();\n    if (sr.pts) {\n      for (let i = 0; i < sr.pts.length; i++) { const q = sr.pts[i]; if (i) ctx.lineTo(X(q[0]), Y(q[1])); else ctx.moveTo(X(q[0]), Y(q[1])); }\n    } else if (sr.d && sr.d.length) {\n      const n = sr.d.length, span = Math.max(n - 1, sr.n ? sr.n - 1 : 1);\n      for (let i = 0; i < n; i++) { const xx = px + (i / span) * pw; if (i) ctx.lineTo(xx, Y(sr.d[i])); else ctx.moveTo(xx, Y(sr.d[i])); }\n    }\n    ctx.stroke();\n    ctx.setLineDash([]);\n    if (sr.dot && sr.d && sr.d.length) {\n      const n = sr.d.length, span = Math.max(n - 1, sr.n ? sr.n - 1 : 1);\n      glow(ctx, sr.c, px + ((n - 1) / span) * pw, Y(sr.d[n - 1]), fl * 0.9, 0.9);\n    }\n  });\n  if (G.marker !== undefined) {\n    ctx.strokeStyle = rgba(ink, 0.4);\n    ctx.setLineDash([3, 3]);\n    ctx.beginPath(); ctx.moveTo(X(G.marker), py); ctx.lineTo(X(G.marker), py + ph); ctx.stroke();\n    ctx.setLineDash([]);\n  }\n  ctx.restore();\n  ctx.font = '500\\u0020' + fl * 0.75 + 'px\\u0020' + MONO;\n  ctx.fillStyle = rgba(ink, 0.38);\n  ctx.textAlign = 'left';\n  if (G.xl) ctx.fillText(G.xl, px, py + ph + fl * 1.1);\n  ctx.textAlign = 'right';\n  if (G.yl) ctx.fillText(G.yl, px + pw, py + ph + fl * 1.1);\n  ctx.textAlign = 'left';\n}\n\n/* Control panel. o: { ink, accent, bg, readouts: [[label, value, unit]], graph } */\nfunction sciPanel(ctx, S, L, o) {\n  const U = S.ui;\n  const P = L.panel;\n  const ink = o.ink, A = o.accent;\n  ctx.save();\n  ctx.fillStyle = mix(o.bg, ink, 0.035);\n  ctx.fillRect(P.x, P.y, P.w, P.h);\n  ctx.fillStyle = rgba(ink, 0.1);\n  if (L.land) ctx.fillRect(P.x, P.y, 1, P.h); else ctx.fillRect(P.x, P.y, P.w, 1);\n\n  const twoCol = !L.land && P.w >= 560;\n  const p = clamp(Math.min(P.w, P.h) * 0.07, 10, 24);\n  const colW = twoCol ? (P.w - p * 3) / 2 : P.w - p * 2;\n  const ctrls = CONTROLS.filter(function (c) { return !c.hidden; });\n  const btns = PRESET.buttons || [];\n  const R = o.readouts || [];\n  /* Size everything from a base font, shrinking until the controls fit. */\n  let fl = clamp(Math.min(colW * 0.042, L.s * 0.016), 8, 12);\n  const rowH = function (c) { return c.type === 'boolean' ? fl * 2.5 : c.options ? fl * 4.2 : fl * 3.5; };\n  const need = function () {\n    let h = fl * 2.4;\n    for (let i = 0; i < ctrls.length; i++) h += rowH(ctrls[i]);\n    if (btns.length) h += Math.ceil(btns.length / 2) * fl * 3;\n    return h;\n  };\n  const room = P.h - p * 2;\n  for (let k = 0; k < 8 && need() > (twoCol ? room : room * 0.72) && fl > 7; k++) fl *= 0.9;\n  if (fl < 7) fl = 7;\n\n  let cx = P.x + p, cy = P.y + p;\n  const section = function (label, x, y, w) {\n    ctx.font = '600\\u0020' + fl * 0.8 + 'px\\u0020' + MONO;\n    ctx.textAlign = 'left';\n    ctx.fillStyle = rgba(ink, 0.42);\n    spaced(ctx, label, x, y + fl * 0.8, 0.16);\n    ctx.fillStyle = rgba(ink, 0.08);\n    ctx.fillRect(x, y + fl * 1.4, w, 1);\n    return y + fl * 2.4;\n  };\n  const hit = function (x, y, w, h) { return U.hov && U.x >= x && U.x <= x + w && U.y >= y && U.y <= y + h; };\n\n  cy = section('PARAMETERS', cx, cy, colW);\n  for (let i = 0; i < ctrls.length; i++) {\n    const c = ctrls[i];\n    const v = U.v[c.name];\n    ctx.textBaseline = 'alphabetic';\n    if (c.type === 'boolean') {\n      const sw = fl * 2.6, sh = fl * 1.4, sx = cx + colW - sw, sy = cy + fl * 0.3;\n      if (U.click && U.cap === 'panel' && hit(cx, cy, colW, rowH(c))) U.v[c.name] = !v;\n      const on = !!U.v[c.name];\n      ctx.font = '500\\u0020' + fl + 'px\\u0020' + SANS;\n      ctx.fillStyle = rgba(ink, 0.82);\n      ctx.textAlign = 'left';\n      ctx.fillText(c.label, cx, cy + fl * 1.3);\n      ctx.fillStyle = on ? A : rgba(ink, 0.14);\n      roundRect(ctx, sx, sy, sw, sh, sh / 2);\n      ctx.fill();\n      ctx.fillStyle = on ? o.bg : rgba(ink, 0.7);\n      ctx.beginPath(); ctx.arc(on ? sx + sw - sh / 2 : sx + sh / 2, sy + sh / 2, sh * 0.36, 0, Math.PI * 2); ctx.fill();\n    } else if (c.options) {\n      ctx.font = '500\\u0020' + fl + 'px\\u0020' + SANS;\n      ctx.fillStyle = rgba(ink, 0.82);\n      ctx.textAlign = 'left';\n      ctx.fillText(c.label, cx, cy + fl);\n      const n = c.options.length, by = cy + fl * 1.6, bh = fl * 2, bw = colW / n;\n      ctx.fillStyle = rgba(ink, 0.06);\n      roundRect(ctx, cx, by, colW, bh, fl * 0.5);\n      ctx.fill();\n      for (let k = 0; k < n; k++) {\n        const bx = cx + k * bw;\n        if (U.click && U.cap === 'panel' && hit(bx, by, bw, bh)) U.v[c.name] = k;\n        const on = Math.round(U.v[c.name]) === k;\n        if (on) { ctx.fillStyle = A; roundRect(ctx, bx + 2, by + 2, bw - 4, bh - 4, fl * 0.4); ctx.fill(); }\n        ctx.font = (on ? '600\\u0020' : '500\\u0020') + fl * 0.85 + 'px\\u0020' + SANS;\n        ctx.fillStyle = on ? o.bg : rgba(ink, 0.7);\n        ctx.textAlign = 'center';\n        ctx.textBaseline = 'middle';\n        let lab = c.options[k];\n        while (lab.length > 2 && ctx.measureText(lab).width > bw - 6) lab = lab.slice(0, lab.length - 2) + '.';\n        ctx.fillText(lab, bx + bw / 2, by + bh / 2 + 0.5);\n        ctx.textBaseline = 'alphabetic';\n      }\n    } else {\n      const tx = cx, tw = colW, ty = cy + fl * 2.3;\n      if (U.click && U.cap === 'panel' && hit(cx, cy, colW, rowH(c))) U.drag = c.name;\n      if (U.drag === c.name && U.down) {\n        let nv = c.min + clamp((U.x - tx) / tw, 0, 1) * (c.max - c.min);\n        nv = Math.round(nv / c.step) * c.step;\n        U.v[c.name] = Number(clamp(nv, c.min, c.max).toFixed(sciDec(c.step) + 1));\n      }\n      const val = Number(U.v[c.name]);\n      const k = clamp((val - c.min) / (c.max - c.min), 0, 1);\n      const act = U.drag === c.name;\n      ctx.font = '500\\u0020' + fl + 'px\\u0020' + SANS;\n      ctx.fillStyle = rgba(ink, 0.82);\n      ctx.textAlign = 'left';\n      ctx.fillText(c.label, cx, cy + fl * 1.1);\n      ctx.font = '500\\u0020' + fl * 0.95 + 'px\\u0020' + MONO;\n      ctx.fillStyle = act ? A : rgba(ink, 0.6);\n      ctx.textAlign = 'right';\n      ctx.fillText(sciFmt(val, sciDec(c.step)) + (c.unit ? '\\u0020' + c.unit : ''), cx + colW, cy + fl * 1.1);\n      ctx.fillStyle = rgba(ink, 0.12);\n      ctx.fillRect(tx, ty, tw, 2);\n      ctx.fillStyle = A;\n      ctx.fillRect(tx, ty, tw * k, 2);\n      ctx.beginPath(); ctx.arc(tx + tw * k, ty + 1, fl * (act ? 0.62 : 0.5), 0, Math.PI * 2);\n      ctx.fillStyle = act ? A : mix(o.bg, ink, 0.9);\n      ctx.fill();\n      if (act) glow(ctx, A, tx + tw * k, ty + 1, fl * 2, 0.5);\n    }\n    cy += rowH(c);\n  }\n  if (btns.length) {\n    const bw = (colW - fl * 0.8) / Math.min(2, btns.length), bh = fl * 2.3;\n    for (let i = 0; i < btns.length; i++) {\n      const bx = cx + (i % 2) * (bw + fl * 0.8), by = cy + Math.floor(i / 2) * fl * 3 + fl * 0.3;\n      const h = hit(bx, by, bw, bh);\n      if (U.click && U.cap === 'panel' && h) U.fire = btns[i].id;\n      const primary = i === 0;\n      ctx.fillStyle = primary ? (h ? mix(A, '#ffffff', 0.15) : A) : (h ? rgba(ink, 0.12) : rgba(ink, 0.06));\n      roundRect(ctx, bx, by, bw, bh, bh / 2);\n      ctx.fill();\n      ctx.font = '600\\u0020' + fl * 0.9 + 'px\\u0020' + SANS;\n      ctx.fillStyle = primary ? o.bg : rgba(ink, 0.85);\n      ctx.textAlign = 'center';\n      ctx.textBaseline = 'middle';\n      ctx.fillText(btns[i].label, bx + bw / 2, by + bh / 2 + 0.5);\n      ctx.textBaseline = 'alphabetic';\n    }\n    cy += Math.ceil(btns.length / 2) * fl * 3;\n  }\n\n  /* Measurements and plot: second column when two-column, else below. */\n  if (twoCol) { cx = P.x + p * 2 + colW; cy = P.y + p; } else cy += fl * 0.8;\n  const bottom = P.y + P.h - p;\n  if (R.length && bottom - cy > fl * 5) {\n    cy = section('MEASUREMENTS', cx, cy, colW);\n    const cols = colW > 300 ? 3 : 2;\n    const cwid = colW / cols;\n    const rh = fl * 3.4;\n    const rows = Math.min(Math.ceil(R.length / cols), Math.max(1, Math.floor((bottom - cy) / rh)));\n    for (let i = 0; i < Math.min(R.length, rows * cols); i++) {\n      const rx = cx + (i % cols) * cwid, ry = cy + Math.floor(i / cols) * rh;\n      ctx.textAlign = 'left';\n      ctx.font = '500\\u0020' + fl * 0.75 + 'px\\u0020' + MONO;\n      ctx.fillStyle = rgba(ink, 0.45);\n      ctx.fillText(String(R[i][0]).toUpperCase(), rx, ry + fl * 0.8);\n      ctx.font = '600\\u0020' + fl * 1.45 + 'px\\u0020' + SANS;\n      ctx.fillStyle = R[i][3] || ink;\n      const val = String(R[i][1]);\n      ctx.fillText(val, rx, ry + fl * 2.55);\n      if (R[i][2]) {\n        const vw = ctx.measureText(val).width;\n        ctx.font = '500\\u0020' + fl * 0.8 + 'px\\u0020' + MONO;\n        ctx.fillStyle = rgba(ink, 0.45);\n        ctx.fillText(R[i][2], rx + vw + fl * 0.35, ry + fl * 2.55);\n      }\n    }\n    cy += rows * rh + fl * 0.6;\n  }\n  if (o.graph && bottom - cy > fl * 8) {\n    sciPlot(ctx, cx, cy, colW, Math.min(bottom - cy, colW * 0.85), o.graph, ink, fl);\n  }\n  ctx.restore();\n}\n\n/* Science engine · Molecular dynamics. 2D particles under a Lennard-Jones\n   (or purely repulsive WCA) potential, integrated with velocity Verlet on a\n   cell list, with reflecting or thermal walls, a movable piston, barriers and\n   semipermeable membranes, gravity and a Berendsen thermostat.\n   Reduced units (σ = 1, ε = 1, m = 1), shown as argon: ε/k = 120 K,\n   σ = 0.34 nm, velocity unit 158 m/s, time unit 2.15 ps.\n   PRESET.mode: gas | liquid | crystal | phase | diffusion | brownian | osmosis | evaporation. */\nlet S = null;\nconst MODE = PRESET.mode;\nconst KT = 120;\nconst VUNIT = 158;\nconst TAU = 2.15;\nconst SIG_NM = 0.34;\nconst ARR = ['x', 'y', 'vx', 'vy', 'fx', 'fy', 'sp', 'nb', 'nbs'];\n\nfunction gauss() {\n  let u = 0;\n  while (u === 0) u = Math.random();\n  return Math.sqrt(0 - 2 * Math.log(u)) * Math.cos(6.283185 * Math.random());\n}\n\nfunction mdNew(BW, BH) {\n  const M = { n: 0, BW: BW, BH: BH, wallR: BW, wallV: 0, sig: [1, 1], mass: [1, 1], lj: false, eps: 1, g: 0, top: true, floorT: 0,\n    bar: null, imp: 0, P: 0, pe: 0, t: 0, out: 0, grab: -1, gx: 0, gy: 0, hits: 0, head: null, next: null };\n  for (let k = 0; k < ARR.length; k++) M[ARR[k]] = [];\n  return M;\n}\n\nfunction mdAdd(M, x, y, sp, T) {\n  const s = Math.sqrt(Math.max(T, 0.01) / M.mass[sp]);\n  M.x.push(x); M.y.push(y); M.vx.push(gauss() * s); M.vy.push(gauss() * s);\n  M.fx.push(0); M.fy.push(0); M.sp.push(sp); M.nb.push(0); M.nbs.push(0);\n  M.n++;\n}\n\nfunction mdRemove(M, i) {\n  const l = M.n - 1;\n  for (let k = 0; k < ARR.length; k++) { const a = M[ARR[k]]; a[i] = a[l]; a.pop(); }\n  if (M.grab === i) M.grab = -1; else if (M.grab === l) M.grab = i;\n  M.n--;\n}\n\n/* Random free spot in a region (no overlap), or null. */\nfunction mdFree(M, sp, x0, x1, y0, y1) {\n  for (let tries = 0; tries < 40; tries++) {\n    const x = x0 + Math.random() * (x1 - x0), y = y0 + Math.random() * (y1 - y0);\n    let ok = true;\n    for (let j = 0; j < M.n && ok; j++) {\n      const d = (M.sig[sp] + M.sig[M.sp[j]]) * 0.5 * 0.95;\n      const dx = M.x[j] - x, dy = M.y[j] - y;\n      if (dx * dx + dy * dy < d * d) ok = false;\n    }\n    if (ok) return [x, y];\n  }\n  return null;\n}\n\n/* Jittered grid fill of a region with n particles. */\nfunction mdScatter(M, n, sp, T, x0, x1, y0, y1) {\n  const r = M.sig[sp] * 0.5;\n  x0 += r; x1 -= r; y0 += r; y1 -= r;\n  const w = Math.max(0.1, x1 - x0), h = Math.max(0.1, y1 - y0);\n  const cols = Math.max(1, Math.round(Math.sqrt((n * w) / h))), rows = Math.max(1, Math.ceil(n / cols));\n  const ax = w / cols, ay = h / rows;\n  const cells = [];\n  for (let i = 0; i < cols * rows; i++) cells.push(i);\n  for (let i = cells.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = cells[i]; cells[i] = cells[j]; cells[j] = t; }\n  const j = Math.max(0, Math.min(ax, ay) - M.sig[sp]) * 0.45;\n  for (let k = 0; k < Math.min(n, cells.length); k++) {\n    const c = cells[k] % cols, rr = Math.floor(cells[k] / cols);\n    mdAdd(M, x0 + (c + 0.5) * ax + (Math.random() - 0.5) * j, y0 + (rr + 0.5) * ay + (Math.random() - 0.5) * j, sp, T);\n  }\n}\n\n/* Hexagonal block, filled row by row upward from y1 (or downward from y0). */\nfunction mdHex(M, n, sp, T, cx, w, y1) {\n  const a = 1.12, h = a * 0.866;\n  const cols = Math.max(2, Math.floor(w / a));\n  let k = 0;\n  for (let row = 0; k < n; row++) {\n    for (let c = 0; c < cols && k < n; c++) {\n      mdAdd(M, cx - (cols * a) / 2 + (c + 0.5 + (row % 2) * 0.5) * a, y1 - 0.6 - row * h, sp, T);\n      k++;\n    }\n    if (row > 200) break;\n  }\n}\n\nfunction mdForces(M) {\n  const n = M.n, X = M.x, Y = M.y, sp = M.sp, sig = M.sig;\n  const cut = M.lj ? 2.5 : 1.1225;\n  const smax = Math.max(sig[0], sig[1]);\n  const cs = Math.max(cut * (smax + 1) * 0.5, cut);\n  const gx = Math.max(1, Math.floor(M.BW / cs)), gy = Math.max(1, Math.floor((M.BH + 4) / cs));\n  const cw = M.BW / gx, ch = (M.BH + 4) / gy;\n  if (!M.head || M.head.length !== gx * gy) M.head = new Int32Array(gx * gy);\n  M.head.fill(0 - 1);\n  if (!M.next || M.next.length < n) M.next = new Int32Array(n * 2 + 64);\n  const head = M.head, next = M.next;\n  for (let i = 0; i < n; i++) {\n    const c = clamp(Math.floor(X[i] / cw), 0, gx - 1) + clamp(Math.floor((Y[i] + 4) / ch), 0, gy - 1) * gx;\n    next[i] = head[c]; head[c] = i;\n    M.fx[i] = 0; M.fy[i] = 0; M.nb[i] = 0;\n  }\n  let pe = 0, hits = 0;\n  const e = M.lj ? M.eps : 1;\n  const OFF = [[1, 0], [0 - 1, 1], [0, 1], [1, 1]];\n  const pair = function (i, j) {\n    const dx = X[j] - X[i], dy = Y[j] - Y[i];\n    const r2 = dx * dx + dy * dy;\n    const s = (sig[sp[i]] + sig[sp[j]]) * 0.5;\n    const rc = s * cut;\n    if (r2 >= rc * rc) return;\n    if (r2 < s * s * 2.25) { M.nb[i]++; M.nb[j]++; }\n    if ((sp[i] !== sp[j]) && r2 < s * s * 1.44) hits++;\n    const q = Math.max(r2, s * s * 0.5);\n    const ir = (s * s) / q;\n    const s6 = ir * ir * ir;\n    let f = (24 * s6 * (2 * s6 - e)) / q;\n    if (f > 400) f = 400;\n    pe += 4 * s6 * (s6 - e);\n    M.fx[i] -= f * dx; M.fy[i] -= f * dy;\n    M.fx[j] += f * dx; M.fy[j] += f * dy;\n  };\n  for (let cy = 0; cy < gy; cy++) {\n    for (let cx = 0; cx < gx; cx++) {\n      const c = cx + cy * gx;\n      for (let i = head[c]; i >= 0; i = next[i]) {\n        for (let j = next[i]; j >= 0; j = next[j]) pair(i, j);\n        for (let o = 0; o < 4; o++) {\n          const nx = cx + OFF[o][0], ny = cy + OFF[o][1];\n          if (nx < 0 || nx >= gx || ny >= gy) continue;\n          for (let j = head[nx + ny * gx]; j >= 0; j = next[j]) pair(i, j);\n        }\n      }\n    }\n  }\n  if (M.grab >= 0 && M.grab < n) {\n    const i = M.grab, m = M.mass[sp[i]];\n    M.fx[i] += ((M.gx - X[i]) * 60 - M.vx[i] * 9) * m;\n    M.fy[i] += ((M.gy - Y[i]) * 60 - M.vy[i] * 9) * m;\n  }\n  M.pe = n ? pe / n : 0;\n  M.hits = hits;\n}\n\nfunction mdWalls(M, i, xo) {\n  const s = M.sp[i], r = M.sig[s] * 0.5, m = M.mass[s];\n  if (M.x[i] < r) { M.x[i] = 2 * r - M.x[i]; if (M.vx[i] < 0) { M.imp += 2 * m * (0 - M.vx[i]); M.vx[i] = 0 - M.vx[i]; } }\n  const R = M.wallR - r;\n  if (M.x[i] > R) {\n    M.x[i] = Math.max(r, 2 * R - M.x[i]);\n    const rel = M.vx[i] - M.wallV;\n    if (rel > 0) { M.imp += 2 * m * rel; M.vx[i] = 2 * M.wallV - M.vx[i]; }\n  }\n  if (M.top && M.y[i] < r) { M.y[i] = 2 * r - M.y[i]; if (M.vy[i] < 0) { M.imp += 2 * m * (0 - M.vy[i]); M.vy[i] = 0 - M.vy[i]; } }\n  const Bt = M.BH - r;\n  if (M.y[i] > Bt) {\n    M.y[i] = 2 * Bt - M.y[i];\n    if (M.vy[i] > 0) {\n      M.imp += 2 * m * M.vy[i];\n      if (M.floorT > 0) {\n        /* Thermal wall: re-emit with the wall's temperature. */\n        M.vy[i] = 0 - Math.sqrt((0 - 2 * M.floorT * Math.log(Math.max(1e-9, Math.random()))) / m);\n        M.vx[i] = gauss() * Math.sqrt(M.floorT / m);\n      } else M.vy[i] = 0 - M.vy[i];\n    }\n  }\n  const B = M.bar;\n  if (B && B.on && !B.pass[s]) {\n    if (xo <= B.x) {\n      if (M.x[i] > B.x - r) {\n        M.x[i] = 2 * (B.x - r) - M.x[i];\n        const rel = M.vx[i] - B.v;\n        if (rel > 0) { B.p += 2 * m * rel; M.vx[i] = 2 * B.v - M.vx[i]; }\n      }\n    } else if (M.x[i] < B.x + r) {\n      M.x[i] = 2 * (B.x + r) - M.x[i];\n      const rel = M.vx[i] - B.v;\n      if (rel < 0) { B.p += 2 * m * rel; M.vx[i] = 2 * B.v - M.vx[i]; }\n    }\n  }\n}\n\nfunction mdStep(M, dt) {\n  const n = M.n;\n  for (let i = 0; i < n; i++) {\n    const im = 1 / M.mass[M.sp[i]];\n    M.vx[i] += 0.5 * dt * M.fx[i] * im;\n    M.vy[i] += 0.5 * dt * (M.fy[i] * im + M.g);\n    const v2 = M.vx[i] * M.vx[i] + M.vy[i] * M.vy[i];\n    if (v2 > 900) { const k = 30 / Math.sqrt(v2); M.vx[i] *= k; M.vy[i] *= k; }\n    const xo = M.x[i];\n    M.x[i] += M.vx[i] * dt;\n    M.y[i] += M.vy[i] * dt;\n    mdWalls(M, i, xo);\n  }\n  if (!M.top) for (let i = M.n - 1; i >= 0; i--) if (M.y[i] < 0 - 1) { mdRemove(M, i); M.out++; }\n  mdForces(M);\n  for (let i = 0; i < M.n; i++) {\n    const im = 1 / M.mass[M.sp[i]];\n    M.vx[i] += 0.5 * dt * M.fx[i] * im;\n    M.vy[i] += 0.5 * dt * (M.fy[i] * im + M.g);\n  }\n  M.t += dt;\n}\n\n/* Kinetic temperature (2D, k = 1): mean kinetic energy per particle. */\nfunction mdTemp(M, sp) {\n  let ke = 0, c = 0;\n  for (let i = 0; i < M.n; i++) {\n    if (sp !== undefined && M.sp[i] !== sp) continue;\n    ke += 0.5 * M.mass[M.sp[i]] * (M.vx[i] * M.vx[i] + M.vy[i] * M.vy[i]);\n    c++;\n  }\n  return c ? ke / c : 0;\n}\nfunction mdScale(M, lam) { for (let i = 0; i < M.n; i++) { M.vx[i] *= lam; M.vy[i] *= lam; } }\nfunction berendsen(M, T0, dt, tau) {\n  const T = mdTemp(M);\n  if (T > 1e-6) mdScale(M, clamp(Math.sqrt(1 + (dt / tau) * (T0 / T - 1)), 0.9, 1.1));\n}\nfunction countSp(M, sp, side) {\n  let c = 0;\n  for (let i = 0; i < M.n; i++) if (M.sp[i] === sp && (side === undefined || (side < 0 ? M.x[i] < M.bar.x : M.x[i] >= M.bar.x))) c++;\n  return c;\n}\n\n/* Bring the count of one species (optionally on one side) to n. */\nfunction setCount(M, sp, n, x0, x1, y0, y1, T, side) {\n  let have = 0;\n  for (let i = 0; i < M.n; i++) if (M.sp[i] === sp && (side === undefined || (side < 0 ? M.x[i] < M.bar.x : M.x[i] >= M.bar.x))) have++;\n  for (let k = 0; k < 6 && have < n; k++) { const q = mdFree(M, sp, x0, x1, y0, y1); if (!q) break; mdAdd(M, q[0], q[1], sp, T); have++; }\n  for (let i = M.n - 1; i >= 0 && have > n; i--) {\n    if (M.sp[i] === sp && i !== M.grab && (side === undefined || (side < 0 ? M.x[i] < M.bar.x : M.x[i] >= M.bar.x))) { mdRemove(M, i); have--; }\n  }\n}\n\nfunction setup(st, v) {\n  const aspect = clamp(st.w / st.h, 0.75, 2.6);\n  const BH = MODE === 'evaporation' ? 30 : MODE === 'brownian' ? 26 : 24;\n  const BW = Math.round(BH * aspect);\n  const M = mdNew(BW, BH);\n  const T = (v.temp || 120) / KT;\n  if (MODE === 'gas') {\n    M.wallR = (BW * v.volume) / 100;\n    mdScatter(M, v.count, 0, T, 0, M.wallR, 0, BH);\n  } else if (MODE === 'liquid') {\n    M.lj = true;\n    mdHex(M, v.count, 0, T, BW / 2, Math.min(BW * 0.5, Math.sqrt(v.count) * 1.8), BH);\n  } else if (MODE === 'crystal') {\n    M.lj = true;\n    const w = BW * 0.62, rows = Math.floor((BH * 0.62) / 0.97);\n    mdHex(M, Math.floor(w / 1.12) * rows, 0, T, BW / 2, w, BH * 0.81);\n  } else if (MODE === 'phase') {\n    M.lj = true; M.g = 0.05;\n    mdHex(M, 210, 0, 0.1, BW / 2, Math.min(BW * 0.45, 18), BH);\n  } else if (MODE === 'diffusion') {\n    M.sig[1] = v.sizeB; M.mass[1] = v.massB;\n    M.bar = { x: BW / 2, v: 0, p: 0, on: true, pass: [false, false], fixed: true };\n    mdScatter(M, v.count / 2, 0, T, 0, BW / 2, 0, BH);\n    mdScatter(M, v.count / 2, 1, T, BW / 2, BW, 0, BH);\n  } else if (MODE === 'brownian') {\n    M.sig[1] = 5; M.mass[1] = v.massBig;\n    mdAdd(M, BW / 2, BH / 2, 1, T);\n    M.vx[0] = 0; M.vy[0] = 0;\n    mdScatter(M, v.count + 30, 0, T, 0, BW, 0, BH);\n    for (let i = M.n - 1; i > 0; i--) { const dx = M.x[i] - BW / 2, dy = M.y[i] - BH / 2; if (dx * dx + dy * dy < 9.5) mdRemove(M, i); }\n    while (M.n > v.count + 1) mdRemove(M, M.n - 1);\n  } else if (MODE === 'osmosis') {\n    M.sig[1] = 1.7; M.mass[1] = 3;\n    M.bar = { x: BW / 2, x0: BW / 2, v: 0, p: 0, on: true, pass: [true, false], fixed: false };\n    mdScatter(M, Math.round(v.soluteL), 1, T, 0, BW / 2, 0, BH);\n    mdScatter(M, Math.round(v.soluteR), 1, T, BW / 2, BW, 0, BH);\n    for (let k = 0; k < 220; k++) { const q = mdFree(M, 0, 0, BW, 0, BH); if (q) mdAdd(M, q[0], q[1], 0, T); }\n  } else if (MODE === 'evaporation') {\n    M.lj = true; M.g = 0.25; M.top = false;\n    mdHex(M, 280, 0, 0.75, BW / 2, BW * 0.96, BH);\n  }\n  mdForces(M);\n  return M;\n}\n\nfunction render(ctx, env) {\n  const W = env.width, H = env.height;\n  if (!S) S = { M: null, trail: [], frame: 0, gr: null, added: 0, rate: 0, outPrev: 0, histMap: {} };\n  const dt = tick(S);\n  const L = sciLayout(W, H);\n  const F = sciFrame(S, env, L);\n  const v = F.v;\n  const A = inp(env, 'accent', '#5ee0ff');\n  const B2 = inp(env, 'accent2', '#ffb454');\n  const bg = inp(env, 'bg', '#06080b');\n  const ink = inp(env, 'text', '#e6edf3');\n  const st = sciStage(L);\n  if (!S.M || F.fired === 'reset' || (F.fired === 'refill' && MODE === 'evaporation')) {\n    S.M = setup(st, v); S.trail = []; S.gr = null; S.added = 0; S.outPrev = 0; S.histMap = {};\n    if (MODE === 'diffusion') PRESET.buttons[0].label = 'Lift barrier';\n  }\n  const M = S.M;\n  S.frame++;\n\n  /* Box placement: fit the box into the stage. */\n  const k = Math.min(st.w / M.BW, st.h / M.BH);\n  const bw = M.BW * k, bh = M.BH * k;\n  const bx = st.x + (st.w - bw) / 2, by = st.y + (st.h - bh) / 2;\n  const toX = function (x) { return bx + x * k; }, toY = function (y) { return by + y * k; };\n  const p = F.p;\n  const sx = (p.x - bx) / k, sy = (p.y - by) / k;\n  const inBox = p.over && sx >= 0 && sx <= M.BW && sy >= 0 && sy <= M.BH;\n\n  /* Live parameters. */\n  const T0 = (v.temp || 120) / KT;\n  if (MODE === 'liquid' || MODE === 'crystal' || MODE === 'phase' || MODE === 'evaporation') M.eps = v.attract;\n  if (MODE === 'liquid') M.g = v.gravity;\n  if (MODE === 'evaporation') { M.g = v.gravity; M.floorT = v.heater / KT; M.top = !!v.lid; }\n  if (MODE === 'diffusion') { M.sig[1] = v.sizeB; M.mass[1] = v.massB; }\n  if (MODE === 'brownian') M.mass[1] = v.massBig;\n  if (MODE === 'gas') {\n    const target = (M.BW * v.volume) / 100;\n    const nw = M.wallR + (target - M.wallR) * Math.min(1, dt * 4);\n    const V0 = M.wallR, V1 = nw;\n    M.wallR = nw;\n    /* Without the thermostat, moving the piston is adiabatic: for a 2D ideal\n       gas (γ = 2) T·V stays constant, so compression heats the gas. */\n    if (Math.abs(V1 - V0) > 1e-6 && !v.isothermal) mdScale(M, Math.sqrt(V0 / V1));\n    for (let i = 0; i < M.n; i++) if (M.x[i] > M.wallR - 0.5) M.x[i] = M.wallR - 0.5;\n  }\n  if (MODE === 'gas' || MODE === 'brownian') setCount(M, 0, Math.round(v.count), 0, MODE === 'gas' ? M.wallR : M.BW, 0, M.BH, T0);\n  if (MODE === 'liquid') setCount(M, 0, Math.round(v.count), M.BW * 0.2, M.BW * 0.8, 0, M.BH * 0.3, T0);\n  if (MODE === 'osmosis') {\n    setCount(M, 1, Math.round(v.soluteL), 0, M.bar.x, 0, M.BH, T0, 0 - 1);\n    setCount(M, 1, Math.round(v.soluteR), M.bar.x, M.BW, 0, M.BH, T0, 1);\n  }\n  if (MODE === 'diffusion' && F.fired === 'barrier') {\n    M.bar.on = !M.bar.on;\n    PRESET.buttons[0].label = M.bar.on ? 'Lift barrier' : 'Insert barrier';\n  }\n\n  /* Pointer: drag the piston, grab a molecule (tweezers), or tap to heat. */\n  if (p.click && inBox) {\n    if (MODE === 'gas' && Math.abs(sx - M.wallR) < 1.6) S.piston = true;\n    else {\n      let best = -1, bd = 1e9;\n      for (let i = 0; i < M.n; i++) {\n        const dx = M.x[i] - sx, dy = M.y[i] - sy, d = dx * dx + dy * dy;\n        const lim = (M.sig[M.sp[i]] * 0.5 + 0.9);\n        if (d < lim * lim && d < bd) { bd = d; best = i; }\n      }\n      if (best >= 0) M.grab = best;\n      else {\n        for (let i = 0; i < M.n; i++) {\n          const dx = M.x[i] - sx, dy = M.y[i] - sy, d = Math.sqrt(dx * dx + dy * dy);\n          if (d < 4 && d > 1e-3) { const kick = 3.5 * (1 - d / 4); M.vx[i] += (dx / d) * kick; M.vy[i] += (dy / d) * kick; }\n        }\n        S.pulse = { x: sx, y: sy, t: 0 };\n      }\n    }\n  }\n  if (F.fired === 'kick') {\n    /* A hammer blow in the middle of the block. */\n    for (let i = 0; i < M.n; i++) {\n      const dx = M.x[i] - M.BW / 2, dy = M.y[i] - M.BH / 2, d = Math.sqrt(dx * dx + dy * dy);\n      if (d < 5 && d > 1e-3) { const kick = 6 * (1 - d / 5); M.vx[i] += (dx / d) * kick; M.vy[i] += (dy / d) * kick; }\n    }\n    S.pulse = { x: M.BW / 2, y: M.BH / 2, t: 0 };\n  }\n  if (S.piston && p.down) S.ui.v.volume = clamp(Math.round((sx / M.BW) * 100), 30, 100);\n  if (!p.down) { S.piston = false; M.grab = -1; }\n  M.gx = clamp(sx, 0, M.BW); M.gy = clamp(sy, 0, M.BH);\n\n  /* Integrate. */\n  const sdt = 0.004;\n  const sub = Math.max(1, Math.round(10 * (v.speed || 1)));\n  M.imp = 0;\n  if (M.bar) M.bar.p = 0;\n  const t0 = M.t;\n  for (let s = 0; s < sub; s++) {\n    mdStep(M, sdt);\n    if (M.bar && !M.bar.fixed) {\n      /* Membrane on a spring: solute impulses push it, the spring holds it. */\n      const Bm = 40;\n      const kspr = v.stiff;\n      M.bar.v += (M.bar.p / Bm) - (kspr * (M.bar.x - M.bar.x0) / Bm) * sdt - M.bar.v * 1.5 * sdt;\n      M.bar.p = 0;\n      M.bar.x = clamp(M.bar.x + M.bar.v * sdt, M.BW * 0.12, M.BW * 0.88);\n    }\n  }\n  const simDt = M.t - t0;\n  const thermo = MODE === 'gas' ? !!v.isothermal : MODE !== 'phase' && MODE !== 'evaporation';\n  if (thermo) berendsen(M, T0, simDt, MODE === 'gas' ? 0.6 : 0.35);\n  if (MODE === 'phase') {\n    const Tn = mdTemp(M);\n    const q = v.heat * 0.5;\n    if (Tn > 0.01 && !(q > 0 && Tn > 5) && !(q < 0 && Tn < 0.04)) {\n      const ke = Tn * M.n;\n      mdScale(M, Math.sqrt(Math.max(0.5, 1 + (q * M.n * simDt) / ke)));\n      S.added += q * simDt;\n    }\n  }\n  const perim = 2 * (M.wallR + M.BH);\n  if (simDt > 0) M.P += ((M.imp / (simDt * perim)) - M.P) * 0.04;\n  for (let i = 0; i < M.n; i++) M.nbs[i] += (M.nb[i] - M.nbs[i]) * 0.08;\n  const Tk = mdTemp(M);\n\n  /* ---------- Draw ---------- */\n  ctx.save();\n  ctx.translate(0 - W / 2, 0 - H / 2);\n  sciBack(ctx, L, bg, ink);\n\n  /* Vessel. */\n  ctx.fillStyle = rgba(ink, 0.025);\n  ctx.fillRect(bx, by, MODE === 'gas' ? M.wallR * k : bw, bh);\n  ctx.strokeStyle = rgba(ink, 0.32);\n  ctx.lineWidth = 1.5;\n  ctx.beginPath();\n  if (M.top) { ctx.moveTo(bx, by); ctx.lineTo(bx + bw, by); }\n  ctx.moveTo(bx, by); ctx.lineTo(bx, by + bh); ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx + bw, by);\n  ctx.stroke();\n  if (MODE === 'evaporation' && !M.top) {\n    ctx.setLineDash([3, 5]);\n    ctx.strokeStyle = rgba(ink, 0.18);\n    ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx + bw, by); ctx.stroke();\n    ctx.setLineDash([]);\n  }\n  if (MODE === 'evaporation') {\n    /* Heater plate glows with its temperature. */\n    const hk = clamp(M.floorT / 2.5, 0, 1);\n    const hg = ctx.createLinearGradient(0, by + bh - k * 3, 0, by + bh);\n    hg.addColorStop(0, rgba('#ff5a3c', 0));\n    hg.addColorStop(1, rgba('#ff5a3c', 0.25 * hk));\n    ctx.fillStyle = hg;\n    ctx.fillRect(bx, by + bh - k * 3, bw, k * 3);\n    ctx.fillStyle = mix('#3a3f46', '#ff6a3c', hk);\n    ctx.fillRect(bx, by + bh, bw, Math.max(3, k * 0.35));\n  }\n\n  /* Trails. */\n  if ((MODE === 'gas' && v.tracer) || MODE === 'brownian') {\n    const ti = MODE === 'brownian' ? 0 : 0;\n    if (M.n > ti) {\n      S.trail.push([M.x[ti], M.y[ti]]);\n      if (S.trail.length > (MODE === 'brownian' ? 900 : 260)) S.trail.shift();\n      ctx.strokeStyle = rgba(MODE === 'brownian' ? B2 : A, 0.75);\n      ctx.lineWidth = 1.2;\n      ctx.beginPath();\n      for (let i = 0; i < S.trail.length; i++) { const q = S.trail[i]; if (i) ctx.lineTo(toX(q[0]), toY(q[1])); else ctx.moveTo(toX(q[0]), toY(q[1])); }\n      ctx.stroke();\n    }\n  }\n\n  /* Bonds for condensed phases. */\n  if (MODE === 'crystal' && v.bonds) {\n    ctx.lineWidth = Math.max(0.6, k * 0.08);\n    for (let i = 0; i < M.n; i++) {\n      for (let j = i + 1; j < M.n; j++) {\n        const dx = M.x[j] - M.x[i];\n        if (dx > 1.45 || dx < 0 - 1.45) continue;\n        const dy = M.y[j] - M.y[i];\n        const d2 = dx * dx + dy * dy;\n        if (d2 < 1.45 * 1.45) {\n          const strain = Math.abs(Math.sqrt(d2) - 1.12) / 0.3;\n          ctx.strokeStyle = rgba(mix(A, '#ff5a3c', clamp(strain, 0, 1)), 0.45);\n          ctx.beginPath(); ctx.moveTo(toX(M.x[i]), toY(M.y[i])); ctx.lineTo(toX(M.x[j]), toY(M.y[j])); ctx.stroke();\n        }\n      }\n    }\n  }\n\n  /* Particles. */\n  const vref = Math.sqrt(Math.max(T0, 0.3)) * 2.6;\n  const drawAt = function (i, col) {\n    const r = M.sig[M.sp[i]] * 0.5 * k * 0.92;\n    ball(ctx, col, toX(M.x[i]), toY(M.y[i]), r);\n  };\n  for (let i = 0; i < M.n; i++) {\n    const s = M.sp[i];\n    let col;\n    if (MODE === 'diffusion') col = s ? B2 : A;\n    else if (MODE === 'osmosis') col = s ? B2 : mix(bg, A, 0.55);\n    else if (MODE === 'brownian') { if (s) continue; if (!v.showSolvent) continue; col = mix(bg, A, 0.5); }\n    else if (MODE === 'phase') col = M.nbs[i] >= 4.5 ? '#6f9dff' : M.nbs[i] >= 1.6 ? A : B2;\n    else {\n      const sp = Math.sqrt(M.vx[i] * M.vx[i] + M.vy[i] * M.vy[i]);\n      col = heat(sp / (MODE === 'gas' ? vref : 3.2));\n    }\n    drawAt(i, col);\n  }\n  if (MODE === 'brownian' && M.n) {\n    const r = M.sig[1] * 0.5 * k;\n    glow(ctx, B2, toX(M.x[0]), toY(M.y[0]), r * 2.2, 0.35);\n    ball(ctx, B2, toX(M.x[0]), toY(M.y[0]), r * 0.96);\n  }\n  if (MODE === 'gas' && v.tracer && M.n) { glow(ctx, A, toX(M.x[0]), toY(M.y[0]), k * 1.6, 0.9); ball(ctx, '#ffffff', toX(M.x[0]), toY(M.y[0]), k * 0.48); }\n\n  /* Piston / barrier / membrane. */\n  if (MODE === 'gas') {\n    const px = toX(M.wallR);\n    ctx.fillStyle = mix(bg, ink, 0.12);\n    ctx.fillRect(px, by, bx + bw - px, bh);\n    ctx.fillStyle = rgba(ink, 0.04);\n    for (let yy = by; yy < by + bh; yy += 6) ctx.fillRect(px, yy, bx + bw - px, 1);\n    ctx.fillStyle = S.piston ? A : mix(bg, ink, 0.55);\n    ctx.fillRect(px, by, Math.max(4, k * 0.45), bh);\n    ctx.fillStyle = rgba(ink, 0.8);\n    const hy = by + bh / 2;\n    ctx.fillRect(px + k * 0.45, hy - 1, Math.max(12, k * 1.5), 2);\n    ctx.beginPath(); ctx.arc(px + k * 0.45 + Math.max(12, k * 1.5), hy, Math.max(4, k * 0.35), 0, Math.PI * 2); ctx.fill();\n  }\n  if (M.bar) {\n    const mx = toX(M.bar.x);\n    if (M.bar.on) {\n      if (MODE === 'osmosis') {\n        ctx.strokeStyle = rgba(ink, 0.7);\n        ctx.lineWidth = Math.max(1.5, k * 0.18);\n        ctx.setLineDash([Math.max(3, k * 0.5), Math.max(2, k * 0.35)]);\n        ctx.beginPath(); ctx.moveTo(mx, by); ctx.lineTo(mx, by + bh); ctx.stroke();\n        ctx.setLineDash([]);\n        ctx.strokeStyle = rgba(ink, 0.25);\n        ctx.lineWidth = 1;\n        ctx.beginPath(); ctx.moveTo(toX(M.bar.x0), by + bh + 4); ctx.lineTo(toX(M.bar.x0), by + bh + 12); ctx.stroke();\n      } else {\n        ctx.fillStyle = mix(bg, ink, 0.6);\n        ctx.fillRect(mx - Math.max(1.5, k * 0.15), by, Math.max(3, k * 0.3), bh);\n      }\n    } else {\n      ctx.fillStyle = rgba(ink, 0.15);\n      ctx.fillRect(mx - 1, by - 8, 2, 8);\n    }\n  }\n\n  /* Grab line and heat pulse. */\n  if (M.grab >= 0 && M.grab < M.n) {\n    ctx.strokeStyle = rgba(A, 0.8);\n    ctx.lineWidth = 1;\n    ctx.setLineDash([3, 3]);\n    ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(toX(M.x[M.grab]), toY(M.y[M.grab])); ctx.stroke();\n    ctx.setLineDash([]);\n    ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, Math.PI * 2); ctx.stroke();\n  }\n  if (S.pulse) {\n    S.pulse.t += dt;\n    const q = S.pulse.t / 0.6;\n    if (q < 1) {\n      ctx.strokeStyle = rgba('#ff8a4a', 0.7 * (1 - q));\n      ctx.lineWidth = 2;\n      ctx.beginPath(); ctx.arc(toX(S.pulse.x), toY(S.pulse.y), k * 4 * ease(q), 0, Math.PI * 2); ctx.stroke();\n    } else S.pulse = null;\n  }\n  if (inBox && !p.down) {\n    ctx.strokeStyle = rgba(ink, 0.25);\n    ctx.lineWidth = 1;\n    ctx.beginPath(); ctx.arc(p.x, p.y, k * 1.2, 0, Math.PI * 2); ctx.stroke();\n  }\n\n  /* Scale bar and clock. */\n  const fs = clamp(L.s * 0.014, 8, 11);\n  const nm = k / SIG_NM;\n  ctx.fillStyle = rgba(ink, 0.6);\n  ctx.fillRect(bx, by + bh + fs * 1.2, nm, 1.5);\n  ctx.fillRect(bx, by + bh + fs * 0.8, 1, fs * 0.8);\n  ctx.fillRect(bx + nm, by + bh + fs * 0.8, 1, fs * 0.8);\n  ctx.font = '500\\u0020' + fs + 'px\\u0020' + MONO;\n  ctx.textAlign = 'left';\n  ctx.fillText('1 nm', bx + nm + fs * 0.6, by + bh + fs * 1.6);\n  ctx.textAlign = 'right';\n  ctx.fillText('t\\u0020=\\u0020' + sciFmt(M.t * TAU, 0) + '\\u0020ps', bx + bw, by + bh + fs * 1.6);\n\n  /* ---------- Measurements ---------- */\n  let R = [], G = null;\n  const push = function (key, val, max) {\n    const h = S.histMap[key] || (S.histMap[key] = []);\n    if (S.frame % 3 === 0) { h.push(val); if (h.length > (max || 160)) h.shift(); }\n    return h;\n  };\n  if (MODE === 'gas') {\n    const V = M.wallR * M.BH;\n    const nbins = 24, vmax = Math.sqrt(Math.max(T0, Tk)) * 4;\n    if (!S.hb || S.hb.length !== nbins) { S.hb = []; for (let i = 0; i < nbins; i++) S.hb.push(0); }\n    const cnt = [];\n    for (let i = 0; i < nbins; i++) cnt.push(0);\n    for (let i = 0; i < M.n; i++) { const sp = Math.sqrt(M.vx[i] * M.vx[i] + M.vy[i] * M.vy[i]); const b = Math.floor((sp / vmax) * nbins); if (b < nbins) cnt[b]++; }\n    for (let i = 0; i < nbins; i++) S.hb[i] += (cnt[i] / Math.max(1, M.n) - S.hb[i]) * 0.08;\n    const ref = [];\n    const Tr = Math.max(Tk, 0.01);\n    for (let i = 0; i < 60; i++) { const vv = ((i + 0.5) / 60) * vmax; ref.push([vv, (vv / Tr) * Math.exp((0 - vv * vv) / (2 * Tr)) * (vmax / nbins)]); }\n    let vm = 0;\n    for (let i = 0; i < M.n; i++) vm += Math.sqrt(M.vx[i] * M.vx[i] + M.vy[i] * M.vy[i]);\n    vm /= Math.max(1, M.n);\n    R = [['Temperature', sciFmt(Tk * KT, 0), 'K'], ['Pressure', sciFmt(M.P, 3), 'ε/σ²'], ['Area', sciFmt(V * SIG_NM * SIG_NM, 1), 'nm²'],\n      ['PV\\u0020/\\u0020NkT', sciFmt((M.P * V) / Math.max(1e-6, M.n * Tk), 2), '', A], ['Mean speed', sciFmt(vm * VUNIT, 0), 'm/s'], ['Molecules', M.n, '']];\n    G = { title: 'Speed distribution', series: [{ bars: S.hb, c: A, name: 'measured' }, { pts: ref, c: B2, name: 'Maxwell–Boltzmann', w: 1.8 }], x0: 0, x1: vmax, y0: 0, xl: '0', yl: sciFmt(vmax * VUNIT, 0) + '\\u0020m/s' };\n  } else if (MODE === 'liquid' || MODE === 'evaporation' || MODE === 'phase') {\n    let nbm = 0, sol = 0, liq = 0, gas = 0;\n    for (let i = 0; i < M.n; i++) { nbm += M.nbs[i]; if (M.nbs[i] >= 4.5) sol++; else if (M.nbs[i] >= 1.6) liq++; else gas++; }\n    nbm /= Math.max(1, M.n);\n    if (MODE === 'liquid') {\n      const ke = push('ke', Tk), pe = push('pe', M.pe);\n      R = [['Temperature', sciFmt(Tk * KT, 0), 'K'], ['Potential', sciFmt(M.pe * 0.996, 2), 'kJ/mol'], ['Neighbours', sciFmt(nbm, 1), ''],\n        ['Condensed', sciFmt((100 * (sol + liq)) / Math.max(1, M.n), 0), '%', A], ['Vapour', gas, ''], ['Molecules', M.n, '']];\n      G = { title: 'Energy per molecule', series: [{ d: ke, c: '#ffb454', name: 'kinetic', n: 160 }, { d: pe, c: A, name: 'potential', n: 160 }] };\n    } else if (MODE === 'phase') {\n      const tr = push('T', Tk * KT, 200);\n      const phase = sol > liq + gas ? 'Solid' : gas > (sol + liq) * 1.5 ? 'Gas' : 'Liquid';\n      R = [['Temperature', sciFmt(Tk * KT, 0), 'K'], ['Phase', phase, '', phase === 'Solid' ? '#6f9dff' : phase === 'Liquid' ? A : B2], ['Heat added', sciFmt(S.added * 0.996, 2), 'kJ/mol'],\n        ['Solid', sciFmt((100 * sol) / Math.max(1, M.n), 0), '%', '#6f9dff'], ['Liquid', sciFmt((100 * liq) / Math.max(1, M.n), 0), '%', A], ['Gas', sciFmt((100 * gas) / Math.max(1, M.n), 0), '%', B2]];\n      G = { title: 'Heating curve', series: [{ d: tr, c: B2, name: 'T\\u0020(K)', n: 200, dot: true }], y0: 0 };\n    } else {\n      const outRate = S.frame % 30 === 0 ? ((M.out - S.outPrev) / Math.max(1e-6, 30 * dt)) : null;\n      if (outRate !== null) { S.rate += (outRate - S.rate) * 0.35; S.outPrev = M.out; }\n      const lq = push('liq', sol + liq);\n      R = [['Liquid T', sciFmt(mdTemp(M) * KT, 0), 'K'], ['Heater', sciFmt(M.floorT * KT, 0), 'K'], ['Escaped', M.out, ''],\n        ['Rate', sciFmt(S.rate, 1), '/s', A], ['Liquid', sol + liq, ''], ['Vapour', gas, '']];\n      G = { title: 'Molecules in the liquid', series: [{ d: lq, c: A, name: 'liquid', n: 160, dot: true }], y0: 0 };\n    }\n  } else if (MODE === 'crystal') {\n    let six = 0, nbm = 0;\n    for (let i = 0; i < M.n; i++) { nbm += M.nb[i]; if (M.nb[i] === 6) six++; }\n    if (!S.gr || S.frame % 12 === 0) {\n      const nb = 40, rmax = 3.6, cnt = [];\n      for (let i = 0; i < nb; i++) cnt.push(0);\n      for (let i = 0; i < M.n; i++) for (let j = i + 1; j < M.n; j++) {\n        const dx = M.x[j] - M.x[i];\n        if (dx > rmax || dx < 0 - rmax) continue;\n        const dy = M.y[j] - M.y[i], d = Math.sqrt(dx * dx + dy * dy);\n        if (d < rmax) cnt[Math.floor((d / rmax) * nb)] += 2;\n      }\n      const rho = M.n / (M.BW * M.BH * 0.4);\n      const g = cnt.map(function (c, i) { const r = ((i + 0.5) / nb) * rmax; return c / (Math.max(1, M.n) * rho * 2 * Math.PI * r * (rmax / nb)); });\n      if (!S.gr) S.gr = g; else for (let i = 0; i < nb; i++) S.gr[i] += (g[i] - S.gr[i]) * 0.4;\n    }\n    R = [['Temperature', sciFmt(Tk * KT, 0), 'K'], ['Order', sciFmt((100 * six) / Math.max(1, M.n), 0), '%', A], ['Defects', M.n - six, ''],\n      ['Neighbours', sciFmt(nbm / Math.max(1, M.n), 2), ''], ['Lattice', sciFmt(1.12 * SIG_NM, 3), 'nm'], ['Atoms', M.n, '']];\n    G = { title: 'Radial distribution g(r)', series: [{ d: S.gr, c: A, name: 'g(r)' }], y0: 0, xl: '0', yl: '1.2 nm' };\n  } else if (MODE === 'diffusion') {\n    const aR = countSp(M, 0, 1), bL = countSp(M, 1, 0 - 1), nA = countSp(M, 0), nB = countSp(M, 1);\n    const fa = push('a', (100 * aR) / Math.max(1, nA)), fb = push('b', (100 * bL) / Math.max(1, nB));\n    const ent = function (a, b) { const n = a + b; if (!n) return 0; let s = 0; if (a) s -= (a / n) * Math.log(a / n); if (b) s -= (b / n) * Math.log(b / n); return s * n; };\n    const Smix = (ent(nA - aR, bL) + ent(aR, nB - bL)) / Math.max(1, M.n) / Math.log(2);\n    R = [['A crossed', sciFmt((100 * aR) / Math.max(1, nA), 0), '%', A], ['B crossed', sciFmt((100 * bL) / Math.max(1, nB), 0), '%', B2], ['Mixing entropy', sciFmt(Smix * 100, 0), '%\\u0020of max'],\n      ['Temperature', sciFmt(Tk * KT, 0), 'K'], ['Graham ratio', sciFmt(Math.sqrt(M.mass[1]), 2), '√(mB/mA)'], ['Barrier', M.bar.on ? 'Closed' : 'Open', '']];\n    G = { title: 'Crossed the midline', series: [{ d: fa, c: A, name: 'A', n: 160, dot: true }, { d: fb, c: B2, name: 'B', n: 160, dot: true }], y0: 0, y1: 55 };\n  } else if (MODE === 'brownian') {\n    /* Time-averaged mean squared displacement from the trail. */\n    const tr = S.trail, lags = [], every = 12;\n    for (let lag = every; lag < tr.length * 0.5; lag += every) {\n      let s = 0, c = 0;\n      for (let i = 0; i + lag < tr.length; i += 6) { const dx = tr[i + lag][0] - tr[i][0], dy = tr[i + lag][1] - tr[i][1]; s += dx * dx + dy * dy; c++; }\n      if (c) lags.push([lag * sub * sdt, s / c]);\n    }\n    let D = 0;\n    if (lags.length > 2) { let sxy = 0, sxx = 0; for (let i = 0; i < lags.length; i++) { sxy += lags[i][0] * lags[i][1]; sxx += lags[i][0] * lags[i][0]; } D = sxy / sxx / 4; }\n    const fit = lags.length ? [[0, 0], [lags[lags.length - 1][0], 4 * D * lags[lags.length - 1][0]]] : [];\n    const disp = M.n ? Math.hypot(M.x[0] - M.BW / 2, M.y[0] - M.BH / 2) * SIG_NM : 0;\n    R = [['Diffusion D', sciFmt(D * SIG_NM * SIG_NM / TAU * 1000, 1), 'nm²/ns', B2], ['Kicks', M.hits, 'in contact'], ['From start', sciFmt(disp, 2), 'nm'],\n      ['Temperature', sciFmt(Tk * KT, 0), 'K'], ['Grain mass', M.mass[1], '× m'], ['Molecules', M.n - 1, '']];\n    G = { title: 'Mean squared displacement', series: [{ pts: lags, c: B2, name: 'MSD' }, { pts: fit, c: rgba(ink, 0.5), name: '4Dt', dash: true, w: 1 }], x0: 0, x1: lags.length ? lags[lags.length - 1][0] : 1, y0: 0, xl: 'lag τ', yl: 'σ²' };\n  } else if (MODE === 'osmosis') {\n    const sl = countSp(M, 1, 0 - 1), sr = countSp(M, 1, 1);\n    const aL = M.bar.x * M.BH, aR2 = (M.BW - M.bar.x) * M.BH;\n    const Pi = (sl / aL - sr / aR2) * Tk;\n    const Pm = (v.stiff * (M.bar.x - M.bar.x0)) / M.BH;\n    const pos = push('x', M.bar.x - M.bar.x0);\n    R = [['Π measured', sciFmt(Pm, 3), 'ε/σ²', A], ['Π van ’t Hoff', sciFmt(Pi, 3), 'ε/σ²', B2], ['Shift', sciFmt((M.bar.x - M.bar.x0) * SIG_NM, 2), 'nm'],\n      ['Solute left', sl, ''], ['Solute right', sr, ''], ['Temperature', sciFmt(Tk * KT, 0), 'K']];\n    G = { title: 'Membrane position', series: [{ d: pos, c: A, name: 'shift\\u0020(σ)', n: 160, dot: true }] };\n  }\n\n  sciHeader(ctx, L, { eyebrow: PRESET.eyebrow, title: String(inp(env, 'title', PRESET.title)), sub: PRESET.sub, hint: PRESET.hint, ink: ink, accent: A });\n  sciPanel(ctx, S, L, { ink: ink, accent: A, bg: bg, readouts: R, graph: G });\n  ctx.restore();\n}\n"
          }
        ]
      },
      "layoutProps": {
        "layoutSizingHorizontal": "fill",
        "layoutSizingVertical": "fill"
      },
      "zIndex": 1,
      "interactions": []
    }
  ]
}
