2

Cable Equation: Dendrite Conduction

A dendrite is a leaky electrical cable. Membrane capacitance stores charge, the membrane resistance leaks it, and the axial cytoplasm carries it sideways. The linear cable equation tracks this competition: . The **membrane time constant** sets how fast voltage decays in time; the **electrotonic length constant** sets how far voltage spreads in space before leaking away. In steady state with a point current injection, — voltage falls off **exponentially** along the cable, which is why a synapse far from the soma contributes much less to firing than one nearby. We discretize the cable on segments with finite differences () and integrate in time with explicit Euler plus adaptive sub-stepping for stability. Click anywhere on the cable to inject a brief current pulse there: watch the bump spread symmetrically and fade, with the spatial envelope decaying as and the peak shrinking on the timescale. The top panel is a heatmap of over the last ~2 seconds of model time so you can read off both decay constants visually — the bright wedge widens at rate . Press **M** (or tap the mode button) to switch to a **myelinated** axon: every tenth segment becomes a **node of Ranvier** with a much larger effective , and the internodes (between nodes, wrapped in a lipid sheath of low capacitance) have a still-larger one. The result is that an injected pulse appears to **jump** from node to node rather than diffusing smoothly — a cartoon of saltatory conduction. Real myelinated axons exploit exactly this: putting low-leak insulation between excitable nodes raises conduction velocity by an order of magnitude, which is why multiple-sclerosis lesions that strip myelin slow neural signaling so dramatically. Bonus keys: **Space** injects at the middle of the cable, **R** resets.

idle
426 lines · vanilla
view source
// Passive cable equation on a 1D dendrite.
// tau_m * dV/dt = lambda^2 * d2V/dx^2 - V + R_m * I(x,t)
// Discretized on N segments with finite differences and explicit Euler.
// Click anywhere on the cable to inject a brief current pulse at that segment.
// Press M (or tap the toggle button) to switch between PASSIVE and MYELINATED:
// in myelinated mode every 10th segment is a "node of Ranvier" with a much
// larger effective space constant in its neighborhood, sketching saltatory
// conduction — pulses appear to jump from node to node instead of decaying
// smoothly along the cable.

const N = 120;                 // segments
const TAU_M_MS = 12;           // membrane time constant (ms)
const LAMBDA_BASE = 4.0;       // space constant (in segments) for passive cable
const LAMBDA_MYELIN = 14.0;    // effective space constant at/between nodes
const NODE_EVERY = 10;         // node-of-Ranvier spacing in segments
const NODE_HALF_WIDTH = 1;     // how many segments around each node feel the boost
const PULSE_AMP = 2.6;         // injected current amplitude
const PULSE_DUR_MS = 1.5;      // pulse duration
const SIM_MS_PER_SEC = 50;     // playback rate: 50 ms of model time per real second
const HISTORY_FRAMES = 90;     // V(x,t) heatmap history depth

let W = 0, H = 0;
let V = new Float64Array(N);     // membrane voltage at each segment
let I = new Float64Array(N);     // injected current at each segment
let pulseTimer = new Float64Array(N); // remaining ms of pulse per segment
let vScratch = new Float64Array(N);   // reused scratch buffer for the explicit Euler step
let history = null;              // Float32Array N*HISTORY_FRAMES rolling buffer
let heatImageData = null;        // reused ImageData for the V(x,t) heatmap
let histHead = 0, histCount = 0;
let myelinated = false;
let inputRef = null;
let modelTimeMs = 0;
let lastPulseAt = -1;
let lastPulseSeg = -1;
let buttonRect = null;           // touch-friendly toggle hit-box {x,y,w,h}
let resetRect  = null;           // touch-friendly reset hit-box {x,y,w,h}

// Pre-baked offscreen for the heatmap, redrawn per frame (cheap at 120x90).
let heatCanvas = null, heatCtx = null;

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

function layout() {
  // Heatmap on top half, cable + voltage line on bottom half.
  const padX = Math.max(16, W * 0.04);
  const padTop = Math.max(48, H * 0.10);
  const padBot = Math.max(28, H * 0.08);

  const plotX = padX;
  const plotW = W - 2 * padX;

  const heatH = Math.max(60, (H - padTop - padBot) * 0.42);
  const lineH = Math.max(70, (H - padTop - padBot) * 0.38);
  const cableH = Math.max(18, (H - padTop - padBot) * 0.10);
  const gap = Math.max(8, (H - padTop - padBot - heatH - lineH - cableH) / 3);

  const heatY = padTop;
  const lineY = heatY + heatH + gap;
  const cableY = lineY + lineH + gap;

  return { plotX, plotW, heatY, heatH, lineY, lineH, cableY, cableH };
}

// ---------- physics ----------

function lambdaSq(i) {
  // Local lambda^2 (in segment units) at segment i.
  if (!myelinated) return LAMBDA_BASE * LAMBDA_BASE;
  // Nearest node distance
  const k = Math.round(i / NODE_EVERY) * NODE_EVERY;
  const d = Math.abs(i - k);
  // Within NODE_HALF_WIDTH of a node, use the strong space constant; in the
  // myelin sheath between nodes, use an even larger one to model the fact
  // that voltage spreads almost without leak under the sheath. The contrast
  // between nodes vs internodes produces the "saltatory" visual.
  if (d <= NODE_HALF_WIDTH) return LAMBDA_MYELIN * LAMBDA_MYELIN * 0.9;
  // Internode: very high effective lambda so V barely decays under the myelin
  return LAMBDA_MYELIN * LAMBDA_MYELIN * 1.6;
}

function isNode(i) {
  return myelinated && (i % NODE_EVERY === 0);
}

function stepCable(dtMs) {
  // Explicit Euler on tau_m * dV/dt = lambda^2 * d2V/dx^2 - V + I
  // Stability: dt/tau_m * (1 + 2*lambda^2) < 1.  With dt = 0.25ms, tau=12,
  // lambda^2 up to ~315, the bracket can hit ~52 => factor ~1.08. Use substeps.
  const maxLamSq = myelinated ? LAMBDA_MYELIN * LAMBDA_MYELIN * 1.6 : LAMBDA_BASE * LAMBDA_BASE;
  const stability = (dtMs / TAU_M_MS) * (1 + 2 * maxLamSq);
  const substeps = Math.max(1, Math.ceil(stability / 0.45));
  const h = dtMs / substeps;

  // Reuse the module-scoped scratch buffer instead of allocating per call.
  const next = vScratch;
  for (let s = 0; s < substeps; s++) {
    for (let i = 0; i < N; i++) {
      const vL = i > 0 ? V[i - 1] : V[i];      // Neumann BC: zero gradient
      const vR = i < N - 1 ? V[i + 1] : V[i];
      const d2 = vL - 2 * V[i] + vR;
      const lam2 = lambdaSq(i);
      const dV = (lam2 * d2 - V[i] + I[i]) / TAU_M_MS;
      next[i] = V[i] + h * dV;
    }
    // Swap
    for (let i = 0; i < N; i++) V[i] = next[i];
  }

  // Decay active pulses
  for (let i = 0; i < N; i++) {
    if (pulseTimer[i] > 0) {
      pulseTimer[i] -= dtMs;
      if (pulseTimer[i] <= 0) {
        pulseTimer[i] = 0;
        I[i] = 0;
      }
    }
  }
}

function pushHistory() {
  // Snapshot current V into the ring buffer.
  const off = histHead * N;
  for (let i = 0; i < N; i++) history[off + i] = V[i];
  histHead = (histHead + 1) % HISTORY_FRAMES;
  if (histCount < HISTORY_FRAMES) histCount++;
}

function injectPulse(seg) {
  if (seg < 0 || seg >= N) return;
  I[seg] = PULSE_AMP;
  pulseTimer[seg] = PULSE_DUR_MS;
  lastPulseAt = modelTimeMs;
  lastPulseSeg = seg;
}

// ---------- color ----------

function voltageColor(v) {
  // Bipolar palette: cyan (neg) -> dark -> warm orange (pos).
  // Normalize against amplitude near 1.
  const t = Math.max(-1.2, Math.min(1.2, v / 1.2));
  if (t >= 0) {
    // 0 -> dark, 1 -> bright amber/orange
    const a = t;
    const r = Math.floor(20 + a * 235);
    const g = Math.floor(15 + a * 150);
    const b = Math.floor(25 + a * 30);
    return `rgb(${r},${g},${b})`;
  } else {
    const a = -t;
    const r = Math.floor(15 + a * 30);
    const g = Math.floor(20 + a * 170);
    const b = Math.floor(30 + a * 220);
    return `rgb(${r},${g},${b})`;
  }
}

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

function drawBackground(ctx) {
  ctx.fillStyle = '#08090d';
  ctx.fillRect(0, 0, W, H);
}

function drawHeatmap(ctx, L) {
  if (!heatCanvas || heatCanvas.width !== N || heatCanvas.height !== HISTORY_FRAMES) {
    heatCanvas = new OffscreenCanvas(N, HISTORY_FRAMES);
    heatCtx = heatCanvas.getContext('2d');
    heatImageData = heatCtx.createImageData(N, HISTORY_FRAMES);
  }
  // Newest row at bottom; older rows above. Walk from oldest to newest.
  const img = heatImageData;
  for (let row = 0; row < HISTORY_FRAMES; row++) {
    const age = HISTORY_FRAMES - 1 - row; // 0 newest, HF-1 oldest
    let src;
    if (age >= histCount) {
      // No data yet — render dark.
      src = null;
    } else {
      const idx = (histHead - 1 - age + HISTORY_FRAMES) % HISTORY_FRAMES;
      src = idx;
    }
    for (let i = 0; i < N; i++) {
      let r = 12, g = 14, b = 20;
      if (src !== null) {
        const v = history[src * N + i];
        const t = Math.max(-1.2, Math.min(1.2, v / 1.2));
        if (t >= 0) {
          const a = t;
          r = Math.floor(20 + a * 235);
          g = Math.floor(15 + a * 150);
          b = Math.floor(25 + a * 30);
        } else {
          const a = -t;
          r = Math.floor(15 + a * 30);
          g = Math.floor(20 + a * 170);
          b = Math.floor(30 + a * 220);
        }
      }
      const p = (row * N + i) * 4;
      img.data[p] = r;
      img.data[p + 1] = g;
      img.data[p + 2] = b;
      img.data[p + 3] = 255;
    }
  }
  heatCtx.putImageData(img, 0, 0);

  ctx.imageSmoothingEnabled = true;
  ctx.drawImage(heatCanvas, L.plotX, L.heatY, L.plotW, L.heatH);

  // Border + label
  ctx.strokeStyle = 'rgba(120,140,170,0.25)';
  ctx.lineWidth = 1;
  ctx.strokeRect(L.plotX + 0.5, L.heatY + 0.5, L.plotW - 1, L.heatH - 1);

  ctx.fillStyle = 'rgba(170,185,210,0.7)';
  ctx.font = '10px monospace';
  ctx.fillText('V(x, t)  — time flows down', L.plotX, L.heatY - 6);

  // "now" indicator
  ctx.fillStyle = 'rgba(220,230,255,0.5)';
  ctx.fillText('now', L.plotX + L.plotW + 4, L.heatY + L.heatH - 2);
  ctx.fillStyle = 'rgba(170,185,210,0.35)';
  ctx.fillText('past', L.plotX + L.plotW + 4, L.heatY + 10);
}

function drawVoltageLine(ctx, L) {
  // Axes
  const y0 = L.lineY + L.lineH * 0.5; // V=0 baseline
  ctx.strokeStyle = 'rgba(120,140,170,0.18)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(L.plotX, y0);
  ctx.lineTo(L.plotX + L.plotW, y0);
  ctx.stroke();

  // V trace
  const vScale = L.lineH * 0.42;
  ctx.lineWidth = 1.6;
  ctx.strokeStyle = myelinated ? '#ffb35a' : '#7ac8ff';
  ctx.beginPath();
  for (let i = 0; i < N; i++) {
    const x = L.plotX + (i + 0.5) * (L.plotW / N);
    const v = V[i];
    const t = Math.max(-1.3, Math.min(1.3, v / 1.2));
    const y = y0 - t * vScale;
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.stroke();

  // Subtle fill under trace
  ctx.lineTo(L.plotX + L.plotW, y0);
  ctx.lineTo(L.plotX, y0);
  ctx.closePath();
  ctx.fillStyle = myelinated ? 'rgba(255,179,90,0.10)' : 'rgba(122,200,255,0.10)';
  ctx.fill();

  // Label
  ctx.fillStyle = 'rgba(170,185,210,0.7)';
  ctx.font = '10px monospace';
  ctx.fillText('V(x)', L.plotX, L.lineY + 12);

  // lambda axis indicator: draw a horizontal bar of length lambda (in pixels)
  // showing how far one space constant reaches. For myelinated mode show two
  // bars: the local sheath lambda and the around-node lambda.
  const pxPerSeg = L.plotW / N;
  const ay = L.lineY + L.lineH - 12;
  if (!myelinated) {
    const lamPx = LAMBDA_BASE * pxPerSeg;
    drawLambdaBar(ctx, L.plotX + 8, ay, lamPx, '#7ac8ff', 'λ');
  } else {
    const lamNode = LAMBDA_MYELIN * Math.sqrt(0.9) * pxPerSeg;
    drawLambdaBar(ctx, L.plotX + 8, ay, lamNode, '#ffb35a', 'λ (node)');
  }
}

function drawLambdaBar(ctx, x, y, len, color, label) {
  ctx.strokeStyle = color;
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  ctx.moveTo(x, y);
  ctx.lineTo(x + len, y);
  // end caps
  ctx.moveTo(x, y - 3);
  ctx.lineTo(x, y + 3);
  ctx.moveTo(x + len, y - 3);
  ctx.lineTo(x + len, y + 3);
  ctx.stroke();
  ctx.fillStyle = color;
  ctx.font = '10px monospace';
  ctx.fillText(label, x + len + 6, y + 4);
}

function drawCable(ctx, L) {
  // Cable strip: filled by current V at each segment.
  const segW = L.plotW / N;
  for (let i = 0; i < N; i++) {
    ctx.fillStyle = voltageColor(V[i]);
    ctx.fillRect(L.plotX + i * segW, L.cableY, segW + 0.5, L.cableH);
  }
  // Outer border
  ctx.strokeStyle = 'rgba(120,140,170,0.4)';
  ctx.lineWidth = 1;
  ctx.strokeRect(L.plotX + 0.5, L.cableY + 0.5, L.plotW - 1, L.cableH - 1);

  // Nodes of Ranvier (myelinated mode): annotate every NODE_EVERY-th segment
  // with a small gap and a thin gold tick.
  if (myelinated) {
    // Myelin sheath highlight: dim the internode regions.
    ctx.fillStyle = 'rgba(140, 110, 60, 0.18)';
    for (let k = NODE_EVERY; k < N; k += NODE_EVERY) {
      // Internode is between (k - NODE_EVERY + NODE_HALF_WIDTH + 1) and (k - NODE_HALF_WIDTH - 1)
      const a = (k - NODE_EVERY + NODE_HALF_WIDTH + 1);
      const b = (k - NODE_HALF_WIDTH - 1);
      if (b > a) {
        ctx.fillRect(L.plotX + a * segW, L.cableY - 3, (b - a + 1) * segW, L.cableH + 6);
      }
    }
    // Node ticks (gold)
    for (let i = 0; i < N; i += NODE_EVERY) {
      const x = L.plotX + (i + 0.5) * segW;
      ctx.strokeStyle = 'rgba(255, 200, 110, 0.85)';
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.moveTo(x, L.cableY - 6);
      ctx.lineTo(x, L.cableY + L.cableH + 6);
      ctx.stroke();
    }
    // small label
    ctx.fillStyle = 'rgba(255, 200, 110, 0.85)';
    ctx.font = '10px monospace';
    ctx.fillText('nodes of Ranvier', L.plotX, L.cableY + L.cableH + 18);
  } else {
    ctx.fillStyle = 'rgba(170,185,210,0.65)';
    ctx.font = '10px monospace';
    ctx.fillText('passive dendrite', L.plotX, L.cableY + L.cableH + 18);
  }

  // x = 0 ... L labels
  ctx.fillStyle = 'rgba(120,140,170,0.6)';
  ctx.font = '10px monospace';
  ctx.fillText('x = 0', L.plotX, L.cableY - 4);
  ctx.textAlign = 'right';
  ctx.fillText('x = L', L.plotX + L.plotW, L.cableY - 4);
  ctx.textAlign = 'left';
}

function drawHUD(ctx, L) {
  // Title row
  ctx.fillStyle = 'rgba(220,230,255,0.92)';
  ctx.font = '12px monospace';
  ctx.fillText('Cable equation', 12, 18);
  ctx.fillStyle = 'rgba(170,185,210,0.75)';
  ctx.font = '10px monospace';
  ctx.fillText(
    `τₘ = ${TAU_M_MS} ms   λ = ${myelinated ? LAMBDA_MYELIN.toFixed(1) : LAMBDA_BASE.toFixed(1)} seg   N = ${N}`,
    12, 32
  );

  // Mode pill / button (also a tap target on mobile)
  const label = myelinated ? 'mode: MYELINATED' : 'mode: PASSIVE';
  ctx.font = '11px monospace';
  const tw = ctx.measureText(label).width;
  const bw = tw + 22;
  const bh = 22;
  const bx = W - bw - 12;
  const by = 10;
  ctx.fillStyle = myelinated ? 'rgba(80, 50, 20, 0.7)' : 'rgba(20, 40, 70, 0.7)';
  ctx.strokeStyle = myelinated ? 'rgba(255, 200, 110, 0.8)' : 'rgba(122, 200, 255, 0.8)';
  ctx.lineWidth = 1;
  roundRect(ctx, bx, by, bw, bh, 6);
  ctx.fill();
  ctx.stroke();
  ctx.fillStyle = myelinated ? '#ffd58a' : '#bfe0ff';
  ctx.fillText(label, bx + 11, by + 15);
  buttonRect = { x: bx, y: by, w: bw, h: bh };

  // Reset pill, left of the mode pill — touch alternative to the R key.
  const resetLabel = 'reset';
  const rtw = ctx.measureText(resetLabel).width;
  const rbw = rtw + 18;
  const rbh = 22;
  const rbx = bx - rbw - 8;
  const rby = by;
  if (rbx > 12) {
    ctx.fillStyle = 'rgba(30, 30, 40, 0.7)';
    ctx.strokeStyle = 'rgba(170, 185, 210, 0.5)';
    ctx.lineWidth = 1;
    roundRect(ctx, rbx, rby, rbw, rbh, 6);
    ctx.fill();
    ctx.stroke();
    ctx.fillStyle = 'rgba(220, 230, 245, 0.9)';
    ctx.fillText(resetLabel, rbx + 9, rby + 15);
    resetRect = { x: rbx, y: rby, w: rbw, h: rbh };
  } else {
    resetRect = null;
  }

  // Hint
  ctx.fillStyle = 'rgba(170,185,210,0.55)';
  ctx.font = '10px monospace';
  ctx.textAlign = 'right';
  ctx.fillText('tap cable to inject · M toggle · reset', W - 12, H - 10);
  ctx.textAlign = 'left';

  // Time
  ctx.fillStyle = 'rgba(140,155,180,0.7)';
  ctx.fillText(`t = ${(modelTimeMs / 1000).toFixed(2)} s (model)`, 12, H - 10);

  // Recent-pulse callout
  if (lastPulseAt >= 0 && modelTimeMs - lastPulseAt < 800) {
    const since = modelTimeMs - lastPulseAt;
    const alpha = Math.max(0, 1 - since / 800);
    const L2 = layout();
    const px = L2.plotX + (lastPulseSeg + 0.5) * (L2.plotW / N);
    ctx.strokeStyle = `rgba(255, 240, 200, ${alpha.toFixed(3)})`;
    ctx.lineWidth = 1;
    ctx.setLineDash([2, 3]);
    ctx.beginPath();
    ctx.moveTo(px, L2.cableY - 4);
    ctx.lineTo(px, L2.heatY + L2.heatH + 4);
    ctx.stroke();
    ctx.setLineDash([]);
  }
}

function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  ctx.lineTo(x + w, y + h - r);
  ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  ctx.lineTo(x + r, y + h);
  ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
}

// ---------- input ----------

function handleClicks() {
  if (!inputRef) return;
  const clicks = (typeof inputRef.consumeClicks === 'function') ? inputRef.consumeClicks() : 0;
  if (clicks <= 0) return;
  const mx = inputRef.mouseX;
  const my = inputRef.mouseY;
  if (mx == null || my == null) return;

  // Button hit-test
  if (buttonRect &&
      mx >= buttonRect.x && mx <= buttonRect.x + buttonRect.w &&
      my >= buttonRect.y && my <= buttonRect.y + buttonRect.h) {
    myelinated = !myelinated;
    return;
  }
  if (resetRect &&
      mx >= resetRect.x && mx <= resetRect.x + resetRect.w &&
      my >= resetRect.y && my <= resetRect.y + resetRect.h) {
    V.fill(0);
    I.fill(0);
    pulseTimer.fill(0);
    if (history) history.fill(0);
    histCount = 0; histHead = 0;
    return;
  }

  // Otherwise, treat as a cable injection if click is in the wide cable band
  // (heatmap, V-trace, or cable strip all count — generous mobile target).
  const L = layout();
  const minY = L.heatY - 6;
  const maxY = L.cableY + L.cableH + 18;
  if (mx < L.plotX || mx > L.plotX + L.plotW) return;
  if (my < minY || my > maxY) return;
  const segF = (mx - L.plotX) / (L.plotW / N);
  const seg = Math.max(0, Math.min(N - 1, Math.floor(segF)));
  injectPulse(seg);
}

function handleKeys() {
  if (!inputRef) return;
  if (typeof inputRef.justPressed !== 'function') return;
  if (inputRef.justPressed('m') || inputRef.justPressed('M')) {
    myelinated = !myelinated;
  }
  if (inputRef.justPressed('r') || inputRef.justPressed('R')) {
    V.fill(0);
    I.fill(0);
    pulseTimer.fill(0);
    if (history) history.fill(0);
    histCount = 0; histHead = 0;
  }
  if (inputRef.justPressed(' ') || inputRef.justPressed('Spacebar')) {
    injectPulse(Math.floor(N / 2));
  }
}

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

function init({ canvas, ctx, width, height, input }) {
  W = width; H = height;
  inputRef = input;
  V = new Float64Array(N);
  I = new Float64Array(N);
  pulseTimer = new Float64Array(N);
  vScratch = new Float64Array(N);
  history = new Float32Array(N * HISTORY_FRAMES);
  histHead = 0; histCount = 0;
  modelTimeMs = 0;
  myelinated = false;

  // A small initial pulse so the very first frame isn't visually empty.
  injectPulse(Math.floor(N / 2));

  ctx.fillStyle = '#08090d';
  ctx.fillRect(0, 0, W, H);
}

function tick({ ctx, dt, width, height }) {
  if (width !== W || height !== H) {
    W = width; H = height;
  }

  handleKeys();
  handleClicks();

  // Advance model time. dt is in seconds.
  const modelDt = Math.min(0.05, dt) * SIM_MS_PER_SEC; // clamp to avoid huge jumps
  // Sub-divide into ~0.25 ms physics ticks for stability and smooth history.
  const sub = Math.max(1, Math.ceil(modelDt / 0.5));
  const stepMs = modelDt / sub;
  for (let s = 0; s < sub; s++) {
    stepCable(stepMs);
    modelTimeMs += stepMs;
  }
  pushHistory();

  const L = layout();
  drawBackground(ctx);
  drawHeatmap(ctx, L);
  drawVoltageLine(ctx, L);
  drawCable(ctx, L);
  drawHUD(ctx, L);
}

Comments (0)

Log in to comment.