2
Cable Equation: Dendrite Conduction
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.