14
Two-Cell FitzHugh-Nagumo Synchronization
drag Y for coupling · click to randomize
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.