4

Hodgkin-Huxley: Action Potential

drag Y for current · click to pulse

The Hodgkin-Huxley model (1952) — the equations that won a Nobel Prize for describing how a squid giant axon fires. A neuron's membrane is a leaky capacitor pierced by voltage-gated sodium and potassium channels, and the entire spike emerges from four coupled ODEs: , with each gating variable evolving as . Here is sodium activation (cubed because three subunits must rotate to open the pore), is sodium inactivation (a swinging ball that plugs the pore from the cytoplasmic side), and is potassium activation (raised to the fourth power for the four K+ subunits). The voltage-dependent rates are the classic empirical fits to voltage-clamp data on the squid giant axon. We integrate with classical RK4 at ms with parameters , , , mS/cm, mV, mV, mV. The top panel shows the membrane in cross-section with Na+ channels on the left (orange) and K+ channels on the right (green); each channel's open fraction is or and the colored beads flowing through visualize the ion flux direction set by the driving force . Watch the choreography during a spike: Na opens first ( rises in ~0.5 ms because is tiny), V shoots toward mV, then inactivates and opens with its slower kinetics, dragging V back toward mV — the after-hyperpolarization. Drag your cursor up and down to scrub the sustained injected current from 0 up to 16 : at low values the cell is silent, then crosses a Hopf bifurcation into limit-cycle firing somewhere around 6-7 , and finally enters depolarization block at high current where the membrane gets stuck near mV because never recovers. Click anywhere to inject a brief 1.5 ms current pulse — with no background drive you can feel the all-or-nothing threshold at about mV. The classical mystery of nonlinear excitability, made tangible.

idle
606 lines · vanilla
view source
// Hodgkin-Huxley action potential model (1952).
//
// State (4 variables):
//   V    membrane voltage (mV)
//   m    sodium activation gate         in [0,1]
//   h    sodium inactivation gate        in [0,1]
//   n    potassium activation gate       in [0,1]
//
// Currents (uA/cm^2):
//   I_Na = gNa * m^3 * h * (V - E_Na)
//   I_K  = gK  * n^4     * (V - E_K)
//   I_L  = gL          * (V - E_L)
//   C * dV/dt = I_inj - I_Na - I_K - I_L
//
// Squid giant axon parameters (Hodgkin & Huxley 1952), modern sign
// convention where E_Na > 0. Threshold sits around V = -55 mV.
//
// Integrated with classical RK4 at dt = 0.01 ms; many substeps per frame
// for stability through the spike upstroke.

// ---------- HH constants ----------
const C_M  = 1.0;     // membrane capacitance (uF/cm^2)
const G_NA = 120.0;   // mS/cm^2
const G_K  = 36.0;
const G_L  = 0.3;
const E_NA = 50.0;    // mV
const E_K  = -77.0;
const E_L  = -54.387;
const V_REST = -65.0;

// Integrator
const DT_SIM = 0.01;       // ms per RK4 step
const STEPS_PER_FRAME = 28; // sim ms per visible frame = STEPS*DT = 0.28 ms

// Trace buffer (each entry = 1 RK4 step, so 1 sample per 0.01 ms)
const TRACE_MS = 250;                          // visible window
const TRACE_LEN = Math.ceil(TRACE_MS / DT_SIM); // 25000 samples
let trace;        // Float32Array of V samples, ring buffer
let mTrace, hTrace, nTrace; // Float32Array gating traces, downsampled
const GATE_TRACE_LEN = 600; // smaller ring buffer for the gate plots
let gateHead = 0;
let gateCount = 0;
let gateAccum = 0;
let head = 0;
let count = 0;

// State
let V, m, h, n;
let simTimeMs = 0;
let spikeCount = 0;
let lastV = V_REST;
let aboveZero = false;

// Injected current (uA/cm^2): baseline + sustained from mouseY + transient pulse.
let iPulseRemain = 0;     // ms remaining of click pulse
let iPulseAmp = 0;        // amplitude of active click pulse
let iSustained = 0;       // continuous current from mouseY
let mouseYNorm = 0.5;     // 0=top, 1=bottom (normalized fraction)

// Layout
let W, H, dpr = 1;
let input = null;

// Bands within the canvas (top membrane, middle voltage trace, bottom gates)
let bandMembrane, bandTrace, bandGates;

// Colors
const COL_BG       = '#06080d';
const COL_BG2      = '#0d1117';
const COL_AXIS     = 'rgba(120,140,170,0.35)';
const COL_AXIS_DIM = 'rgba(120,140,170,0.15)';
const COL_TEXT     = 'rgba(190,200,220,0.85)';
const COL_DIM      = 'rgba(140,155,180,0.6)';
const COL_V        = '#7ed6ff';      // voltage trace
const COL_V_GLOW   = 'rgba(126,214,255,0.35)';
const COL_NA       = '#ff8a5b';      // sodium
const COL_K        = '#7afeb1';      // potassium
const COL_M        = '#ff8a5b';
const COL_H        = '#ffd06a';
const COL_N        = '#7afeb1';
const COL_THRESH   = 'rgba(255,180,90,0.4)';
const COL_REST     = 'rgba(120,170,220,0.28)';
const COL_MEMBRANE = '#3a4763';
const COL_LIPID    = '#1a2031';

// Animated channel pictograms — these are bands of "channels" sliding up the
// membrane to convey ion flux direction. We don't simulate individual ions —
// we visually link channel openness + flux direction to gating variables.
const CHANNEL_ROWS = 5; // rows of Na+ and K+ channels stacked vertically
let channelAnim = 0;     // phase for ion-flow ticks

// ---------- HH gating functions ----------

function alpha_m(V) {
  const x = -(V + 40);
  // Limit -> 1.0 as V -> -40 to avoid 0/0
  if (Math.abs(x) < 1e-6) return 1.0;
  return 0.1 * x / (Math.exp(x / 10) - 1);
}
function beta_m(V)  { return 4 * Math.exp(-(V + 65) / 18); }
function alpha_h(V) { return 0.07 * Math.exp(-(V + 65) / 20); }
function beta_h(V)  { return 1 / (Math.exp(-(V + 35) / 10) + 1); }
function alpha_n(V) {
  const x = -(V + 55);
  if (Math.abs(x) < 1e-6) return 0.1;
  return 0.01 * x / (Math.exp(x / 10) - 1);
}
function beta_n(V)  { return 0.125 * Math.exp(-(V + 65) / 80); }

// Steady-state at rest (used for init)
function steadyState(V) {
  const am = alpha_m(V), bm = beta_m(V);
  const ah = alpha_h(V), bh = beta_h(V);
  const an = alpha_n(V), bn = beta_n(V);
  return {
    m: am / (am + bm),
    h: ah / (ah + bh),
    n: an / (an + bn),
  };
}

// ---------- HH derivatives ----------

function derivs(V, m, h, n, I) {
  const INa = G_NA * m * m * m * h * (V - E_NA);
  const IK  = G_K  * n * n * n * n * (V - E_K);
  const IL  = G_L  *                 (V - E_L);
  const dV  = (I - INa - IK - IL) / C_M;

  const am = alpha_m(V), bm = beta_m(V);
  const ah = alpha_h(V), bh = beta_h(V);
  const an = alpha_n(V), bn = beta_n(V);

  return [
    dV,
    am * (1 - m) - bm * m,
    ah * (1 - h) - bh * h,
    an * (1 - n) - bn * n,
  ];
}

// Classical RK4 step.
function rk4Step(I) {
  const k1 = derivs(V, m, h, n, I);
  const k2 = derivs(
    V + 0.5 * DT_SIM * k1[0],
    m + 0.5 * DT_SIM * k1[1],
    h + 0.5 * DT_SIM * k1[2],
    n + 0.5 * DT_SIM * k1[3],
    I
  );
  const k3 = derivs(
    V + 0.5 * DT_SIM * k2[0],
    m + 0.5 * DT_SIM * k2[1],
    h + 0.5 * DT_SIM * k2[2],
    n + 0.5 * DT_SIM * k2[3],
    I
  );
  const k4 = derivs(
    V + DT_SIM * k3[0],
    m + DT_SIM * k3[1],
    h + DT_SIM * k3[2],
    n + DT_SIM * k3[3],
    I
  );
  V = V + (DT_SIM / 6) * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]);
  m = m + (DT_SIM / 6) * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]);
  h = h + (DT_SIM / 6) * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2]);
  n = n + (DT_SIM / 6) * (k1[3] + 2 * k2[3] + 2 * k3[3] + k4[3]);
  // Clamp gates to physical [0,1]
  if (m < 0) m = 0; else if (m > 1) m = 1;
  if (h < 0) h = 0; else if (h > 1) h = 1;
  if (n < 0) n = 0; else if (n > 1) n = 1;
  // Clamp voltage to a generous range (depolarization block can push high)
  if (V > 80)  V = 80;
  if (V < -100) V = -100;
}

// ---------- layout ----------

function computeLayout() {
  // Vertical bands. Membrane on top (~32%), V trace in middle (~38%), gates bottom (~30%).
  const topH    = Math.max(120, Math.floor(H * 0.32));
  const traceH  = Math.max(120, Math.floor(H * 0.38));
  const gatesH  = H - topH - traceH;
  bandMembrane = { x: 0, y: 0,           w: W, h: topH };
  bandTrace    = { x: 0, y: topH,        w: W, h: traceH };
  bandGates    = { x: 0, y: topH + traceH, w: W, h: gatesH };
}

// ---------- init / tick ----------

function init({ canvas, ctx, width, height, input: inp }) {
  W = width; H = height;
  input = inp;
  computeLayout();

  // Initial state at rest
  V = V_REST;
  const ss = steadyState(V_REST);
  m = ss.m; h = ss.h; n = ss.n;

  trace = new Float32Array(TRACE_LEN);
  for (let i = 0; i < TRACE_LEN; i++) trace[i] = V_REST;
  head = 0;
  count = TRACE_LEN;

  mTrace = new Float32Array(GATE_TRACE_LEN);
  hTrace = new Float32Array(GATE_TRACE_LEN);
  nTrace = new Float32Array(GATE_TRACE_LEN);
  for (let i = 0; i < GATE_TRACE_LEN; i++) {
    mTrace[i] = m; hTrace[i] = h; nTrace[i] = n;
  }
  gateHead = 0; gateCount = GATE_TRACE_LEN; gateAccum = 0;

  simTimeMs = 0;
  spikeCount = 0;
  iPulseRemain = 0;
  iPulseAmp = 0;
  iSustained = 0;
  mouseYNorm = 0.5;
  channelAnim = 0;

  ctx.fillStyle = COL_BG;
  ctx.fillRect(0, 0, W, H);
}

function handleInput() {
  if (!input) return;
  // mouseY scrubs sustained current. Top of screen = 0, bottom = ~14 uA/cm^2.
  // Use the full canvas height so the user can scrub regardless of where the
  // cursor falls within the layout bands.
  if (typeof input.mouseY === 'number' && input.mouseY >= 0 && input.mouseY <= H) {
    mouseYNorm = input.mouseY / H;
    // Map: top half (0..0.5) -> 0..6 uA, bottom half (0.5..1) -> 6..16 uA
    // This gives a "no-spike / firing / block" feel.
    iSustained = mouseYNorm * 16;
  }
  // Clicks inject short pulse (1.5 ms, ~12 uA/cm^2). Multiple clicks stack.
  // consumeClicks() returns a Number (count); truthy when > 0.
  const clicks = (typeof input.consumeClicks === 'function') ? input.consumeClicks() : 0;
  if (clicks > 0) {
    iPulseRemain = 1.5;       // ms
    iPulseAmp = 12;           // uA/cm^2 — enough to fire from rest with no background
  }
}

function tick(args) {
  const ctx = args.ctx;
  if (args.width !== W || args.height !== H) {
    W = args.width; H = args.height;
    computeLayout();
  }

  handleInput();

  // ---- Integrate ----
  for (let s = 0; s < STEPS_PER_FRAME; s++) {
    let I = iSustained;
    if (iPulseRemain > 0) {
      I += iPulseAmp;
      iPulseRemain -= DT_SIM;
      if (iPulseRemain < 0) iPulseRemain = 0;
    }
    lastV = V;
    rk4Step(I);
    simTimeMs += DT_SIM;
    // Spike detection: crossing 0 mV from below
    if (!aboveZero && V > 0) { aboveZero = true; spikeCount++; }
    if (aboveZero && V < -20) aboveZero = false;
    // Append to voltage ring
    trace[head] = V;
    head = (head + 1) % TRACE_LEN;
    if (count < TRACE_LEN) count++;
    // Gate trace downsample: 1 sample per ~5 steps (~0.05 ms apart)
    gateAccum++;
    if (gateAccum >= 5) {
      gateAccum = 0;
      mTrace[gateHead] = m;
      hTrace[gateHead] = h;
      nTrace[gateHead] = n;
      gateHead = (gateHead + 1) % GATE_TRACE_LEN;
      if (gateCount < GATE_TRACE_LEN) gateCount++;
    }
  }
  channelAnim += 0.04;

  // ---- Draw ----
  drawBackground(ctx);
  drawMembrane(ctx);
  drawVoltageTrace(ctx);
  drawGates(ctx);
  drawHUD(ctx);
}

// ---------- drawing ----------

function drawBackground(ctx) {
  // Vertical gradient: a hair lighter near the membrane band, darker near the bottom.
  const g = ctx.createLinearGradient(0, 0, 0, H);
  g.addColorStop(0,    '#0a0e16');
  g.addColorStop(0.45, '#070a11');
  g.addColorStop(1,    '#05070c');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, W, H);

  // Subtle separators between the three bands
  ctx.strokeStyle = 'rgba(80,100,140,0.18)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(0, bandTrace.y + 0.5); ctx.lineTo(W, bandTrace.y + 0.5);
  ctx.moveTo(0, bandGates.y + 0.5); ctx.lineTo(W, bandGates.y + 0.5);
  ctx.stroke();
}

// ---- Top band: membrane schematic ----
function drawMembrane(ctx) {
  const b = bandMembrane;
  ctx.save();
  // Clip to band so glows don't bleed
  ctx.beginPath();
  ctx.rect(b.x, b.y, b.w, b.h);
  ctx.clip();

  const cy = b.y + b.h * 0.5;
  // Membrane band thickness scales with min dimension
  const memT = Math.max(38, Math.min(64, b.h * 0.42));
  const memTop = cy - memT * 0.5;
  const memBot = cy + memT * 0.5;

  // Extracellular side (top) — pale blue tint
  const gExt = ctx.createLinearGradient(0, b.y, 0, memTop);
  gExt.addColorStop(0, 'rgba(80,140,200,0.10)');
  gExt.addColorStop(1, 'rgba(80,140,200,0.02)');
  ctx.fillStyle = gExt;
  ctx.fillRect(b.x, b.y, b.w, memTop - b.y);

  // Intracellular side (bottom) — warm rose tint
  const gInt = ctx.createLinearGradient(0, memBot, 0, b.y + b.h);
  gInt.addColorStop(0, 'rgba(200,120,140,0.10)');
  gInt.addColorStop(1, 'rgba(200,120,140,0.02)');
  ctx.fillStyle = gInt;
  ctx.fillRect(b.x, memBot, b.w, b.y + b.h - memBot);

  // Voltage gradient across membrane: tint the bilayer based on V.
  // Map V in [-90, +50] to a color blend.
  const vNorm = Math.max(0, Math.min(1, (V + 90) / 140));
  const bilayerR = Math.floor(28 + vNorm * 70);
  const bilayerG = Math.floor(32 + vNorm * 30);
  const bilayerB = Math.floor(50 - vNorm * 10);

  // Lipid bilayer: two darker bands sandwiching a slightly lighter core
  ctx.fillStyle = `rgb(${bilayerR}, ${bilayerG}, ${bilayerB})`;
  ctx.fillRect(b.x, memTop, b.w, memT);

  // Inner highlight line
  ctx.strokeStyle = 'rgba(140,160,200,0.20)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(b.x, memTop + 0.5); ctx.lineTo(b.x + b.w, memTop + 0.5);
  ctx.moveTo(b.x, memBot - 0.5); ctx.lineTo(b.x + b.w, memBot - 0.5);
  ctx.stroke();

  // --- Phospholipid head/tail beads sprinkled along the bilayer ---
  // Cheap: a few dotted lines.
  const beadSpace = 12;
  ctx.fillStyle = 'rgba(190,210,240,0.35)';
  for (let x = b.x + 6; x < b.x + b.w; x += beadSpace) {
    ctx.fillRect(x, memTop + 2, 2, 2);
    ctx.fillRect(x, memBot - 4, 2, 2);
  }

  // --- Na+ and K+ channels ---
  // Lay out channels horizontally. Na channels on the left half, K on the right.
  // Each is rendered as a "pore" through the bilayer; openness = m^3*h for Na, n^4 for K.
  // Ion arrows show flux direction relative to the driving force.

  const halfW = b.w * 0.5;
  const naCount = Math.max(3, Math.floor(halfW / 95));
  const kCount  = Math.max(3, Math.floor(halfW / 95));

  const naOpen = clamp01(Math.pow(m, 3) * h);
  const kOpen  = clamp01(Math.pow(n, 4));

  // Driving force determines flux direction & magnitude (sign).
  const dfNa = (V - E_NA);  // negative when below E_Na -> Na flows INWARD (down arrow)
  const dfK  = (V - E_K);   // positive when above E_K -> K flows OUTWARD (up arrow)

  // Na channels
  for (let i = 0; i < naCount; i++) {
    const cx = b.x + (i + 0.5) * (halfW / naCount);
    drawChannel(ctx, cx, cy, memT, naOpen, dfNa, COL_NA, 'Na', i);
  }
  // K channels
  for (let i = 0; i < kCount; i++) {
    const cx = b.x + halfW + (i + 0.5) * (halfW / kCount);
    drawChannel(ctx, cx, cy, memT, kOpen, dfK, COL_K, 'K', i + 100);
  }

  // Labels: extracellular / intracellular
  ctx.font = '10px ui-monospace, monospace';
  ctx.fillStyle = COL_DIM;
  ctx.textAlign = 'left';
  ctx.fillText('extracellular', b.x + 10, b.y + 14);
  ctx.fillText('intracellular', b.x + 10, b.y + b.h - 6);

  // Section labels
  ctx.fillStyle = 'rgba(255,138,91,0.85)';
  ctx.font = 'bold 11px ui-monospace, monospace';
  ctx.fillText('Na+', b.x + 10, cy + 4);
  ctx.fillStyle = 'rgba(122,254,177,0.85)';
  ctx.fillText('K+',  b.x + halfW + 10, cy + 4);

  // Voltage indicator on the right edge of the band: little vertical thermometer.
  drawVoltageBar(ctx, b.x + b.w - 26, b.y + 12, 14, b.h - 24, V);

  ctx.restore();
}

function drawChannel(ctx, cx, cy, memT, openness, driveForce, color, kind, seed) {
  const poreW = 14;
  const poreH = memT - 4;
  const top = cy - poreH * 0.5;
  // Pore body
  ctx.fillStyle = '#161c2a';
  ctx.fillRect(cx - poreW * 0.5, top, poreW, poreH);
  ctx.strokeStyle = 'rgba(150,170,200,0.45)';
  ctx.lineWidth = 1;
  ctx.strokeRect(cx - poreW * 0.5 + 0.5, top + 0.5, poreW - 1, poreH - 1);

  // Inner channel: openness controls how much "open" the gate is.
  // Render an open region from the bottom (intracellular) upward.
  const innerW = poreW - 4;
  const innerH = poreH - 4;
  const openH = innerH * openness;
  const innerX = cx - innerW * 0.5;
  const innerY = top + 2;

  // Closed remainder (dark)
  ctx.fillStyle = '#0b0e16';
  ctx.fillRect(innerX, innerY, innerW, innerH - openH);
  // Open region (colored, glowing)
  if (openH > 0.5) {
    const g = ctx.createLinearGradient(innerX, innerY + innerH - openH, innerX, innerY + innerH);
    g.addColorStop(0, hexA(color, 0.95));
    g.addColorStop(1, hexA(color, 0.55));
    ctx.fillStyle = g;
    ctx.fillRect(innerX, innerY + innerH - openH, innerW, openH);
    // Glow halo
    ctx.fillStyle = hexA(color, 0.12 * Math.min(1, openness * 1.5));
    ctx.fillRect(innerX - 3, innerY + innerH - openH - 3, innerW + 6, openH + 6);
  }

  // Ion flux arrows: only render if channel is open enough AND there is drive.
  // Na: dfNa < 0 -> inward (downward arrow); K: dfK > 0 -> outward (upward arrow).
  const fluxStrength = clamp01(openness * Math.min(1, Math.abs(driveForce) / 80));
  if (fluxStrength > 0.05) {
    const dir = driveForce < 0 ? +1 : -1; // +1 = down (into cell), -1 = up (out of cell)
    const numIons = 3;
    const phase = (channelAnim + seed * 0.37) % 1;
    for (let k = 0; k < numIons; k++) {
      const t = (phase + k / numIons) % 1;
      // Travel from above pore (dir=+1) to below pore, or vice versa.
      let iy;
      if (dir > 0) {
        iy = (cy - poreH * 0.5 - 12) + t * (poreH + 20);
      } else {
        iy = (cy + poreH * 0.5 + 12) - t * (poreH + 20);
      }
      const a = 0.85 * fluxStrength * Math.sin(Math.PI * t); // fade in/out
      ctx.fillStyle = hexA(color, a);
      ctx.beginPath();
      ctx.arc(cx, iy, 2.4, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  // Tiny label below the pore: openness percent (only on larger canvases)
  if (memT >= 50) {
    ctx.fillStyle = hexA(color, 0.7);
    ctx.font = '9px ui-monospace, monospace';
    ctx.textAlign = 'center';
    ctx.fillText(`${Math.round(openness * 100)}%`, cx, cy + poreH * 0.5 + 12);
    ctx.textAlign = 'left';
  }
}

function drawVoltageBar(ctx, x, y, w, h, V) {
  // Map V from [-90, +60] to height fraction (bottom = -90, top = +60)
  const minV = -90, maxV = 60;
  const frac = (V - minV) / (maxV - minV);
  const fillH = Math.max(0, Math.min(1, frac)) * h;

  // Frame
  ctx.fillStyle = 'rgba(20,25,40,0.85)';
  ctx.fillRect(x, y, w, h);
  ctx.strokeStyle = 'rgba(140,160,200,0.45)';
  ctx.lineWidth = 1;
  ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);

  // Fill from bottom upward
  const fy = y + h - fillH;
  const g = ctx.createLinearGradient(x, fy, x, y + h);
  // Hue depends on whether we're depolarized or hyperpolarized vs rest
  let topCol, botCol;
  if (V > -55) { topCol = 'rgba(255,138,91,0.85)'; botCol = 'rgba(255,80,40,0.55)'; }
  else         { topCol = 'rgba(126,214,255,0.8)'; botCol = 'rgba(80,160,220,0.5)'; }
  g.addColorStop(0, topCol);
  g.addColorStop(1, botCol);
  ctx.fillStyle = g;
  ctx.fillRect(x + 1, fy, w - 2, fillH);

  // Tick at rest level
  const restY = y + h - ((V_REST - minV) / (maxV - minV)) * h;
  ctx.strokeStyle = COL_REST;
  ctx.beginPath();
  ctx.moveTo(x - 2, restY); ctx.lineTo(x + w + 2, restY);
  ctx.stroke();
  // Tick at threshold
  const thrY = y + h - ((-55 - minV) / (maxV - minV)) * h;
  ctx.strokeStyle = COL_THRESH;
  ctx.setLineDash([2, 3]);
  ctx.beginPath();
  ctx.moveTo(x - 2, thrY); ctx.lineTo(x + w + 2, thrY);
  ctx.stroke();
  ctx.setLineDash([]);

  ctx.fillStyle = COL_DIM;
  ctx.font = '9px ui-monospace, monospace';
  ctx.textAlign = 'right';
  ctx.fillText(`${V.toFixed(0)} mV`, x - 4, restY + 3);
  ctx.textAlign = 'left';
}

// ---- Middle band: voltage trace ----
function drawVoltageTrace(ctx) {
  const b = bandTrace;
  const padL = 44, padR = 16, padT = 16, padB = 22;
  const plotX = b.x + padL;
  const plotY = b.y + padT;
  const plotW = b.w - padL - padR;
  const plotH = b.h - padT - padB;

  // Plot frame
  ctx.fillStyle = 'rgba(8,11,18,0.65)';
  ctx.fillRect(plotX, plotY, plotW, plotH);

  // V range: -90 to +60 mV
  const vmin = -90, vmax = 60;
  const vToY = (v) => plotY + (1 - (v - vmin) / (vmax - vmin)) * plotH;

  // Horizontal reference lines
  ctx.font = '9px ui-monospace, monospace';
  ctx.textAlign = 'right';
  const refs = [
    { v: 50,    label: '+50',         col: 'rgba(120,140,170,0.15)' },
    { v: 0,     label: '  0',         col: 'rgba(120,140,170,0.22)' },
    { v: -55,   label: '-55 thresh',  col: COL_THRESH },
    { v: -65,   label: '-65 rest',    col: COL_REST },
    { v: -77,   label: '-77 EK',      col: 'rgba(122,254,177,0.18)' },
  ];
  for (const r of refs) {
    const y = vToY(r.v);
    ctx.strokeStyle = r.col;
    ctx.setLineDash(r.label.includes('thresh') ? [3, 3] : []);
    ctx.beginPath();
    ctx.moveTo(plotX, y); ctx.lineTo(plotX + plotW, y);
    ctx.stroke();
    ctx.setLineDash([]);
    ctx.fillStyle = r.label.includes('thresh') ? 'rgba(255,180,90,0.7)' :
                    r.label.includes('rest')   ? 'rgba(120,170,220,0.75)' :
                    'rgba(140,155,180,0.55)';
    ctx.fillText(r.label, plotX - 4, y + 3);
  }
  ctx.textAlign = 'left';

  // x-axis (time) ticks every 50 ms
  const tMaxMs = TRACE_MS;
  ctx.strokeStyle = COL_AXIS_DIM;
  ctx.fillStyle = COL_DIM;
  for (let t = 0; t <= tMaxMs; t += 50) {
    const x = plotX + (t / tMaxMs) * plotW;
    ctx.beginPath();
    ctx.moveTo(x, plotY + plotH - 3);
    ctx.lineTo(x, plotY + plotH);
    ctx.stroke();
    if (t !== 0 && t !== tMaxMs) {
      const label = `${-tMaxMs + t}`;
      ctx.textAlign = 'center';
      ctx.fillText(label, x, plotY + plotH + 11);
    }
  }
  ctx.textAlign = 'left';
  ctx.fillText('-250 ms', plotX, plotY + plotH + 11);
  ctx.textAlign = 'right';
  ctx.fillText('now', plotX + plotW, plotY + plotH + 11);
  ctx.textAlign = 'left';

  // Draw the trace. trace[head-1] is the newest sample. We map left edge =
  // oldest, right edge = newest.
  const startIdx = (head - count + TRACE_LEN) % TRACE_LEN;
  // Downsample: only one point per pixel.
  const samplesPerPx = count / plotW;

  // Underlay: area fill below 0 mV line for visual "spike" emphasis
  ctx.beginPath();
  ctx.moveTo(plotX, vToY(V_REST));
  let firstY = null;
  for (let px = 0; px < plotW; px++) {
    const s0 = Math.floor(px * samplesPerPx);
    let vmaxLocal = -1e9;
    let vminLocal = 1e9;
    const sEnd = Math.min(count, Math.floor((px + 1) * samplesPerPx));
    for (let i = s0; i < sEnd; i++) {
      const v = trace[(startIdx + i) % TRACE_LEN];
      if (v > vmaxLocal) vmaxLocal = v;
      if (v < vminLocal) vminLocal = v;
    }
    if (vmaxLocal === -1e9) {
      const v = trace[(startIdx + s0) % TRACE_LEN];
      vmaxLocal = v; vminLocal = v;
    }
    const y = vToY(vmaxLocal);
    if (firstY === null) { firstY = y; ctx.moveTo(plotX, y); }
    ctx.lineTo(plotX + px, y);
  }
  // Close path to baseline for fill
  ctx.lineTo(plotX + plotW, plotY + plotH);
  ctx.lineTo(plotX, plotY + plotH);
  ctx.closePath();
  const fillG = ctx.createLinearGradient(0, plotY, 0, plotY + plotH);
  fillG.addColorStop(0, 'rgba(126,214,255,0.20)');
  fillG.addColorStop(0.5, 'rgba(126,214,255,0.06)');
  fillG.addColorStop(1, 'rgba(126,214,255,0)');
  ctx.fillStyle = fillG;
  ctx.fill();

  // Main trace stroke (min/max per pixel as a thin vertical bar, then top line)
  ctx.strokeStyle = COL_V;
  ctx.lineWidth = 1.4;
  ctx.beginPath();
  for (let px = 0; px < plotW; px++) {
    const s0 = Math.floor(px * samplesPerPx);
    const sEnd = Math.min(count, Math.floor((px + 1) * samplesPerPx));
    let vmaxLocal = -1e9;
    let vminLocal = 1e9;
    for (let i = s0; i < sEnd; i++) {
      const v = trace[(startIdx + i) % TRACE_LEN];
      if (v > vmaxLocal) vmaxLocal = v;
      if (v < vminLocal) vminLocal = v;
    }
    if (vmaxLocal === -1e9) {
      const v = trace[(startIdx + s0) % TRACE_LEN];
      vmaxLocal = v; vminLocal = v;
    }
    if (px === 0) ctx.moveTo(plotX + px, vToY(vmaxLocal));
    else ctx.lineTo(plotX + px, vToY(vmaxLocal));
    // For pixels containing significant variation, also draw min so we don't
    // alias spikes into hairlines.
    if (vmaxLocal - vminLocal > 4) {
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(plotX + px, vToY(vmaxLocal));
      ctx.lineTo(plotX + px, vToY(vminLocal));
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(plotX + px, vToY(vmaxLocal));
    }
  }
  ctx.stroke();

  // Subtle glow line (one px wider, lower alpha)
  ctx.strokeStyle = COL_V_GLOW;
  ctx.lineWidth = 3;
  ctx.beginPath();
  for (let px = 0; px < plotW; px += 2) {
    const s0 = Math.floor(px * samplesPerPx);
    const v = trace[(startIdx + s0) % TRACE_LEN];
    if (px === 0) ctx.moveTo(plotX + px, vToY(v));
    else ctx.lineTo(plotX + px, vToY(v));
  }
  ctx.stroke();

  // Current voltage marker (right edge)
  const yNow = vToY(V);
  ctx.fillStyle = '#ffffff';
  ctx.beginPath();
  ctx.arc(plotX + plotW - 1, yNow, 3, 0, Math.PI * 2);
  ctx.fill();

  // Title
  ctx.fillStyle = COL_TEXT;
  ctx.font = '11px ui-monospace, monospace';
  ctx.fillText('V(t)  membrane voltage (mV)', plotX, plotY - 4);

  // Pulse indicator: a small triangle on the right edge if a pulse is active
  if (iPulseRemain > 0) {
    ctx.fillStyle = 'rgba(255,200,90,0.9)';
    ctx.beginPath();
    ctx.moveTo(plotX + plotW + 4, yNow - 4);
    ctx.lineTo(plotX + plotW + 4, yNow + 4);
    ctx.lineTo(plotX + plotW + 10, yNow);
    ctx.closePath();
    ctx.fill();
  }
}

// ---- Bottom band: m, h, n traces ----
function drawGates(ctx) {
  const b = bandGates;
  if (b.h < 40) return;
  const padL = 44, padR = 16, padT = 8, padB = 14;
  const plotX = b.x + padL;
  const plotY = b.y + padT;
  const plotW = b.w - padL - padR;
  const plotH = b.h - padT - padB;

  ctx.fillStyle = 'rgba(8,11,18,0.65)';
  ctx.fillRect(plotX, plotY, plotW, plotH);

  // 0 and 1 reference lines
  ctx.strokeStyle = COL_AXIS_DIM;
  ctx.beginPath();
  ctx.moveTo(plotX, plotY + 0.5); ctx.lineTo(plotX + plotW, plotY + 0.5);
  ctx.moveTo(plotX, plotY + plotH - 0.5); ctx.lineTo(plotX + plotW, plotY + plotH - 0.5);
  ctx.moveTo(plotX, plotY + plotH * 0.5); ctx.lineTo(plotX + plotW, plotY + plotH * 0.5);
  ctx.stroke();

  ctx.fillStyle = COL_DIM;
  ctx.font = '9px ui-monospace, monospace';
  ctx.textAlign = 'right';
  ctx.fillText('1.0', plotX - 4, plotY + 7);
  ctx.fillText('0.5', plotX - 4, plotY + plotH * 0.5 + 3);
  ctx.fillText('0.0', plotX - 4, plotY + plotH - 2);
  ctx.textAlign = 'left';

  // Draw three traces
  const startIdx = (gateHead - gateCount + GATE_TRACE_LEN) % GATE_TRACE_LEN;
  const valAtPx = (arr, px) => {
    const i = Math.floor((px / plotW) * gateCount);
    return arr[(startIdx + Math.min(gateCount - 1, i)) % GATE_TRACE_LEN];
  };
  function plot(arr, color, label, labelX) {
    ctx.strokeStyle = color;
    ctx.lineWidth = 1.3;
    ctx.beginPath();
    for (let px = 0; px < plotW; px++) {
      const v = valAtPx(arr, px);
      const y = plotY + (1 - v) * plotH;
      if (px === 0) ctx.moveTo(plotX + px, y);
      else ctx.lineTo(plotX + px, y);
    }
    ctx.stroke();
    // Label at right edge (current value)
    const vNow = arr[(gateHead - 1 + GATE_TRACE_LEN) % GATE_TRACE_LEN];
    const yNow = plotY + (1 - vNow) * plotH;
    ctx.fillStyle = color;
    ctx.font = '10px ui-monospace, monospace';
    ctx.fillText(label, labelX, yNow + 4);
  }
  plot(mTrace, COL_M, `m=${m.toFixed(2)}`, plotX + plotW + 4);
  plot(hTrace, COL_H, `h=${h.toFixed(2)}`, plotX + plotW + 4);
  plot(nTrace, COL_N, `n=${n.toFixed(2)}`, plotX + plotW + 4);

  // Title
  ctx.fillStyle = COL_TEXT;
  ctx.font = '11px ui-monospace, monospace';
  ctx.fillText('gating variables  m (Na act)  h (Na inact)  n (K act)', plotX, plotY - 1);

  // Tiny legend dots (anchored to the right of the plot so they never overflow
  // the title text on a narrow canvas).
  if (plotW > 240) {
    const baseX = plotX + plotW - 96;
    ctx.fillStyle = COL_M; ctx.fillRect(baseX,      plotY - 9, 8, 2);
    ctx.fillStyle = COL_H; ctx.fillRect(baseX + 32, plotY - 9, 8, 2);
    ctx.fillStyle = COL_N; ctx.fillRect(baseX + 64, plotY - 9, 8, 2);
  }
}

// ---- HUD overlay ----
function drawHUD(ctx) {
  // Top-left: live readout
  ctx.font = '10px ui-monospace, monospace';
  ctx.fillStyle = 'rgba(190,200,220,0.85)';
  const iTotal = iSustained + (iPulseRemain > 0 ? iPulseAmp : 0);
  ctx.fillText(`I_inj = ${iTotal.toFixed(1)} uA/cm^2`, 10, H - 30);
  ctx.fillStyle = COL_DIM;
  ctx.fillText(`spikes: ${spikeCount}`, 10, H - 18);

  // Bottom-right: regime label based on iSustained
  let regime = 'silent';
  let regimeCol = 'rgba(140,160,200,0.7)';
  if (iSustained > 9.5)      { regime = 'depolarization block'; regimeCol = 'rgba(255,138,91,0.85)'; }
  else if (iSustained > 6.2) { regime = 'sustained firing';     regimeCol = 'rgba(255,205,90,0.9)'; }
  else if (iSustained > 2.3) { regime = 'subthreshold';         regimeCol = 'rgba(180,200,230,0.85)'; }
  ctx.textAlign = 'right';
  ctx.fillStyle = regimeCol;
  ctx.fillText(regime, W - 10, H - 8);
  ctx.textAlign = 'left';
}

// ---------- utilities ----------

function clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x; }

// Build "color with alpha" from a hex string like "#7afeb1"
function hexA(hex, a) {
  const h = hex.replace('#', '');
  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);
  return `rgba(${r},${g},${b},${a})`;
}

Comments (0)

Log in to comment.