14

Two-Cell FitzHugh-Nagumo Synchronization

drag Y for coupling · click to randomize

Two diffusively coupled FitzHugh–Nagumo neurons, each obeying and with . Each cell in isolation sits past the Hopf bifurcation at , so yields two independent limit-cycle oscillators with the same period but arbitrary phase offset. The coupling term acts like a gap junction: it pulls fast voltage variables together while leaving the slow recovery variables free, and below a critical strength the cells exhibit a steady drift in phase difference (where is the instantaneous angle in the phase plane). Above — somewhere in for these parameters, highlighted on the coupling bar — the system passes through a phase-locking transition and flattens to a constant near , i.e. the two cells fire in unison. Left panel: phase-plane trajectories overlaid on the cubic -nullcline and the linear -nullcline , whose intersection is the unstable focus driving oscillation. Middle panel: scrolling time series and — watch peaks align as you push up. Right panel: unwrapped phase difference , with a live SYNCHRONIZED/DRIFTING indicator that thresholds the recent drift rate. Integration uses classical RK4 at for steps per frame. Drag the cursor vertically to scrub across ; click to randomize the four-dimensional initial state and kick the pair out of whatever lock it had found.

idle
485 lines · vanilla
view source
// Two coupled FitzHugh-Nagumo oscillators.
//   v_i' = v_i - v_i^3/3 - w_i + I + g (v_j - v_i)
//   w_i' = tau (v_i + a - b w_i)
// Each cell oscillates spontaneously at the chosen (a, b, tau, I);
// coupling g (controlled by mouseY) drives the pair through a
// synchronization transition.

const A     = 0.7;
const B_     = 0.8;
const TAU   = 0.08;
const I_EXT = 0.5;

const DT     = 0.05;      // ms per RK4 step
const STEPS_PER_FRAME = 6;
const HIST_LEN = 900;     // samples kept for the scrolling time series

let W, H, cx, cy;
let layout = null;        // {phase:{x,y,w,h}, ts:{...}, phi:{...}, vertical}
let input  = null;
let g = 0.1;              // coupling, will be set by mouseY
let cells = null;         // [{v,w}, {v,w}]
let phaseTrail0 = null;   // ring buffer for cell-1 (v,w) trail
let phaseTrail1 = null;
let trailHead = 0;
let trailCount = 0;
const TRAIL_LEN = 600;

let vHist0, vHist1, phiHist;   // Float32Arrays
let histHead = 0;
let histCount = 0;

let phi0Prev = 0, phi1Prev = 0;
let phaseUnwrap0 = 0, phaseUnwrap1 = 0;
let lastDphi = 0;

let kickFlash = 0;             // frames since last click
let frameCount = 0;

// Stable, neuro-paper-ish palette.
const BG       = '#06080d';
const PANEL_BG = 'rgba(14, 18, 28, 0.55)';
const PANEL_BD = 'rgba(80, 110, 150, 0.22)';
const GRID_DIM = 'rgba(80, 110, 150, 0.10)';
const AXIS_DIM = 'rgba(140, 165, 200, 0.45)';
const TEXT_HI  = 'rgba(210, 225, 245, 0.92)';
const TEXT_MID = 'rgba(160, 180, 210, 0.78)';
const TEXT_LO  = 'rgba(120, 140, 170, 0.62)';

const COLOR_A  = '#ff7a5c';    // cell 1 — warm coral
const COLOR_B  = '#5cd1ff';    // cell 2 — cool cyan
const COLOR_P  = '#c79bff';    // phase difference — violet

// ---------- math ----------

// Returns [dv1, dw1, dv2, dw2] for the coupled system.
function deriv(v1, w1, v2, w2, gNow) {
  const cVal = v1 - (v1 * v1 * v1) / 3 - w1 + I_EXT + gNow * (v2 - v1);
  const cWal = TAU * (v1 + A - B_ * w1);
  const dVal = v2 - (v2 * v2 * v2) / 3 - w2 + I_EXT + gNow * (v1 - v2);
  const dWal = TAU * (v2 + A - B_ * w2);
  return [cVal, cWal, dVal, dWal];
}

function rk4(state, gNow, h) {
  const [v1, w1, v2, w2] = state;
  const k1 = deriv(v1, w1, v2, w2, gNow);
  const k2 = deriv(
    v1 + 0.5 * h * k1[0], w1 + 0.5 * h * k1[1],
    v2 + 0.5 * h * k1[2], w2 + 0.5 * h * k1[3],
    gNow
  );
  const k3 = deriv(
    v1 + 0.5 * h * k2[0], w1 + 0.5 * h * k2[1],
    v2 + 0.5 * h * k2[2], w2 + 0.5 * h * k2[3],
    gNow
  );
  const k4 = deriv(
    v1 + h * k3[0], w1 + h * k3[1],
    v2 + h * k3[2], w2 + h * k3[3],
    gNow
  );
  return [
    v1 + (h / 6) * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
    w1 + (h / 6) * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
    v2 + (h / 6) * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2]),
    w2 + (h / 6) * (k1[3] + 2 * k2[3] + 2 * k3[3] + k4[3]),
  ];
}

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

function computeLayout() {
  const PAD = Math.max(8, Math.min(W, H) * 0.012);
  const TITLE_BAND = 22;
  const FOOT_BAND  = 22;

  // Stack vertically on narrow / portrait viewports, side-by-side on wide ones.
  // Threshold tuned so phones get stacked; desktop and landscape tablets get 3-panel row.
  const vertical = (W / H) < 1.15 || W < 760;

  if (vertical) {
    const usableH = H - TITLE_BAND - FOOT_BAND - 4 * PAD;
    const panelW = W - 2 * PAD;
    // Give phase panel a touch more room (it's square-ish).
    const phaseH = Math.min(panelW, usableH * 0.45);
    const remain = usableH - phaseH;
    const tsH    = remain * 0.55;
    const phiH   = remain * 0.45;
    return {
      vertical: true,
      phase: { x: PAD, y: TITLE_BAND + PAD, w: panelW, h: phaseH },
      ts:    { x: PAD, y: TITLE_BAND + 2 * PAD + phaseH, w: panelW, h: tsH },
      phi:   { x: PAD, y: TITLE_BAND + 3 * PAD + phaseH + tsH, w: panelW, h: phiH },
    };
  } else {
    const usableW = W - 4 * PAD;
    const usableH = H - TITLE_BAND - FOOT_BAND - 2 * PAD;
    // Phase: square-ish; time series & phi share the rest equally.
    const phaseW = Math.min(usableH, usableW * 0.36);
    const restW  = usableW - phaseW;
    const tsW    = restW * 0.55;
    const phiW   = restW * 0.45;
    return {
      vertical: false,
      phase: { x: PAD,                                y: TITLE_BAND + PAD, w: phaseW, h: usableH },
      ts:    { x: 2 * PAD + phaseW,                   y: TITLE_BAND + PAD, w: tsW,    h: usableH },
      phi:   { x: 3 * PAD + phaseW + tsW,             y: TITLE_BAND + PAD, w: phiW,   h: usableH },
    };
  }
}

// ---------- state helpers ----------

function randomizeICs() {
  // Pick two distinctly different start states so phase difference jumps.
  cells = {
    v1: (Math.random() - 0.5) * 4,
    w1: (Math.random() - 0.5) * 2,
    v2: (Math.random() - 0.5) * 4,
    w2: (Math.random() - 0.5) * 2,
  };
  // Reset trails so we don't see ghost orbits from prior coupling regime.
  phaseTrail0 = new Float32Array(TRAIL_LEN * 2);
  phaseTrail1 = new Float32Array(TRAIL_LEN * 2);
  trailHead = 0;
  trailCount = 0;
  vHist0 = new Float32Array(HIST_LEN);
  vHist1 = new Float32Array(HIST_LEN);
  phiHist = new Float32Array(HIST_LEN);
  histHead = 0;
  histCount = 0;
  phaseUnwrap0 = 0;
  phaseUnwrap1 = 0;
  phi0Prev = Math.atan2(cells.w1, cells.v1);
  phi1Prev = Math.atan2(cells.w2, cells.v2);
  kickFlash = 18;
}

function pushTrail(buf, head, x, y) {
  buf[head * 2]     = x;
  buf[head * 2 + 1] = y;
}

function pushHist(v1, v2, dphi) {
  vHist0[histHead]  = v1;
  vHist1[histHead]  = v2;
  phiHist[histHead] = dphi;
  histHead = (histHead + 1) % HIST_LEN;
  if (histCount < HIST_LEN) histCount++;
}

// Unwrapped instantaneous phase from (v, w) via atan2.
function updatePhases() {
  const p0 = Math.atan2(cells.w1 - 0.5, cells.v1);   // shift origin near limit-cycle center
  const p1 = Math.atan2(cells.w2 - 0.5, cells.v2);
  let dp0 = p0 - phi0Prev;
  let dp1 = p1 - phi1Prev;
  // Unwrap: keep delta in (-pi, pi).
  if (dp0 >  Math.PI) dp0 -= 2 * Math.PI;
  if (dp0 < -Math.PI) dp0 += 2 * Math.PI;
  if (dp1 >  Math.PI) dp1 -= 2 * Math.PI;
  if (dp1 < -Math.PI) dp1 += 2 * Math.PI;
  phaseUnwrap0 += dp0;
  phaseUnwrap1 += dp1;
  phi0Prev = p0;
  phi1Prev = p1;
  lastDphi = phaseUnwrap1 - phaseUnwrap0;
}

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

function init({ canvas, ctx, width, height, input: inp }) {
  W = width; H = height;
  cx = W * 0.5; cy = H * 0.5;
  input = inp;
  layout = computeLayout();

  randomizeICs();
  // Re-randomize without the click flash on initial seed.
  kickFlash = 0;

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

function readCoupling() {
  if (!input || input.mouseY == null) return g;
  // Map mouseY across full canvas to g in [0, 0.5].
  const t = Math.max(0, Math.min(1, 1 - input.mouseY / H));
  return t * 0.5;
}

function handleInput() {
  if (!input) return;
  const clicks = (typeof input.consumeClicks === 'function') ? input.consumeClicks() : 0;
  if (clicks > 0) randomizeICs();
}

function tick({ ctx, dt, width, height }) {
  if (width !== W || height !== H) {
    W = width; H = height;
    cx = W * 0.5; cy = H * 0.5;
    layout = computeLayout();
  }

  handleInput();
  g = readCoupling();

  // Integrate.
  for (let s = 0; s < STEPS_PER_FRAME; s++) {
    const next = rk4([cells.v1, cells.w1, cells.v2, cells.w2], g, DT);
    cells.v1 = next[0]; cells.w1 = next[1];
    cells.v2 = next[2]; cells.w2 = next[3];
    updatePhases();
  }

  // Push trails / history once per frame.
  pushTrail(phaseTrail0, trailHead, cells.v1, cells.w1);
  pushTrail(phaseTrail1, trailHead, cells.v2, cells.w2);
  trailHead = (trailHead + 1) % TRAIL_LEN;
  if (trailCount < TRAIL_LEN) trailCount++;
  pushHist(cells.v1, cells.v2, lastDphi);

  if (kickFlash > 0) kickFlash--;

  // ---- Draw ----
  ctx.fillStyle = BG;
  ctx.fillRect(0, 0, W, H);

  drawTitle(ctx);
  drawPanelFrame(ctx, layout.phase, 'phase plane  (v, w)');
  drawPhasePanel(ctx, layout.phase);
  drawPanelFrame(ctx, layout.ts,    'voltage  v(t)');
  drawTimeSeriesPanel(ctx, layout.ts);
  drawPanelFrame(ctx, layout.phi,   'phase diff  Δθ = θ₂ − θ₁');
  drawPhiPanel(ctx, layout.phi);

  drawFooter(ctx);

  frameCount++;
}

// ---------- drawing primitives ----------

function drawPanelFrame(ctx, r, title) {
  ctx.fillStyle = PANEL_BG;
  ctx.fillRect(r.x, r.y, r.w, r.h);
  ctx.strokeStyle = PANEL_BD;
  ctx.lineWidth = 1;
  ctx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1);

  ctx.fillStyle = TEXT_MID;
  ctx.font = '10px monospace';
  ctx.textAlign = 'left';
  ctx.fillText(title, r.x + 8, r.y - 4);
}

function clipRect(ctx, r) {
  ctx.save();
  ctx.beginPath();
  ctx.rect(r.x + 1, r.y + 1, r.w - 2, r.h - 2);
  ctx.clip();
}

// ---------- phase plane ----------

function drawPhasePanel(ctx, r) {
  clipRect(ctx, r);

  // World limits roughly tracking the FHN limit-cycle envelope.
  const vMin = -2.6, vMax = 2.6;
  const wMin = -1.4, wMax = 2.0;

  const wToPx = (v) => r.x + ((v - vMin) / (vMax - vMin)) * r.w;
  const hToPx = (w) => r.y + r.h - ((w - wMin) / (wMax - wMin)) * r.h;

  // Grid
  ctx.strokeStyle = GRID_DIM;
  ctx.lineWidth = 1;
  ctx.beginPath();
  for (let v = -2; v <= 2; v++) {
    const x = wToPx(v);
    ctx.moveTo(x, r.y); ctx.lineTo(x, r.y + r.h);
  }
  for (let w = -1; w <= 2; w++) {
    const y = hToPx(w);
    ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y);
  }
  ctx.stroke();

  // Axes
  ctx.strokeStyle = AXIS_DIM;
  ctx.beginPath();
  const y0 = hToPx(0);
  const x0 = wToPx(0);
  ctx.moveTo(r.x, y0); ctx.lineTo(r.x + r.w, y0);
  ctx.moveTo(x0, r.y); ctx.lineTo(x0, r.y + r.h);
  ctx.stroke();

  // Nullclines
  // v-nullcline (uncoupled approximation): w = v - v^3/3 + I
  // w-nullcline:                            w = (v + a) / b
  ctx.lineWidth = 1.2;
  ctx.strokeStyle = 'rgba(255, 220, 120, 0.35)';
  ctx.beginPath();
  let first = true;
  for (let i = 0; i <= 160; i++) {
    const v = vMin + (i / 160) * (vMax - vMin);
    const w = v - (v * v * v) / 3 + I_EXT;
    const px = wToPx(v), py = hToPx(w);
    if (py < r.y || py > r.y + r.h) { first = true; continue; }
    if (first) { ctx.moveTo(px, py); first = false; }
    else ctx.lineTo(px, py);
  }
  ctx.stroke();

  ctx.strokeStyle = 'rgba(180, 220, 255, 0.30)';
  ctx.beginPath();
  first = true;
  for (let i = 0; i <= 80; i++) {
    const v = vMin + (i / 80) * (vMax - vMin);
    const w = (v + A) / B_;
    const px = wToPx(v), py = hToPx(w);
    if (py < r.y || py > r.y + r.h) { first = true; continue; }
    if (first) { ctx.moveTo(px, py); first = false; }
    else ctx.lineTo(px, py);
  }
  ctx.stroke();

  // Trails
  drawPhaseTrail(ctx, phaseTrail0, COLOR_A, wToPx, hToPx, r);
  drawPhaseTrail(ctx, phaseTrail1, COLOR_B, wToPx, hToPx, r);

  // Current dots
  ctx.globalCompositeOperation = 'lighter';
  drawDot(ctx, wToPx(cells.v1), hToPx(cells.w1), COLOR_A);
  drawDot(ctx, wToPx(cells.v2), hToPx(cells.w2), COLOR_B);
  ctx.globalCompositeOperation = 'source-over';

  // Axis labels
  ctx.fillStyle = TEXT_LO;
  ctx.font = '9px monospace';
  ctx.textAlign = 'left';
  ctx.fillText('v', r.x + r.w - 12, hToPx(0) - 3);
  ctx.fillText('w', wToPx(0) + 3,   r.y + 11);

  // Legend
  ctx.fillStyle = TEXT_LO;
  ctx.font = '9px monospace';
  ctx.fillText('v-nullcline', r.x + 6, r.y + r.h - 18);
  ctx.fillStyle = 'rgba(255, 220, 120, 0.70)';
  ctx.fillRect(r.x + 64, r.y + r.h - 22, 10, 2);
  ctx.fillStyle = TEXT_LO;
  ctx.fillText('w-nullcline', r.x + 6, r.y + r.h - 6);
  ctx.fillStyle = 'rgba(180, 220, 255, 0.70)';
  ctx.fillRect(r.x + 64, r.y + r.h - 10, 10, 2);

  ctx.restore();
}

function drawPhaseTrail(ctx, buf, color, wToPx, hToPx, r) {
  if (trailCount < 2) return;
  const start = (trailHead - trailCount + TRAIL_LEN) % TRAIL_LEN;
  ctx.globalCompositeOperation = 'lighter';
  ctx.lineWidth = 1.1;
  let prevPx = wToPx(buf[start * 2]);
  let prevPy = hToPx(buf[start * 2 + 1]);
  for (let i = 1; i < trailCount; i++) {
    const idx = (start + i) % TRAIL_LEN;
    const px = wToPx(buf[idx * 2]);
    const py = hToPx(buf[idx * 2 + 1]);
    // alpha grows toward the head (newest segment brightest).
    const t = i / trailCount;
    const alpha = (0.04 + 0.55 * t);
    ctx.strokeStyle = withAlpha(color, alpha);
    ctx.beginPath();
    ctx.moveTo(prevPx, prevPy);
    ctx.lineTo(px, py);
    ctx.stroke();
    prevPx = px; prevPy = py;
  }
  ctx.globalCompositeOperation = 'source-over';
}

function drawDot(ctx, px, py, color) {
  const grad = ctx.createRadialGradient(px, py, 0, px, py, 7);
  grad.addColorStop(0, withAlpha(color, 0.95));
  grad.addColorStop(1, withAlpha(color, 0));
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.arc(px, py, 7, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = withAlpha(color, 1);
  ctx.beginPath();
  ctx.arc(px, py, 2.4, 0, Math.PI * 2);
  ctx.fill();
}

// ---------- time series ----------

function drawTimeSeriesPanel(ctx, r) {
  clipRect(ctx, r);

  const vMin = -2.4, vMax = 2.4;
  const wToY = (v) => r.y + r.h - ((v - vMin) / (vMax - vMin)) * r.h;

  // Mid-line
  ctx.strokeStyle = AXIS_DIM;
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(r.x, wToY(0));
  ctx.lineTo(r.x + r.w, wToY(0));
  ctx.stroke();

  // Gridlines
  ctx.strokeStyle = GRID_DIM;
  ctx.beginPath();
  for (let v = -2; v <= 2; v++) {
    if (v === 0) continue;
    const y = wToY(v);
    ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y);
  }
  ctx.stroke();

  if (histCount < 2) { ctx.restore(); return; }

  // Two traces.
  drawTrace(ctx, vHist0, r, wToY, COLOR_A);
  drawTrace(ctx, vHist1, r, wToY, COLOR_B);

  // Tick labels
  ctx.fillStyle = TEXT_LO;
  ctx.font = '9px monospace';
  ctx.textAlign = 'left';
  ctx.fillText(' 2', r.x + 4, wToY(2) + 9);
  ctx.fillText(' 0', r.x + 4, wToY(0) - 2);
  ctx.fillText('-2', r.x + 4, wToY(-2) - 2);

  // Legend bubbles
  ctx.fillStyle = COLOR_A;
  ctx.beginPath(); ctx.arc(r.x + r.w - 70, r.y + 12, 3, 0, Math.PI * 2); ctx.fill();
  ctx.fillStyle = TEXT_MID;
  ctx.fillText('cell 1', r.x + r.w - 62, r.y + 15);
  ctx.fillStyle = COLOR_B;
  ctx.beginPath(); ctx.arc(r.x + r.w - 36, r.y + 12, 3, 0, Math.PI * 2); ctx.fill();
  ctx.fillStyle = TEXT_MID;
  ctx.fillText('cell 2', r.x + r.w - 28, r.y + 15);

  ctx.restore();
}

function drawTrace(ctx, buf, r, wToY, color) {
  const start = (histHead - histCount + HIST_LEN) % HIST_LEN;
  const stepX = r.w / (HIST_LEN - 1);
  ctx.lineWidth = 1.4;
  ctx.strokeStyle = color;
  ctx.beginPath();
  for (let i = 0; i < histCount; i++) {
    const idx = (start + i) % HIST_LEN;
    const px = r.x + i * stepX;
    const py = wToY(buf[idx]);
    if (i === 0) ctx.moveTo(px, py);
    else         ctx.lineTo(px, py);
  }
  ctx.stroke();
}

// ---------- phase difference ----------

function drawPhiPanel(ctx, r) {
  clipRect(ctx, r);

  if (histCount < 2) { ctx.restore(); return; }

  // Auto-scale band: keep most recent ~half of buffer in view, centered on its mean.
  const start = (histHead - histCount + HIST_LEN) % HIST_LEN;
  // Find min/max of phi over the buffer.
  let pmin = Infinity, pmax = -Infinity;
  for (let i = 0; i < histCount; i++) {
    const idx = (start + i) % HIST_LEN;
    const v = phiHist[idx];
    if (v < pmin) pmin = v;
    if (v > pmax) pmax = v;
  }
  const pad = Math.max(0.4, (pmax - pmin) * 0.15);
  const lo = pmin - pad;
  const hi = pmax + pad;
  const wToY = (p) => r.y + r.h - ((p - lo) / (hi - lo)) * r.h;

  // Reference grid: lines at integer multiples of 2π if visible.
  ctx.strokeStyle = GRID_DIM;
  ctx.lineWidth = 1;
  ctx.beginPath();
  const kMin = Math.ceil(lo / (2 * Math.PI));
  const kMax = Math.floor(hi / (2 * Math.PI));
  for (let k = kMin; k <= kMax; k++) {
    const y = wToY(k * 2 * Math.PI);
    ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y);
  }
  // Zero line
  if (lo <= 0 && 0 <= hi) {
    ctx.moveTo(r.x, wToY(0)); ctx.lineTo(r.x + r.w, wToY(0));
  }
  ctx.stroke();

  // Trace
  const stepX = r.w / (HIST_LEN - 1);
  ctx.lineWidth = 1.5;
  ctx.strokeStyle = COLOR_P;
  ctx.beginPath();
  for (let i = 0; i < histCount; i++) {
    const idx = (start + i) % HIST_LEN;
    const px = r.x + i * stepX;
    const py = wToY(phiHist[idx]);
    if (i === 0) ctx.moveTo(px, py);
    else         ctx.lineTo(px, py);
  }
  ctx.stroke();

  // Current value bubble
  const cur = phiHist[(histHead - 1 + HIST_LEN) % HIST_LEN];
  const cpx = r.x + r.w - 2;
  const cpy = wToY(cur);
  ctx.fillStyle = withAlpha(COLOR_P, 0.95);
  ctx.beginPath();
  ctx.arc(cpx, cpy, 3, 0, Math.PI * 2);
  ctx.fill();

  // Sync indicator: drift rate over the last ~30% of buffer.
  const tailN = Math.max(2, Math.floor(histCount * 0.3));
  const tailStart = (histHead - tailN + HIST_LEN) % HIST_LEN;
  const tailEnd   = (histHead - 1 + HIST_LEN) % HIST_LEN;
  const driftRaw  = phiHist[tailEnd] - phiHist[tailStart];
  const drift     = Math.abs(driftRaw) / tailN;
  const isSynced  = drift < 0.012;

  ctx.fillStyle = TEXT_LO;
  ctx.font = '9px monospace';
  ctx.textAlign = 'left';
  ctx.fillText(`Δθ = ${cur.toFixed(2)}`, r.x + 8, r.y + 12);

  ctx.textAlign = 'right';
  ctx.fillStyle = isSynced
    ? 'rgba(140, 230, 170, 0.95)'
    : 'rgba(240, 190, 120, 0.92)';
  ctx.fillText(isSynced ? 'SYNCHRONIZED' : 'DRIFTING', r.x + r.w - 6, r.y + 12);

  ctx.restore();
}

// ---------- title + footer ----------

function drawTitle(ctx) {
  ctx.fillStyle = TEXT_HI;
  ctx.font = 'bold 12px monospace';
  ctx.textAlign = 'left';
  ctx.fillText('Two-cell FitzHugh–Nagumo  ·  coupling sweep', 10, 14);

  // Coupling bar on the right of the title row.
  const barW = Math.min(220, Math.max(140, W * 0.18));
  const barH = 5;
  const barX = W - barW - 10;
  const barY = 8;
  ctx.fillStyle = 'rgba(80, 110, 150, 0.20)';
  ctx.fillRect(barX, barY, barW, barH);
  const t = g / 0.5;
  // Critical g region highlight (around g_c ~ 0.12-0.18 for these parameters)
  const gcLo = (0.12 / 0.5) * barW;
  const gcHi = (0.20 / 0.5) * barW;
  ctx.fillStyle = 'rgba(199, 155, 255, 0.18)';
  ctx.fillRect(barX + gcLo, barY, gcHi - gcLo, barH);
  ctx.fillStyle = COLOR_P;
  ctx.fillRect(barX, barY, barW * t, barH);

  // Labels around bar.
  ctx.fillStyle = TEXT_LO;
  ctx.font = '9px monospace';
  ctx.textAlign = 'right';
  ctx.fillText(`g = ${g.toFixed(3)}`, barX - 8, barY + 6);
  ctx.textAlign = 'left';
  ctx.fillText('0', barX, barY + 16);
  ctx.textAlign = 'right';
  ctx.fillText('0.5', barX + barW, barY + 16);
  ctx.textAlign = 'center';
  ctx.fillText('g_c', barX + (gcLo + gcHi) / 2, barY + 16);
}

function drawFooter(ctx) {
  ctx.fillStyle = (kickFlash > 0) ? 'rgba(255, 230, 180, 0.95)' : TEXT_LO;
  ctx.font = '10px monospace';
  ctx.textAlign = 'center';
  const msg = (kickFlash > 0)
    ? 'kicked: new initial conditions'
    : 'drag Y to change coupling  ·  click to randomize';
  ctx.fillText(msg, W / 2, H - 8);
}

// ---------- utility ----------

function withAlpha(hex, a) {
  // hex is "#rrggbb"
  const r = parseInt(hex.slice(1, 3), 16);
  const g_ = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return `rgba(${r}, ${g_}, ${b}, ${a.toFixed(3)})`;
}

Comments (0)

Log in to comment.