<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Grid-Cell-Notes | Shaoyang Cui</title><link>https://spidermonk7.github.io/grid-cell-notes/</link><atom:link href="https://spidermonk7.github.io/grid-cell-notes/index.xml" rel="self" type="application/rss+xml"/><description>Grid-Cell-Notes</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Thu, 14 May 2026 00:00:00 +0000</lastBuildDate><image><url>https://spidermonk7.github.io/media/icon_hu7729264130191091259.png</url><title>Grid-Cell-Notes</title><link>https://spidermonk7.github.io/grid-cell-notes/</link></image><item><title>Grid Cell Demo 03: From Grid Cells to fMRI Hexadirectional Signal</title><link>https://spidermonk7.github.io/grid-cell-notes/demo3/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://spidermonk7.github.io/grid-cell-notes/demo3/</guid><description>&lt;p>This demo now focuses only on voxel-level fMRI outputs and decoding.&lt;/p>
&lt;p>Core intuition:&lt;/p>
&lt;ol>
&lt;li>Within one voxel, many cells with different spatial phases can reduce position-locked map contrast after averaging.&lt;/li>
&lt;li>But if local cells share a similar grid orientation $\phi$, the six-fold directional term can survive at population level.&lt;/li>
&lt;li>After HRF convolution, we can still decode hexadirectional structure from BOLD.&lt;/li>
&lt;/ol>
&lt;p>Decoding protocol in this page:&lt;/p>
&lt;ol>
&lt;li>Use first half of time points as training data.&lt;/li>
&lt;li>Fit $\cos(6\theta)$ and $\sin(6\theta)$ GLM terms to estimate population $\hat\phi$.&lt;/li>
&lt;li>Use second half as test data.&lt;/li>
&lt;li>Build test regressor $\cos(6(\theta-\hat\phi))$ and estimate test $\beta_{\text{hex}}$.&lt;/li>
&lt;li>Compare recovered $\hat\phi$ against ground-truth $\phi$ (modulo 60 degrees).&lt;/li>
&lt;li>Visualize aligned vs misaligned bins and directional preference on a 360-degree polar plot.&lt;/li>
&lt;/ol>
&lt;p>You can customize:&lt;/p>
&lt;ul>
&lt;li>voxel cell count&lt;/li>
&lt;li>initial phase distribution&lt;/li>
&lt;li>HRF kernel&lt;/li>
&lt;li>signal/noise settings&lt;/li>
&lt;li>trajectory length&lt;/li>
&lt;/ul>
&lt;p>and inspect how recovery quality changes.&lt;/p>
&lt;div class="gridcell-demo">
&lt;div class="gridcell-demo-intro">
&lt;p class="gridcell-demo-stage-note">
Voxel-level fMRI simulation + decoding. We generate synthetic Right EC BOLD with a forward HRF, then decode hexadirectional structure with a GLM that uses a potentially different decode HRF on regressors.
&lt;/p>
&lt;/div>
&lt;div class="gridcell-demo-controls">
&lt;label>
&lt;span>cells per voxel&lt;/span>
&lt;input type="range" min="10" max="700" value="220" step="10" data-role="n-cells">
&lt;output data-role="n-cells-value">220&lt;/output>
&lt;/label>
&lt;label>
&lt;span>phase distribution&lt;/span>
&lt;select data-role="phase-dist">
&lt;option value="uniform" selected>uniform&lt;/option>
&lt;option value="clustered">clustered&lt;/option>
&lt;option value="bimodal">bimodal&lt;/option>
&lt;/select>
&lt;/label>
&lt;label>
&lt;span>grid orientation phi (ground truth)&lt;/span>
&lt;input type="range" min="0" max="59" value="18" step="1" data-role="phi-deg">
&lt;output data-role="phi-deg-value">18 deg&lt;/output>
&lt;/label>
&lt;label>
&lt;span>noise level&lt;/span>
&lt;input type="range" min="0" max="1.0" value="0.18" step="0.01" data-role="noise-amp">
&lt;output data-role="noise-amp-value">0.18&lt;/output>
&lt;/label>
&lt;label>
&lt;span>trajectory samples&lt;/span>
&lt;input type="range" min="300" max="2000" value="800" step="20" data-role="n-time">
&lt;output data-role="n-time-value">800&lt;/output>
&lt;/label>
&lt;label>
&lt;span>runs (CV)&lt;/span>
&lt;input type="range" min="2" max="8" value="4" step="1" data-role="n-runs">
&lt;output data-role="n-runs-value">4&lt;/output>
&lt;/label>
&lt;label>
&lt;span>forward HRF (generation)&lt;/span>
&lt;textarea rows="3" data-role="hrf-forward-text">0,0,0.02,0.08,0.22,0.38,0.52,0.59,0.57,0.49,0.38,0.27,0.18,0.11,0.06,0.03,0.01,0,-0.01,-0.02,-0.02,-0.01,0&lt;/textarea>
&lt;/label>
&lt;label>
&lt;span>decode HRF (GLM design)&lt;/span>
&lt;textarea rows="3" data-role="hrf-decode-text">0,0,0.01,0.05,0.15,0.29,0.43,0.53,0.55,0.50,0.40,0.30,0.21,0.14,0.09,0.05,0.02,0.01,0,-0.01,-0.01,0,0&lt;/textarea>
&lt;/label>
&lt;div class="gridcell-demo-actions">
&lt;button type="button" data-role="reset-default">Reset defaults&lt;/button>
&lt;button type="button" data-role="rerun">Rerun simulation&lt;/button>
&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-stage gridcell-demo-stage-split">
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Forward HRF Kernel&lt;/h4>
&lt;canvas width="520" height="180" data-role="hrf-forward-plot">&lt;/canvas>
&lt;/div>
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Decode HRF Kernel&lt;/h4>
&lt;canvas width="520" height="180" data-role="hrf-decode-plot">&lt;/canvas>
&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-metrics">
&lt;div class="gridcell-demo-metric">&lt;span>phi true / phi estimated&lt;/span>&lt;strong data-role="phi-pair">0.0 deg / 0.0 deg&lt;/strong>&lt;/div>
&lt;div class="gridcell-demo-metric">&lt;span>phi error (mod 60)&lt;/span>&lt;strong data-role="phi-error">0.00 deg&lt;/strong>&lt;/div>
&lt;div class="gridcell-demo-metric">&lt;span>beta_hex train&lt;/span>&lt;strong data-role="beta-train">0.000&lt;/strong>&lt;/div>
&lt;div class="gridcell-demo-metric">&lt;span>beta_hex test&lt;/span>&lt;strong data-role="beta-test">0.000&lt;/strong>&lt;/div>
&lt;div class="gridcell-demo-metric">&lt;span>aligned - misaligned (test)&lt;/span>&lt;strong data-role="align-gap">0.000&lt;/strong>&lt;/div>
&lt;div class="gridcell-demo-metric">&lt;span>cv setup&lt;/span>&lt;strong data-role="cv-info">leave-one-run-out&lt;/strong>&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-stage gridcell-demo-stage-split">
&lt;div class="gridcell-demo-visual-block">
&lt;h4>BOLD and Decoded Predictor&lt;/h4>
&lt;canvas width="520" height="260" data-role="time-plot">&lt;/canvas>
&lt;/div>
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Aligned vs Misaligned Bins (Test)&lt;/h4>
&lt;canvas width="520" height="260" data-role="bin-plot">&lt;/canvas>
&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-stage gridcell-demo-stage-split">
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Cell Orientation: Ground Truth vs Decoded&lt;/h4>
&lt;canvas width="420" height="420" data-role="ori-compare-plot">&lt;/canvas>
&lt;/div>
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Folded 60 deg Profile&lt;/h4>
&lt;canvas width="420" height="420" data-role="folded-plot">&lt;/canvas>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;script>
(() => {
const root = document.currentScript.previousElementSibling;
if (!root || !root.classList.contains('gridcell-demo')) return;
const controls = {
nCells: root.querySelector('[data-role="n-cells"]'),
phaseDist: root.querySelector('[data-role="phase-dist"]'),
phiDeg: root.querySelector('[data-role="phi-deg"]'),
noiseAmp: root.querySelector('[data-role="noise-amp"]'),
nTime: root.querySelector('[data-role="n-time"]'),
nRuns: root.querySelector('[data-role="n-runs"]'),
hrfForwardText: root.querySelector('[data-role="hrf-forward-text"]'),
hrfDecodeText: root.querySelector('[data-role="hrf-decode-text"]')
};
const outputs = {
nCells: root.querySelector('[data-role="n-cells-value"]'),
phiDeg: root.querySelector('[data-role="phi-deg-value"]'),
noiseAmp: root.querySelector('[data-role="noise-amp-value"]'),
nTime: root.querySelector('[data-role="n-time-value"]'),
nRuns: root.querySelector('[data-role="n-runs-value"]')
};
const metrics = {
phiPair: root.querySelector('[data-role="phi-pair"]'),
phiError: root.querySelector('[data-role="phi-error"]'),
betaTrain: root.querySelector('[data-role="beta-train"]'),
betaTest: root.querySelector('[data-role="beta-test"]'),
alignGap: root.querySelector('[data-role="align-gap"]'),
cvInfo: root.querySelector('[data-role="cv-info"]')
};
const ctxTime = root.querySelector('[data-role="time-plot"]').getContext('2d');
const ctxBin = root.querySelector('[data-role="bin-plot"]').getContext('2d');
const ctxOriCompare = root.querySelector('[data-role="ori-compare-plot"]').getContext('2d');
const ctxFolded = root.querySelector('[data-role="folded-plot"]').getContext('2d');
const ctxHrfForward = root.querySelector('[data-role="hrf-forward-plot"]').getContext('2d');
const ctxHrfDecode = root.querySelector('[data-role="hrf-decode-plot"]').getContext('2d');
const defaults = {
nCells: 220,
phaseDist: 'uniform',
phiDeg: 18,
noiseAmp: 0.18,
nTime: 800,
nRuns: 4,
hrfForwardText: '0,0,0.02,0.08,0.22,0.38,0.52,0.59,0.57,0.49,0.38,0.27,0.18,0.11,0.06,0.03,0.01,0,-0.01,-0.02,-0.02,-0.01,0',
hrfDecodeText: '0,0,0.01,0.05,0.15,0.29,0.43,0.53,0.55,0.50,0.40,0.30,0.21,0.14,0.09,0.05,0.02,0.01,0,-0.01,-0.01,0,0'
};
const FIXED_OMEGA0 = 1.6;
const FIXED_BETA_VEL = 22.0;
function clamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }
function mean(arr) { return arr.reduce((a, b) => a + b, 0) / Math.max(arr.length, 1); }
function randn() {
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function deg(rad) { return rad * 180 / Math.PI; }
function rad(degVal) { return degVal * Math.PI / 180; }
function mod2pi(x) {
let y = x % (2 * Math.PI);
if (y &lt; 0) y += 2 * Math.PI;
return y;
}
function mod60ErrorDeg(a, b) {
let d = Math.abs(a - b) % 60;
return Math.min(d, 60 - d);
}
function parseKernel(text, fallbackText) {
const raw = text.split(',').map((x) => Number(x.trim())).filter((x) => Number.isFinite(x));
const vec = raw.length >= 3 ? raw : fallbackText.split(',').map(Number);
const norm = vec.reduce((s, v) => s + Math.abs(v), 0) || 1;
return vec.map((v) => v / norm);
}
function syncLabels() {
outputs.nCells.textContent = controls.nCells.value;
outputs.phiDeg.textContent = `${controls.phiDeg.value} deg`;
outputs.noiseAmp.textContent = Number(controls.noiseAmp.value).toFixed(2);
outputs.nTime.textContent = controls.nTime.value;
outputs.nRuns.textContent = controls.nRuns.value;
}
function drawKernel(ctx, kernel, color) {
const w = ctx.canvas.width, h = ctx.canvas.height;
const pad = 16;
ctx.clearRect(0, 0, w, h);
const minV = Math.min(...kernel), maxV = Math.max(...kernel);
const den = Math.max(maxV - minV, 1e-6);
ctx.strokeStyle = 'rgba(19,21,24,0.22)';
ctx.strokeRect(pad, pad, w - 2 * pad, h - 2 * pad);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i &lt; kernel.length; i += 1) {
const x = pad + (i / Math.max(kernel.length - 1, 1)) * (w - 2 * pad);
const y = h - pad - ((kernel[i] - minV) / den) * (h - 2 * pad);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
function samplePhase(dist) {
if (dist === 'clustered') {
return [clamp(0.5 + 0.09 * randn(), 0, 1), clamp(0.5 + 0.09 * randn(), 0, 1), clamp(0.5 + 0.09 * randn(), 0, 1)];
}
if (dist === 'bimodal') {
const c = Math.random() &lt; 0.5 ? 0.25 : 0.75;
return [clamp(c + 0.07 * randn(), 0, 1), clamp(c + 0.07 * randn(), 0, 1), clamp(c + 0.07 * randn(), 0, 1)];
}
return [Math.random(), Math.random(), Math.random()];
}
function gridFieldValue(x, y, phase) {
const angles = [0, Math.PI / 3, 2 * Math.PI / 3];
const k = 8.6;
let acc = 0;
for (let i = 0; i &lt; 3; i += 1) {
const proj = x * Math.cos(angles[i]) + y * Math.sin(angles[i]);
acc += Math.cos(k * proj + 2 * Math.PI * phase[i]);
}
return Math.max(0, (acc + 1.6) / 4.6);
}
function convolve(signal, kernel) {
const out = new Array(signal.length).fill(0);
for (let t = 0; t &lt; signal.length; t += 1) {
let v = 0;
for (let k = 0; k &lt; kernel.length; k += 1) {
const idx = t - k;
if (idx &lt; 0) break;
v += signal[idx] * kernel[k];
}
out[t] = v;
}
return out;
}
function solveLinearSystem(A, b) {
const n = A.length;
const M = A.map((row, i) => row.concat([b[i]]));
for (let c = 0; c &lt; n; c += 1) {
let piv = c;
for (let r = c + 1; r &lt; n; r += 1) if (Math.abs(M[r][c]) > Math.abs(M[piv][c])) piv = r;
if (Math.abs(M[piv][c]) &lt; 1e-10) continue;
if (piv !== c) {
const tmp = M[c];
M[c] = M[piv];
M[piv] = tmp;
}
const div = M[c][c];
for (let j = c; j &lt;= n; j += 1) M[c][j] /= div;
for (let r = 0; r &lt; n; r += 1) {
if (r === c) continue;
const f = M[r][c];
for (let j = c; j &lt;= n; j += 1) M[r][j] -= f * M[c][j];
}
}
return M.map((row) => row[n]);
}
function fitGLM(y, regs) {
const n = y.length;
const p = regs.length;
const dim = p + 1;
const XtX = Array.from({ length: dim }, () => new Array(dim).fill(0));
const Xty = new Array(dim).fill(0);
for (let i = 0; i &lt; n; i += 1) {
const row = [1];
for (let j = 0; j &lt; p; j += 1) row.push(regs[j][i]);
for (let a = 0; a &lt; dim; a += 1) {
Xty[a] += row[a] * y[i];
for (let b = 0; b &lt; dim; b += 1) XtX[a][b] += row[a] * row[b];
}
}
for (let d = 0; d &lt; dim; d += 1) XtX[d][d] += 1e-6;
const beta = solveLinearSystem(XtX, Xty);
return beta;
}
function drawTimePlot(y1, y2) {
const ctx = ctxTime;
const w = ctx.canvas.width, h = ctx.canvas.height, pad = 22;
ctx.clearRect(0, 0, w, h);
const all = y1.concat(y2);
const minV = Math.min(...all), maxV = Math.max(...all);
const den = Math.max(maxV - minV, 1e-6);
function plot(arr, color) {
ctx.strokeStyle = color;
ctx.lineWidth = 1.7;
ctx.beginPath();
for (let i = 0; i &lt; arr.length; i += 1) {
const x = pad + (i / Math.max(arr.length - 1, 1)) * (w - 2 * pad);
const y = h - pad - ((arr[i] - minV) / den) * (h - 2 * pad);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
ctx.strokeStyle = 'rgba(19,21,24,0.24)';
ctx.strokeRect(pad, pad, w - 2 * pad, h - 2 * pad);
plot(y1, '#2b4d83');
plot(y2, '#b26a2b');
}
function drawBinPlot(bins, alignedMask) {
const ctx = ctxBin;
const w = ctx.canvas.width, h = ctx.canvas.height, pad = 30;
ctx.clearRect(0, 0, w, h);
const minV = Math.min(...bins), maxV = Math.max(...bins);
const den = Math.max(maxV - minV, 1e-6);
const bw = (w - 2 * pad) / bins.length * 0.82;
bins.forEach((v, i) => {
const x = pad + i * (w - 2 * pad) / bins.length;
const y0 = h - pad;
const y = y0 - ((v - minV) / den) * (h - 2 * pad);
ctx.fillStyle = alignedMask[i] ? 'rgba(27,110,72,0.78)' : 'rgba(164,62,62,0.72)';
ctx.fillRect(x, y, bw, y0 - y);
});
ctx.strokeStyle = 'rgba(19,21,24,0.35)';
ctx.strokeRect(pad, pad, w - 2 * pad, h - 2 * pad);
}
function drawOrientationCompare(ctx, trueDeg, estDegList) {
const w = ctx.canvas.width, h = ctx.canvas.height;
const padL = 56, padR = 22, padT = 26, padB = 42;
const innerW = w - padL - padR;
const innerH = h - padT - padB;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(19,21,24,0.28)';
ctx.strokeRect(padL, padT, innerW, innerH);
[0, 15, 30, 45, 60].forEach((degTick) => {
const y = padT + innerH - (degTick / 60) * innerH;
ctx.strokeStyle = 'rgba(19,21,24,0.14)';
ctx.beginPath();
ctx.moveTo(padL, y);
ctx.lineTo(padL + innerW, y);
ctx.stroke();
ctx.fillStyle = 'rgba(19,21,24,0.72)';
ctx.font = '12px Instrument Sans, Segoe UI, sans-serif';
ctx.fillText(`${degTick}°`, 16, y + 4);
});
const yTrue = padT + innerH - (trueDeg / 60) * innerH;
ctx.strokeStyle = 'rgba(27,110,72,0.95)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(padL, yTrue);
ctx.lineTo(padL + innerW, yTrue);
ctx.stroke();
const stride = Math.max(1, Math.floor(estDegList.length / 180));
ctx.fillStyle = 'rgba(176,106,43,0.72)';
for (let i = 0; i &lt; estDegList.length; i += stride) {
const x = padL + (i / Math.max(estDegList.length - 1, 1)) * innerW;
const y = padT + innerH - (estDegList[i] / 60) * innerH;
ctx.beginPath();
ctx.arc(x, y, 2, 0, 2 * Math.PI);
ctx.fill();
}
}
function drawPolar(ctx, values, colorStroke) {
const w = ctx.canvas.width, h = ctx.canvas.height;
const cx = w / 2, cy = h / 2;
const rMax = Math.min(w, h) * 0.37;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(19,21,24,0.22)';
for (let i = 1; i &lt;= 4; i += 1) {
ctx.beginPath(); ctx.arc(cx, cy, rMax * i / 4, 0, 2 * Math.PI); ctx.stroke();
}
for (let d = 0; d &lt; 360; d += 30) {
const a = rad(d - 90);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + rMax * Math.cos(a), cy + rMax * Math.sin(a));
ctx.stroke();
}
const minV = Math.min(...values), maxV = Math.max(...values);
const den = Math.max(maxV - minV, 1e-6);
ctx.beginPath();
values.forEach((v, i) => {
const a = rad(i * (360 / values.length) - 90);
const rr = rMax * (0.18 + 0.82 * (v - minV) / den);
const x = cx + rr * Math.cos(a);
const y = cy + rr * Math.sin(a);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.closePath();
ctx.fillStyle = 'rgba(176,106,43,0.20)';
ctx.strokeStyle = colorStroke;
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
function run() {
syncLabels();
const nCells = Number(controls.nCells.value);
const phaseDist = controls.phaseDist.value;
const phiTrue = rad(Number(controls.phiDeg.value));
const phiTrueDeg = Number(controls.phiDeg.value);
const noiseAmp = Number(controls.noiseAmp.value);
const nTime = Number(controls.nTime.value);
const nRuns = Number(controls.nRuns.value);
const hrfForward = parseKernel(controls.hrfForwardText.value, defaults.hrfForwardText);
const hrfDecode = parseKernel(controls.hrfDecodeText.value, defaults.hrfDecodeText);
drawKernel(ctxHrfForward, hrfForward, '#2b4d83');
drawKernel(ctxHrfDecode, hrfDecode, '#b26a2b');
const phases = new Array(nCells).fill(0).map(() => samplePhase(phaseDist));
const x = new Array(nTime).fill(0);
const y = new Array(nTime).fill(0);
const theta = new Array(nTime).fill(0);
for (let t = 1; t &lt; nTime; t += 1) {
const dir = Math.random() * 2 * Math.PI;
const step = 0.055 + 0.02 * randn();
x[t] = clamp(x[t - 1] + step * Math.cos(dir), -1.35, 1.35);
y[t] = clamp(y[t - 1] + step * Math.sin(dir), -1.35, 1.35);
theta[t] = Math.atan2(y[t] - y[t - 1], x[t] - x[t - 1]);
}
const cos6Raw = new Array(nTime).fill(0);
const sin6Raw = new Array(nTime).fill(0);
const neural = new Array(nTime).fill(0);
const cellSignals = Array.from({ length: nCells }, () => new Array(nTime).fill(0));
const dirs = [phiTrue, phiTrue + Math.PI / 3, phiTrue + (2 * Math.PI) / 3];
const cellPhaseState = phases.map((p) => [2 * Math.PI * p[0], 2 * Math.PI * p[1], 2 * Math.PI * p[2]]);
for (let t = 0; t &lt; nTime; t += 1) {
cos6Raw[t] = Math.cos(6 * theta[t]);
sin6Raw[t] = Math.sin(6 * theta[t]);
}
for (let t = 1; t &lt; nTime; t += 1) {
const dx = x[t] - x[t - 1];
const dy = y[t] - y[t - 1];
let pop = 0;
for (let c = 0; c &lt; nCells; c += 1) {
for (let m = 0; m &lt; 3; m += 1) {
const ux = Math.cos(dirs[m]);
const uy = Math.sin(dirs[m]);
const proj = dx * ux + dy * uy;
cellPhaseState[c][m] += FIXED_OMEGA0 + FIXED_BETA_VEL * proj;
}
const raw = Math.cos(cellPhaseState[c][0]) + Math.cos(cellPhaseState[c][1]) + Math.cos(cellPhaseState[c][2]);
const firing = Math.max(0, (raw + 1.2) / 3.2);
cellSignals[c][t] = firing;
pop += firing;
}
neural[t] = pop / nCells + noiseAmp * randn();
}
const bold = convolve(neural, hrfForward);
const cos6Conv = convolve(cos6Raw, hrfDecode);
const sin6Conv = convolve(sin6Raw, hrfDecode);
const drift = new Array(nTime).fill(0).map((_, i) => i / Math.max(nTime - 1, 1));
const runLen = Math.floor(nTime / nRuns);
const usableLen = runLen * nRuns;
if (usableLen &lt; nRuns * 40) return;
let sumSin = 0;
let sumCos = 0;
const trainBetas = [];
const testBetas = [];
const testIdxAll = [];
for (let r = 0; r &lt; nRuns; r += 1) {
const s = r * runLen;
const e = s + runLen;
const trainIdx = [];
const testIdx = [];
for (let i = 0; i &lt; usableLen; i += 1) {
if (i >= s &amp;&amp; i &lt; e) testIdx.push(i); else trainIdx.push(i);
}
testIdxAll.push(...testIdx);
const yTrain = trainIdx.map((i) => bold[i]);
const cTrain = trainIdx.map((i) => cos6Conv[i]);
const sTrain = trainIdx.map((i) => sin6Conv[i]);
const bTrain = fitGLM(yTrain, [cTrain, sTrain]);
const bc = bTrain[1];
const bs = bTrain[2];
const phiFold = mod2pi(Math.atan2(bs, bc) / 6);
const betaTrainFold = Math.sqrt(bc * bc + bs * bs);
trainBetas.push(betaTrainFold);
sumSin += Math.sin(6 * phiFold);
sumCos += Math.cos(6 * phiFold);
}
const phiHat = mod2pi(Math.atan2(sumSin, sumCos) / 6);
const phiHatDeg = deg(phiHat);
for (let r = 0; r &lt; nRuns; r += 1) {
const s = r * runLen;
const e = s + runLen;
const testIdx = [];
for (let i = s; i &lt; e; i += 1) testIdx.push(i);
const yTest = testIdx.map((i) => bold[i]);
const hexRaw = testIdx.map((i) => Math.cos(6 * (theta[i] - phiHat)));
const hexConv = convolve(hexRaw, hrfDecode).slice(0, hexRaw.length);
const bTest = fitGLM(yTest, [hexConv]);
testBetas.push(bTest[1]);
}
const betaTrain = mean(trainBetas);
const betaTest = mean(testBetas);
const fullHexRaw = theta.map((th) => Math.cos(6 * (th - phiHat)));
const fullHexConv = convolve(fullHexRaw, hrfDecode);
const fullBeta = fitGLM(bold, [fullHexConv]);
const pred = fullHexConv.map((v) => fullBeta[0] + fullBeta[1] * v);
drawTimePlot(bold, pred);
const nBins = 12;
const binSum = new Array(nBins).fill(0);
const binCnt = new Array(nBins).fill(0);
const alignedMask = new Array(nBins).fill(false).map((_, i) => (i % 2 === 0));
for (let k = 0; k &lt; testIdxAll.length; k += 1) {
const i = testIdxAll[k];
const th = mod2pi(theta[i] - phiHat);
const b = Math.min(nBins - 1, Math.floor(th / (2 * Math.PI / nBins)));
binSum[b] += bold[i];
binCnt[b] += 1;
}
const bins = binSum.map((v, i) => v / Math.max(1, binCnt[i]));
drawBinPlot(bins, alignedMask);
const aligned = bins.filter((_, i) => alignedMask[i]);
const misaligned = bins.filter((_, i) => !alignedMask[i]);
const alignGap = mean(aligned) - mean(misaligned);
const estPhiDegList = [];
const cosReg = cos6Conv.slice(0, usableLen);
const sinReg = sin6Conv.slice(0, usableLen);
for (let c = 0; c &lt; nCells; c += 1) {
const yCellNeural = cellSignals[c].slice(0, usableLen);
const yCellBold = convolve(yCellNeural, hrfForward);
const bCell = fitGLM(yCellBold, [cosReg, sinReg]);
const phiCell = mod2pi(Math.atan2(bCell[2], bCell[1]) / 6);
let phiCellDeg = deg(phiCell) % 60;
if (phiCellDeg &lt; 0) phiCellDeg += 60;
estPhiDegList.push(phiCellDeg);
}
drawOrientationCompare(ctxOriCompare, phiTrueDeg, estPhiDegList);
const nFold = 30;
const fSum = new Array(nFold).fill(0);
const fCnt = new Array(nFold).fill(0);
for (let k = 0; k &lt; testIdxAll.length; k += 1) {
const i = testIdxAll[k];
const folded = mod2pi(theta[i] - phiHat) % (Math.PI / 3);
const b = Math.min(nFold - 1, Math.floor(folded / ((Math.PI / 3) / nFold)));
fSum[b] += bold[i];
fCnt[b] += 1;
}
drawPolar(ctxFolded, fSum.map((v, i) => v / Math.max(1, fCnt[i])), 'rgba(176,106,43,0.95)');
metrics.phiPair.textContent = `${phiTrueDeg.toFixed(1)} deg / ${phiHatDeg.toFixed(1)} deg`;
metrics.phiError.textContent = `${mod60ErrorDeg(phiHatDeg, phiTrueDeg).toFixed(2)} deg`;
metrics.betaTrain.textContent = betaTrain.toFixed(3);
metrics.betaTest.textContent = betaTest.toFixed(3);
metrics.alignGap.textContent = alignGap.toFixed(3);
metrics.cvInfo.textContent = `${nRuns} runs, leave-one-run-out`;
}
function bind() {
Object.values(controls).forEach((el) => {
const eventName = (el.tagName === 'SELECT' || el.tagName === 'TEXTAREA') ? 'change' : 'input';
el.addEventListener(eventName, run);
});
root.querySelector('[data-role="rerun"]').addEventListener('click', run);
root.querySelector('[data-role="reset-default"]').addEventListener('click', () => {
controls.nCells.value = defaults.nCells;
controls.phaseDist.value = defaults.phaseDist;
controls.phiDeg.value = defaults.phiDeg;
controls.noiseAmp.value = defaults.noiseAmp;
controls.nTime.value = defaults.nTime;
controls.nRuns.value = defaults.nRuns;
controls.hrfForwardText.value = defaults.hrfForwardText;
controls.hrfDecodeText.value = defaults.hrfDecodeText;
syncLabels();
run();
});
}
syncLabels();
bind();
run();
})();
&lt;/script></description></item><item><title>Grid Cell Notes 04: From fMRI to Grid-Cell-Like Coding</title><link>https://spidermonk7.github.io/grid-cell-notes/grid-cell-notes-fmri-gridlike-coding/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://spidermonk7.github.io/grid-cell-notes/grid-cell-notes-fmri-gridlike-coding/</guid><description>&lt;p>This note is about a practical question I kept encountering:&lt;/p>
&lt;p>How do we move from noisy fMRI in the right entorhinal cortex (Right EC) to a defensible claim of &lt;strong>grid-cell-like coding&lt;/strong>?&lt;/p>
&lt;p>The short answer is: we do not look for literal hexagonal &amp;ldquo;spots&amp;rdquo; in BOLD images. We test whether Right EC activity is modulated by movement direction with a &lt;strong>60-degree periodicity&lt;/strong>.&lt;/p>
&lt;p>If movement direction aligns with a latent grid orientation, BOLD is higher; if it is offset by 30 degrees, BOLD is lower. This is the classic &lt;strong>hexadirectional modulation&lt;/strong> logic.&lt;/p>
&lt;h2 id="1-start-from-trajectory-not-static-location">1) Start from trajectory, not static location&lt;/h2>
&lt;p>Suppose the participant moves in a virtual environment and we track position:&lt;/p>
$$ (x(t), y(t)) $$&lt;p>Instantaneous movement direction is&lt;/p>
$$ \theta(t)=\mathrm{atan2}(y(t+\Delta t)-y(t), x(t+\Delta t)-x(t)) $$&lt;p>In practice, low-speed or stationary periods are excluded. The directional model is about motion direction during navigation, not occupancy.&lt;/p>
&lt;h2 id="2-estimate-grid-orientation-in-right-ec">2) Estimate grid orientation in Right EC&lt;/h2>
&lt;p>The standard modeling idea is that many grid cells in a local EC population can share a common orientation (while differing in spatial phase). At voxel level, phase-specific structure can average out, but orientation-locked directional modulation can remain.&lt;/p>
&lt;p>For each time point, construct:&lt;/p>
$$ \cos(6\theta(t)),\ \sin(6\theta(t)) $$&lt;p>The factor 6 encodes six-fold rotational symmetry (60-degree periodicity).&lt;/p>
&lt;p>Fit a GLM on training data (for example, N-1 runs):&lt;/p>
$$ \mathrm{BOLD}_{EC}(t)=\beta_{\cos}\cos(6\theta(t))+\beta_{\sin}\sin(6\theta(t))+\mathrm{nuisance\ regressors} $$&lt;p>Then recover the putative grid orientation:&lt;/p>
$$ \phi=\frac{1}{6}\mathrm{atan2}(\beta_{\sin},\beta_{\cos}) $$&lt;p>Because of 60-degree periodicity, $\phi$ is defined modulo 60 degrees.&lt;/p>
&lt;h2 id="3-test-in-independent-data-critical">3) Test in independent data (critical)&lt;/h2>
&lt;p>The key methodological constraint is avoiding circularity:
do not estimate $\phi$ and test $\phi$ on the same data.&lt;/p>
&lt;p>Use cross-validation (for example leave-one-run-out): estimate $\phi$ on training runs, test on held-out run, rotate folds, then average.&lt;/p>
&lt;p>In test data, build:&lt;/p>
$$ \cos(6(\theta(t)-\phi)) $$&lt;p>Then fit:&lt;/p>
$$ \mathrm{BOLD}_{RightEC}(t)=\beta_{\mathrm{hex}}\cos(6(\theta(t)-\phi))+\mathrm{nuisance\ regressors} $$&lt;p>If $\beta_{\mathrm{hex}} > 0$, Right EC is stronger when $\theta$ aligns with
$\phi + k\cdot60^\circ$ than when it is shifted toward $\phi + 30^\circ + k\cdot60^\circ$.&lt;/p>
&lt;p>This is the aligned &amp;gt; misaligned signature.&lt;/p>
&lt;h2 id="4-intuitive-visualization">4) Intuitive visualization&lt;/h2>
&lt;p>A useful check is directional binning:&lt;/p>
&lt;ul>
&lt;li>aligned bins: $\phi \pm 15^\circ$ and every 60-degree repetition&lt;/li>
&lt;li>misaligned bins: $\phi+30^\circ \pm 15^\circ$ and every 60-degree repetition&lt;/li>
&lt;/ul>
&lt;p>Plot Right EC beta by bins. A consistent aligned &amp;gt; misaligned gap supports the model.&lt;/p>
&lt;h2 id="5-group-level-significance">5) Group-level significance&lt;/h2>
&lt;p>A common inference flow:&lt;/p>
&lt;ol>
&lt;li>Estimate one $\beta_{\mathrm{hex}}$ per subject.&lt;/li>
&lt;li>Run one-sample t-test or permutation test at group level.&lt;/li>
&lt;li>Restrict inference to Right EC ROI with proper correction (for example small-volume correction / FWE).&lt;/li>
&lt;li>Verify effect is significantly above zero.&lt;/li>
&lt;/ol>
&lt;p>More conservative pipelines also do voxel-wise inference in EC with permutation + TFCE + FWE correction.&lt;/p>
&lt;h2 id="6-necessary-control-analyses">6) Necessary control analyses&lt;/h2>
&lt;p>A 6-fold effect alone is not enough. I would treat these controls as mandatory:&lt;/p>
&lt;ol>
&lt;li>Symmetry controls: test 4-, 5-, 7-, 8-fold models. The effect should be specific to 6-fold.&lt;/li>
&lt;li>Behavioral/perceptual confounds: control speed, head direction, turning angle, visual flow, button/motor effects, and head motion artifacts.&lt;/li>
&lt;li>Direction sampling balance: ensure movement directions are not heavily skewed by task geometry.&lt;/li>
&lt;li>Stability/coherence checks:
&lt;ul>
&lt;li>similar $\phi$ across runs&lt;/li>
&lt;li>non-random orientation structure within Right EC (for example Rayleigh-type checks).&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ol>
&lt;h2 id="7-what-conclusion-is-valid">7) What conclusion is valid&lt;/h2>
&lt;p>A careful claim is:&lt;/p>
&lt;blockquote>
&lt;p>Right EC BOLD shows significant six-fold directional modulation relative to an estimated grid orientation, replicated out-of-sample and stronger than non-6-fold control symmetries. This supports a grid-cell-like population code in Right EC.&lt;/p>
&lt;/blockquote>
&lt;p>What I should avoid saying:&lt;/p>
&lt;blockquote>
&lt;p>fMRI proves single-neuron grid cells in Right EC.&lt;/p>
&lt;/blockquote>
&lt;p>fMRI supports &lt;strong>population-level grid-like representation&lt;/strong>, not direct single-cell firing-field proof.&lt;/p>
&lt;h2 id="one-line-takeaway">One-line takeaway&lt;/h2>
&lt;p>Right EC grid-like signal in fMRI means:
estimate hidden orientation $\phi$ on independent data, then show BOLD is higher for $\theta=\phi+k\cdot60^\circ$ than for $\theta=\phi+30^\circ+k\cdot60^\circ$, with 6-fold specificity and confounds controlled.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Doeller CF, Barry C, Burgess N. &lt;a href="https://pubmed.ncbi.nlm.nih.gov/20090680/" target="_blank" rel="noopener">Evidence for grid cells in a human memory network&lt;/a>. &lt;em>Nature&lt;/em>. 2010.&lt;/li>
&lt;li>Doeller Lab copy of the paper PDF: &lt;a href="https://doellerlab.com/wp-content/uploads/2015/08/doeller-nature-2010.pdf" target="_blank" rel="noopener">Evidence for grid cells in a human memory network&lt;/a>.&lt;/li>
&lt;li>Nature Communications (2024): &lt;a href="https://www.nature.com/articles/s41467-024-45127-z" target="_blank" rel="noopener">Grid-like entorhinal representation of an abstract value space during prospective decision making&lt;/a>.&lt;/li>
&lt;li>Null/constraint evidence in passive navigation context: &lt;a href="https://pub.dzne.de/record/285920/export/hx?ln=en" target="_blank" rel="noopener">Failure to detect entorhinal grid-like signals in a passive virtual navigation paradigm&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Grid Cell Demo 02: Three-Wave Interference and Spatial Autocorrelogram</title><link>https://spidermonk7.github.io/grid-cell-notes/demo2/</link><pubDate>Wed, 13 May 2026 00:00:00 +0000</pubDate><guid>https://spidermonk7.github.io/grid-cell-notes/demo2/</guid><description>&lt;p>This page strips away the rat trajectory and keeps only the theoretical three-wave construction.&lt;/p>
&lt;p>The question here is:&lt;/p>
&lt;ol>
&lt;li>if three direction-tuned waves interfere in space, what firing map does that imply?&lt;/li>
&lt;li>once that firing map is built, what does its &lt;strong>spatial autocorrelogram&lt;/strong> look like?&lt;/li>
&lt;/ol>
&lt;p>The demo below now exposes a broader family of deformations. You can adjust:&lt;/p>
&lt;ul>
&lt;li>&lt;code>beta&lt;/code> and the three &lt;code>k_i&lt;/code> scales for anisotropic wavelength changes&lt;/li>
&lt;li>&lt;code>theta_1&lt;/code>, &lt;code>theta_2&lt;/code>, &lt;code>theta_3&lt;/code> for angle distortions&lt;/li>
&lt;li>&lt;code>A_1&lt;/code>, &lt;code>A_2&lt;/code>, &lt;code>A_3&lt;/code> for amplitude imbalance&lt;/li>
&lt;li>&lt;code>stretch x&lt;/code>, &lt;code>stretch y&lt;/code>, and &lt;code>shear&lt;/code> for global affine deformation&lt;/li>
&lt;li>&lt;code>coord warp&lt;/code> and &lt;code>phase warp&lt;/code> for position-dependent distortion&lt;/li>
&lt;li>&lt;code>amp modulation&lt;/code> for spatially varying amplitude bias&lt;/li>
&lt;li>&lt;code>baseline&lt;/code> and &lt;code>threshold&lt;/code> for excitability / threshold effects&lt;/li>
&lt;/ul>
&lt;p>The left panel shows the theoretical firing pattern itself. The right panel shows the spatial autocorrelogram computed from that map. I also display a simple &lt;strong>gridness&lt;/strong> estimate, so the demo can be used not only qualitatively but also as a first quantitative probe of how each deformation changes hexagonal order.&lt;/p>
&lt;div class="gridcell-demo">
&lt;div class="gridcell-demo-intro">
&lt;p class="gridcell-demo-stage-note">
This demo turns the hypotheses in the note into explicit parameters. You can deform wave lengths, wave angles, amplitudes, affine geometry, nonlinear phase warp, and thresholding, then inspect both the firing map and its spatial autocorrelogram.
&lt;/p>
&lt;/div>
&lt;div class="gridcell-demo-controls">
&lt;label>
&lt;span>beta base&lt;/span>
&lt;input type="range" min="6" max="24" value="15.5" step="0.5" data-role="beta">
&lt;output data-role="beta-value">15.5&lt;/output>
&lt;/label>
&lt;label>
&lt;span>k1 scale&lt;/span>
&lt;input type="range" min="0.6" max="1.4" value="1.00" step="0.01" data-role="k1">
&lt;output data-role="k1-value">1.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>k2 scale&lt;/span>
&lt;input type="range" min="0.6" max="1.4" value="1.00" step="0.01" data-role="k2">
&lt;output data-role="k2-value">1.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>k3 scale&lt;/span>
&lt;input type="range" min="0.6" max="1.4" value="1.00" step="0.01" data-role="k3">
&lt;output data-role="k3-value">1.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>theta_1&lt;/span>
&lt;input type="range" min="0" max="180" value="0" step="1" data-role="theta1">
&lt;output data-role="theta1-value">0 deg&lt;/output>
&lt;/label>
&lt;label>
&lt;span>theta_2&lt;/span>
&lt;input type="range" min="0" max="180" value="60" step="1" data-role="theta2">
&lt;output data-role="theta2-value">60 deg&lt;/output>
&lt;/label>
&lt;label>
&lt;span>theta_3&lt;/span>
&lt;input type="range" min="0" max="180" value="120" step="1" data-role="theta3">
&lt;output data-role="theta3-value">120 deg&lt;/output>
&lt;/label>
&lt;label>
&lt;span>A1&lt;/span>
&lt;input type="range" min="0.3" max="1.6" value="1.00" step="0.01" data-role="a1">
&lt;output data-role="a1-value">1.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>A2&lt;/span>
&lt;input type="range" min="0.3" max="1.6" value="1.00" step="0.01" data-role="a2">
&lt;output data-role="a2-value">1.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>A3&lt;/span>
&lt;input type="range" min="0.3" max="1.6" value="1.00" step="0.01" data-role="a3">
&lt;output data-role="a3-value">1.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>stretch x&lt;/span>
&lt;input type="range" min="0.6" max="1.5" value="1.00" step="0.01" data-role="stretchx">
&lt;output data-role="stretchx-value">1.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>stretch y&lt;/span>
&lt;input type="range" min="0.6" max="1.5" value="1.00" step="0.01" data-role="stretchy">
&lt;output data-role="stretchy-value">1.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>shear&lt;/span>
&lt;input type="range" min="-0.8" max="0.8" value="0.00" step="0.01" data-role="shear">
&lt;output data-role="shear-value">0.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>coord warp&lt;/span>
&lt;input type="range" min="0" max="0.8" value="0.00" step="0.01" data-role="warp">
&lt;output data-role="warp-value">0.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>phase warp&lt;/span>
&lt;input type="range" min="0" max="2.5" value="0.00" step="0.01" data-role="phasewarp">
&lt;output data-role="phasewarp-value">0.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>amp modulation&lt;/span>
&lt;input type="range" min="0" max="0.9" value="0.00" step="0.01" data-role="ampmod">
&lt;output data-role="ampmod-value">0.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>baseline b&lt;/span>
&lt;input type="range" min="-1.5" max="1.5" value="0.00" step="0.01" data-role="baseline">
&lt;output data-role="baseline-value">0.00&lt;/output>
&lt;/label>
&lt;label>
&lt;span>threshold&lt;/span>
&lt;input type="range" min="-0.5" max="3.5" value="0.80" step="0.01" data-role="threshold">
&lt;output data-role="threshold-value">0.80&lt;/output>
&lt;/label>
&lt;/div>
&lt;div class="gridcell-demo-metrics">
&lt;div class="gridcell-demo-metric">
&lt;span>Gridness&lt;/span>
&lt;strong data-role="gridness">0.000&lt;/strong>
&lt;/div>
&lt;div class="gridcell-demo-metric">
&lt;span>Ring Radius&lt;/span>
&lt;strong data-role="ring-radius">0&lt;/strong>
&lt;/div>
&lt;div class="gridcell-demo-metric">
&lt;span>R60 / R30&lt;/span>
&lt;strong data-role="corr-pair">0.000 / 0.000&lt;/strong>
&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-stage gridcell-demo-stage-split">
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Theoretical Firing Pattern&lt;/h4>
&lt;canvas width="520" height="520" data-role="pattern-canvas">&lt;/canvas>
&lt;div class="gridcell-demo-caption">
The firing map after three-wave interference, amplitude modulation, affine distortion, coordinate warp, phase warp, baseline shift, and thresholding.
&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Spatial Autocorrelogram&lt;/h4>
&lt;canvas width="520" height="520" data-role="autocorr-canvas">&lt;/canvas>
&lt;div class="gridcell-demo-caption">
The normalized spatial autocorrelogram. Gridness is computed from rotational correlations on the first ring around the center peak.
&lt;/div>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;script>
(() => {
const root = document.currentScript.previousElementSibling;
if (!root || !root.classList.contains('gridcell-demo')) return;
const patternCanvas = root.querySelector('[data-role="pattern-canvas"]');
const patternCtx = patternCanvas.getContext('2d');
const autocorrCanvas = root.querySelector('[data-role="autocorr-canvas"]');
const autocorrCtx = autocorrCanvas.getContext('2d');
const controls = {
beta: root.querySelector('[data-role="beta"]'),
k1: root.querySelector('[data-role="k1"]'),
k2: root.querySelector('[data-role="k2"]'),
k3: root.querySelector('[data-role="k3"]'),
theta1: root.querySelector('[data-role="theta1"]'),
theta2: root.querySelector('[data-role="theta2"]'),
theta3: root.querySelector('[data-role="theta3"]'),
a1: root.querySelector('[data-role="a1"]'),
a2: root.querySelector('[data-role="a2"]'),
a3: root.querySelector('[data-role="a3"]'),
stretchx: root.querySelector('[data-role="stretchx"]'),
stretchy: root.querySelector('[data-role="stretchy"]'),
shear: root.querySelector('[data-role="shear"]'),
warp: root.querySelector('[data-role="warp"]'),
phasewarp: root.querySelector('[data-role="phasewarp"]'),
ampmod: root.querySelector('[data-role="ampmod"]'),
baseline: root.querySelector('[data-role="baseline"]'),
threshold: root.querySelector('[data-role="threshold"]')
};
const outputs = Object.fromEntries(
Object.keys(controls).map((key) => [key, root.querySelector(`[data-role="${key}-value"]`)])
);
const metrics = {
gridness: root.querySelector('[data-role="gridness"]'),
radius: root.querySelector('[data-role="ring-radius"]'),
corrPair: root.querySelector('[data-role="corr-pair"]')
};
const gridSize = 56;
const centerIndex = Math.floor(gridSize / 2);
const worldRadius = 1.7;
const field = new Float32Array(gridSize * gridSize);
const centered = new Float32Array(gridSize * gridSize);
const autocorr = new Float32Array(gridSize * gridSize);
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function clamp(x, lo, hi) {
return Math.max(lo, Math.min(hi, x));
}
function degToRad(deg) {
return deg * Math.PI / 180;
}
function bilinearSample(data, x, y) {
if (x &lt; 0 || y &lt; 0 || x > gridSize - 1 || y > gridSize - 1) return null;
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const x1 = Math.min(gridSize - 1, x0 + 1);
const y1 = Math.min(gridSize - 1, y0 + 1);
const tx = x - x0;
const ty = y - y0;
const v00 = data[y0 * gridSize + x0];
const v10 = data[y0 * gridSize + x1];
const v01 = data[y1 * gridSize + x0];
const v11 = data[y1 * gridSize + x1];
return (
v00 * (1 - tx) * (1 - ty) +
v10 * tx * (1 - ty) +
v01 * (1 - tx) * ty +
v11 * tx * ty
);
}
function mapPoint(ix, iy) {
return {
x: ((ix / (gridSize - 1)) * 2 - 1) * worldRadius,
y: ((iy / (gridSize - 1)) * 2 - 1) * worldRadius
};
}
function readParams() {
return {
beta: Number(controls.beta.value),
kScales: [Number(controls.k1.value), Number(controls.k2.value), Number(controls.k3.value)],
angles: [
degToRad(Number(controls.theta1.value)),
degToRad(Number(controls.theta2.value)),
degToRad(Number(controls.theta3.value))
],
amps: [Number(controls.a1.value), Number(controls.a2.value), Number(controls.a3.value)],
stretchX: Number(controls.stretchx.value),
stretchY: Number(controls.stretchy.value),
shear: Number(controls.shear.value),
warp: Number(controls.warp.value),
phaseWarp: Number(controls.phasewarp.value),
ampMod: Number(controls.ampmod.value),
baseline: Number(controls.baseline.value),
threshold: Number(controls.threshold.value)
};
}
function refreshLabels(params) {
outputs.beta.textContent = params.beta.toFixed(1);
outputs.k1.textContent = params.kScales[0].toFixed(2);
outputs.k2.textContent = params.kScales[1].toFixed(2);
outputs.k3.textContent = params.kScales[2].toFixed(2);
outputs.theta1.textContent = `${controls.theta1.value} deg`;
outputs.theta2.textContent = `${controls.theta2.value} deg`;
outputs.theta3.textContent = `${controls.theta3.value} deg`;
outputs.a1.textContent = params.amps[0].toFixed(2);
outputs.a2.textContent = params.amps[1].toFixed(2);
outputs.a3.textContent = params.amps[2].toFixed(2);
outputs.stretchx.textContent = params.stretchX.toFixed(2);
outputs.stretchy.textContent = params.stretchY.toFixed(2);
outputs.shear.textContent = params.shear.toFixed(2);
outputs.warp.textContent = params.warp.toFixed(2);
outputs.phasewarp.textContent = params.phaseWarp.toFixed(2);
outputs.ampmod.textContent = params.ampMod.toFixed(2);
outputs.baseline.textContent = params.baseline.toFixed(2);
outputs.threshold.textContent = params.threshold.toFixed(2);
}
function transformCoords(x, y, params) {
const ax = params.stretchX * x + params.shear * y;
const ay = params.stretchY * y;
return {
x: ax + params.warp * 0.55 * x * y,
y: ay + params.warp * 0.35 * (x * x - y * y)
};
}
function localAmplitude(baseAmp, angle, x, y, ampMod) {
const directional = x * Math.cos(angle) + y * Math.sin(angle);
return Math.max(0, baseAmp * (1 + ampMod * directional / worldRadius));
}
function localPhaseWarp(x, y, index, strength) {
return strength * Math.sin(0.95 * x + 0.65 * y + index * 1.37);
}
function fillField(params) {
let sum = 0;
let maxValue = 0;
for (let iy = 0; iy &lt; gridSize; iy += 1) {
for (let ix = 0; ix &lt; gridSize; ix += 1) {
const { x, y } = mapPoint(ix, iy);
const xi = transformCoords(x, y, params);
let raw = params.baseline;
for (let i = 0; i &lt; 3; i += 1) {
const projection = xi.x * Math.cos(params.angles[i]) + xi.y * Math.sin(params.angles[i]);
const amp = localAmplitude(params.amps[i], params.angles[i], x, y, params.ampMod);
raw += amp * Math.cos(params.beta * params.kScales[i] * projection + localPhaseWarp(x, y, i, params.phaseWarp));
}
const firing = Math.max(0, raw - params.threshold);
const index = iy * gridSize + ix;
field[index] = firing;
sum += firing;
if (firing > maxValue) maxValue = firing;
}
}
const mean = sum / field.length;
for (let i = 0; i &lt; field.length; i += 1) {
centered[i] = field[i] - mean;
if (maxValue > 1e-8) {
field[i] /= maxValue;
}
}
}
function computeAutocorr() {
let maxValue = -Infinity;
for (let dy = 0; dy &lt; gridSize; dy += 1) {
for (let dx = 0; dx &lt; gridSize; dx += 1) {
const shiftX = dx - centerIndex;
const shiftY = dy - centerIndex;
let numerator = 0;
let leftNorm = 0;
let rightNorm = 0;
for (let y = 0; y &lt; gridSize; y += 1) {
const yy = y + shiftY;
if (yy &lt; 0 || yy >= gridSize) continue;
for (let x = 0; x &lt; gridSize; x += 1) {
const xx = x + shiftX;
if (xx &lt; 0 || xx >= gridSize) continue;
const a = centered[y * gridSize + x];
const b = centered[yy * gridSize + xx];
numerator += a * b;
leftNorm += a * a;
rightNorm += b * b;
}
}
const denom = Math.sqrt(leftNorm * rightNorm);
const value = denom > 1e-8 ? numerator / denom : 0;
autocorr[dy * gridSize + dx] = value;
if (value > maxValue) maxValue = value;
}
}
if (maxValue > 0) {
for (let i = 0; i &lt; autocorr.length; i += 1) {
autocorr[i] /= maxValue;
}
}
}
function estimateRingRadius() {
let bestValue = -Infinity;
let bestRadius = 10;
for (let y = 0; y &lt; gridSize; y += 1) {
for (let x = 0; x &lt; gridSize; x += 1) {
const dx = x - centerIndex;
const dy = y - centerIndex;
const radius = Math.hypot(dx, dy);
if (radius &lt; 5 || radius > gridSize * 0.35) continue;
const value = autocorr[y * gridSize + x];
if (value > bestValue) {
bestValue = value;
bestRadius = radius;
}
}
}
return bestRadius;
}
function rotationalCorrelation(angleDeg, innerRadius, outerRadius) {
const angle = degToRad(angleDeg);
let sumA = 0;
let sumB = 0;
let sumAA = 0;
let sumBB = 0;
let sumAB = 0;
let count = 0;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
for (let y = 0; y &lt; gridSize; y += 1) {
for (let x = 0; x &lt; gridSize; x += 1) {
const dx = x - centerIndex;
const dy = y - centerIndex;
const radius = Math.hypot(dx, dy);
if (radius &lt; innerRadius || radius > outerRadius) continue;
const rx = centerIndex + dx * cos - dy * sin;
const ry = centerIndex + dx * sin + dy * cos;
const rotated = bilinearSample(autocorr, rx, ry);
if (rotated == null) continue;
const base = autocorr[y * gridSize + x];
sumA += base;
sumB += rotated;
sumAA += base * base;
sumBB += rotated * rotated;
sumAB += base * rotated;
count += 1;
}
}
if (count &lt; 10) return 0;
const cov = sumAB - (sumA * sumB) / count;
const varA = sumAA - (sumA * sumA) / count;
const varB = sumBB - (sumB * sumB) / count;
const denom = Math.sqrt(Math.max(varA, 0) * Math.max(varB, 0));
return denom > 1e-8 ? cov / denom : 0;
}
function computeGridness() {
const ringRadius = estimateRingRadius();
const inner = Math.max(4, ringRadius * 0.55);
const outer = Math.min(gridSize * 0.48, ringRadius * 1.45);
const c30 = rotationalCorrelation(30, inner, outer);
const c60 = rotationalCorrelation(60, inner, outer);
const c90 = rotationalCorrelation(90, inner, outer);
const c120 = rotationalCorrelation(120, inner, outer);
const c150 = rotationalCorrelation(150, inner, outer);
const gridness = Math.min(c60, c120) - Math.max(c30, c90, c150);
return { gridness, ringRadius, c30, c60 };
}
function drawGrid(ctx, canvas, data, mode, ringRadius) {
const width = canvas.width;
const height = canvas.height;
const image = ctx.createImageData(width, height);
const pixels = image.data;
let ptr = 0;
for (let py = 0; py &lt; height; py += 1) {
for (let px = 0; px &lt; width; px += 1) {
const ix = Math.min(gridSize - 1, Math.floor((px / width) * gridSize));
const iy = Math.min(gridSize - 1, Math.floor(((height - py) / height) * gridSize));
const value = data[iy * gridSize + ix];
let r;
let g;
let b;
if (mode === 'pattern') {
const t = clamp01(value);
r = 249 - 42 * t;
g = 244 - 126 * t;
b = 236 - 190 * t;
} else {
const t = clamp01((value + 1) / 2);
r = 243 - 176 * t;
g = 240 - 140 * t;
b = 234 - 62 * t;
}
pixels[ptr] = Math.round(r);
pixels[ptr + 1] = Math.round(g);
pixels[ptr + 2] = Math.round(b);
pixels[ptr + 3] = 255;
ptr += 4;
}
}
ctx.putImageData(image, 0, 0);
ctx.save();
ctx.strokeStyle = 'rgba(15, 23, 42, 0.12)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(width / 2, 0);
ctx.lineTo(width / 2, height);
ctx.moveTo(0, height / 2);
ctx.lineTo(width, height / 2);
ctx.stroke();
if (mode === 'autocorr' &amp;&amp; ringRadius > 0) {
const pxRadius = (ringRadius / gridSize) * width;
ctx.strokeStyle = 'rgba(143, 29, 29, 0.32)';
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.arc(width / 2, height / 2, pxRadius, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.restore();
}
function render() {
const params = readParams();
refreshLabels(params);
fillField(params);
computeAutocorr();
const stats = computeGridness();
drawGrid(patternCtx, patternCanvas, field, 'pattern', 0);
drawGrid(autocorrCtx, autocorrCanvas, autocorr, 'autocorr', stats.ringRadius);
metrics.gridness.textContent = stats.gridness.toFixed(3);
metrics.radius.textContent = stats.ringRadius.toFixed(1);
metrics.corrPair.textContent = `${stats.c60.toFixed(3)} / ${stats.c30.toFixed(3)}`;
}
Object.values(controls).forEach((control) => {
control.addEventListener('input', render);
});
render();
})();
&lt;/script></description></item><item><title>Grid Cell Notes 03: Environmental Geometry Deforms Grid Patterns</title><link>https://spidermonk7.github.io/grid-cell-notes/grid-cell-environmental-geometry/</link><pubDate>Wed, 13 May 2026 00:00:00 +0000</pubDate><guid>https://spidermonk7.github.io/grid-cell-notes/grid-cell-environmental-geometry/</guid><description>&lt;p>This note is a literature checkpoint rather than a derivation. The question is simple:&lt;/p>
&lt;p>If grid cells really implement a clean internal spatial metric, then what happens when the animal explores an enclosure whose geometry is strongly polarized or deformed?&lt;/p>
&lt;p>The short answer from experiment is: &lt;strong>the grid is not perfectly rigid&lt;/strong>. In sufficiently non-regular environments, grid firing patterns can become stretched, less hexagonal, locally non-uniform, and history-dependent.&lt;/p>
&lt;h2 id="the-phenomenon">The Phenomenon&lt;/h2>
&lt;p>The clearest empirical message from the literature is that environmental geometry can reshape grid firing in at least three related ways:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Global distortion&lt;/strong>: the lattice can become less hexagonal and more elliptical.&lt;/li>
&lt;li>&lt;strong>Local distortion&lt;/strong>: different parts of the same enclosure can carry different local grid structure.&lt;/li>
&lt;li>&lt;strong>Deformation-specific remapping of metric&lt;/strong>: compression or expansion of the enclosure can change grid spacing and apparent field positions, often in a boundary-dependent way.&lt;/li>
&lt;/ol>
&lt;p>So the naive statement&lt;/p>
$$
\text{grid cell firing fields are a universal, geometry-invariant hexagonal metric}
$$&lt;p>is too strong.&lt;/p>
&lt;h2 id="classic-trapezoid-result">Classic Trapezoid Result&lt;/h2>
&lt;p>One of the strongest demonstrations is the trapezoid experiment of &lt;a href="https://www.nature.com/articles/nature14153" target="_blank" rel="noopener">Krupic et al. (2015)&lt;/a>, who compared firing in square and trapezoidal environments.&lt;/p>
&lt;p>Their main qualitative findings were:&lt;/p>
&lt;ul>
&lt;li>in squares, grid patterns retained stronger hexagonal regularity&lt;/li>
&lt;li>in trapezoids, the pattern became more elliptical and less homogeneous&lt;/li>
&lt;li>the distortion was not only global; the narrow and wide sides of the trapezoid showed different local structure&lt;/li>
&lt;/ul>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Krupic et al. 2015 Figure 3 crop showing global distortion of grid fields in trapezoids" srcset="
/grid-cell-notes/grid-cell-environmental-geometry/krupic-2015-trapezoid-global_hu4719692946423691809.webp 400w,
/grid-cell-notes/grid-cell-environmental-geometry/krupic-2015-trapezoid-global_hu8784171785111729418.webp 760w,
/grid-cell-notes/grid-cell-environmental-geometry/krupic-2015-trapezoid-global_hu12755140713097327324.webp 1200w"
src="https://spidermonk7.github.io/grid-cell-notes/grid-cell-environmental-geometry/krupic-2015-trapezoid-global_hu4719692946423691809.webp"
width="560"
height="585"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;/p>
&lt;p>&lt;em>Biological data from Figure 3 of Krupic et al. (2015), cropped from the paper PDF. The same cells show cleaner hexagonal structure in the square and visibly distorted, stretched patterns in the trapezoid.&lt;/em>&lt;/p>
&lt;p>
&lt;figure >
&lt;div class="flex justify-center ">
&lt;div class="w-100" >&lt;img alt="Krupic et al. 2015 Figure 4 crop showing local inhomogeneity across the two sides of a trapezoid" srcset="
/grid-cell-notes/grid-cell-environmental-geometry/krupic-2015-trapezoid-local_hu17722921585197554632.webp 400w,
/grid-cell-notes/grid-cell-environmental-geometry/krupic-2015-trapezoid-local_hu1110076970105554180.webp 760w,
/grid-cell-notes/grid-cell-environmental-geometry/krupic-2015-trapezoid-local_hu10413673133511242310.webp 1200w"
src="https://spidermonk7.github.io/grid-cell-notes/grid-cell-environmental-geometry/krupic-2015-trapezoid-local_hu17722921585197554632.webp"
width="580"
height="705"
loading="lazy" data-zoomable />&lt;/div>
&lt;/div>&lt;/figure>
&lt;/p>
&lt;p>&lt;em>Biological data from Figure 4 of Krupic et al. (2015), cropped from the paper PDF. The left and right sides of the trapezoid do not carry identical local grid structure: the pattern rotates, stretches, and changes field size across the enclosure.&lt;/em>&lt;/p>
&lt;p>For my own purposes, this is the most important observation: &lt;strong>the distortion is not just a single uniform affine warp of an otherwise perfect lattice&lt;/strong>. The same enclosure can induce different local grid structure in different subregions.&lt;/p>
&lt;h2 id="beyond-trapezoids-environmental-deformation">Beyond Trapezoids: Environmental Deformation&lt;/h2>
&lt;p>Later work pushed this farther by asking what happens when familiar enclosures are compressed, stretched, or otherwise deformed.&lt;/p>
&lt;p>In &lt;a href="https://elifesciences.org/articles/38169" target="_blank" rel="noopener">Keinath, Epstein, and Balasubramanian (2018)&lt;/a>, the authors argued that environmental deformations produce systematic shifts in the grid metric that are better understood as &lt;strong>boundary-tethered, history-dependent distortions&lt;/strong> than as a simple global rescaling. In other words, the apparent grid can depend on how recent boundary encounters anchor the phase pattern.&lt;/p>
&lt;p>This is useful because it says the deformation effect is not merely a static shape issue. It depends on how self-motion integration interacts with environmental boundaries over time.&lt;/p>
&lt;p>Related evidence from &lt;a href="https://pubmed.ncbi.nlm.nih.gov/32814067/" target="_blank" rel="noopener">Munn et al. (2020)&lt;/a> showed that entorhinal velocity-related signals themselves reflect environmental geometry. That result matters because it suggests the distortion may enter not only at the level of the final firing map, but also upstream in the velocity coding and path-integration machinery.&lt;/p>
&lt;h2 id="what-i-take-from-these-papers">What I Take From These Papers&lt;/h2>
&lt;p>For this notebook, the current takeaway is:&lt;/p>
&lt;ol>
&lt;li>A grid pattern should not be treated as an always-perfect Euclidean ruler.&lt;/li>
&lt;li>Strongly polarized geometry can bias orientation, spacing, ellipticity, and local homogeneity.&lt;/li>
&lt;li>A narrow rectangle or a trapezoid should not be expected to preserve the same lattice visible in a square.&lt;/li>
&lt;li>If I build demos later, it will be worth distinguishing:
&lt;ul>
&lt;li>a purely internal oscillatory/interference mechanism&lt;/li>
&lt;li>boundary-driven anchoring or correction&lt;/li>
&lt;li>velocity-code distortion induced by enclosure geometry&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ol>
&lt;p>So the next conceptual question is no longer only “how do multiple waves form a hexagonal pattern?” It is also:&lt;/p>
$$
\text{how does environmental geometry bend or constrain that pattern?}
$$&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Krupic J, Bauza M, Burton S, Barry C, O&amp;rsquo;Keefe J. &lt;a href="https://www.nature.com/articles/nature14153" target="_blank" rel="noopener">Grid cell symmetry is shaped by environmental geometry&lt;/a>. &lt;em>Nature&lt;/em>. 2015.&lt;/li>
&lt;li>Keinath AT, Epstein RA, Balasubramanian V. &lt;a href="https://elifesciences.org/articles/38169" target="_blank" rel="noopener">Environmental deformations dynamically shift the grid cell spatial metric&lt;/a>. &lt;em>eLife&lt;/em>. 2018.&lt;/li>
&lt;li>Munn RGK, Rikhye RV, Sreenivasan S, et al. &lt;a href="https://pubmed.ncbi.nlm.nih.gov/32814067/" target="_blank" rel="noopener">Entorhinal velocity signals reflect environmental geometry&lt;/a>. &lt;em>Nature Neuroscience&lt;/em>. 2020.&lt;/li>
&lt;/ul></description></item><item><title>Grid Cell Demo 01: Rat Walk Demo in a 2 x 2 Arena</title><link>https://spidermonk7.github.io/grid-cell-notes/demo1/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://spidermonk7.github.io/grid-cell-notes/demo1/</guid><description>&lt;p>This page is the first step from static geometry toward an actual moving-animal simulation.&lt;/p>
&lt;p>The setup is intentionally simple:&lt;/p>
&lt;ul>
&lt;li>the arena is a fixed $2\times 2$ square&lt;/li>
&lt;li>the rat is controlled by &lt;code>W&lt;/code>, &lt;code>A&lt;/code>, &lt;code>S&lt;/code>, &lt;code>D&lt;/code>&lt;/li>
&lt;li>the right side shows four moving waves: &lt;code>theta&lt;/code>, &lt;code>wave 1&lt;/code>, &lt;code>wave 2&lt;/code>, &lt;code>wave 3&lt;/code>&lt;/li>
&lt;li>the middle panel accumulates the cell&amp;rsquo;s firing as the rat moves&lt;/li>
&lt;/ul>
&lt;p>The heatmap keeps the full firing history until you reset it, so the spatial pattern emerges directly from the trajectory itself rather than from a pre-drawn lattice.&lt;/p>
&lt;div class="gridcell-dynamic-demo">
&lt;div class="gridcell-dynamic-demo__header">
&lt;p class="gridcell-dynamic-demo__intro">
Drag inside the arena and the rat will stay locked to the mouse position. The center panel is driven directly by online phase interference: motion updates the waves in real time, the four-wave sum is converted into a 0-1 score, and firing is deposited only when that score crosses the current threshold. The right column shows the live theta wave together with the three direction-tuned waves.
&lt;/p>
&lt;div class="gridcell-dynamic-demo__actions">
&lt;button type="button" data-role="reset">Reset trail&lt;/button>
&lt;/div>
&lt;/div>
&lt;div class="gridcell-dynamic-demo__controls">
&lt;label>
&lt;span>Firing threshold&lt;/span>
&lt;input type="range" min="0" max="1" step="0.01" value="0.95" data-role="threshold">
&lt;output data-role="threshold-value">0.95&lt;/output>
&lt;/label>
&lt;/div>
&lt;div class="gridcell-dynamic-demo__layout">
&lt;section class="gridcell-dynamic-demo__card">
&lt;h4>Rat in 2 x 2 Arena&lt;/h4>
&lt;canvas width="360" height="360" data-role="arena-canvas">&lt;/canvas>
&lt;p class="gridcell-dynamic-demo__caption">
While you drag, the rat moves exactly with the pointer inside the square world, and that movement updates the phase evolution of the directional waves.
&lt;/p>
&lt;/section>
&lt;section class="gridcell-dynamic-demo__card">
&lt;h4>Grid Cell Firing&lt;/h4>
&lt;canvas width="360" height="360" data-role="heatmap-canvas">&lt;/canvas>
&lt;canvas width="360" height="120" data-role="interference-canvas">&lt;/canvas>
&lt;p class="gridcell-dynamic-demo__caption">
Pale circles show the theoretical firing sites predicted by the same four-wave score and the same threshold. The brighter heat layer records the actual online firing accumulated from the rat's trajectory.
&lt;/p>
&lt;/section>
&lt;section class="gridcell-dynamic-demo__card">
&lt;h4>Oscillatory Waves&lt;/h4>
&lt;div class="gridcell-dynamic-demo__wave-stack">
&lt;canvas width="420" height="92" data-role="wave-theta">&lt;/canvas>
&lt;canvas width="420" height="92" data-role="wave-1">&lt;/canvas>
&lt;canvas width="420" height="92" data-role="wave-2">&lt;/canvas>
&lt;canvas width="420" height="92" data-role="wave-3">&lt;/canvas>
&lt;/div>
&lt;p class="gridcell-dynamic-demo__caption">
These four traces show the moving theta wave together with the three direction-tuned waves.
&lt;/p>
&lt;/section>
&lt;/div>
&lt;/div>
&lt;script>
(() => {
const root = document.currentScript.previousElementSibling;
if (!root || !root.classList.contains('gridcell-dynamic-demo')) return;
const arenaCanvas = root.querySelector('[data-role="arena-canvas"]');
const arenaCtx = arenaCanvas.getContext('2d');
const heatmapCanvas = root.querySelector('[data-role="heatmap-canvas"]');
const heatmapCtx = heatmapCanvas.getContext('2d');
const interferenceCanvas = root.querySelector('[data-role="interference-canvas"]');
const resetButton = root.querySelector('[data-role="reset"]');
const thresholdInput = root.querySelector('[data-role="threshold"]');
const thresholdOutput = root.querySelector('[data-role="threshold-value"]');
const waveCanvases = {
theta: root.querySelector('[data-role="wave-theta"]'),
wave1: root.querySelector('[data-role="wave-1"]'),
wave2: root.querySelector('[data-role="wave-2"]'),
wave3: root.querySelector('[data-role="wave-3"]')
};
const arenaSize = 2;
const trail = [];
const heatSize = 72;
const heat = new Float32Array(heatSize * heatSize);
const waveAngles = [0, Math.PI / 3, (2 * Math.PI) / 3];
const waveColors = ['#153a63', '#b06a2b', '#2f6a4f'];
const state = {
x: 1,
y: 1,
vx: 0,
vy: 0,
dragging: false,
lastPointerTime: null,
thetaPhase: 0,
directionalPhases: [0, 0, 0],
lastTime: null
};
const settings = {
omega0: 5.8,
beta: 15.4,
depositRadius: 3,
peakThreshold: 0.95
};
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function clearHeat() {
heat.fill(0);
trail.length = 0;
}
function theoreticalSpatialScore(x, y) {
const cx = x - arenaSize / 2;
const cy = y - arenaSize / 2;
const waves = waveAngles.map((angle) => {
const projection = cx * Math.cos(angle) + cy * Math.sin(angle);
return Math.cos(settings.beta * projection);
});
const raw = 1 + waves[0] + waves[1] + waves[2];
return (raw + 4) / 8;
}
function theoreticalMarkers() {
const samples = 84;
const markers = [];
for (let iy = 1; iy &lt; samples - 1; iy += 1) {
for (let ix = 1; ix &lt; samples - 1; ix += 1) {
const x = (ix / (samples - 1)) * arenaSize;
const y = (iy / (samples - 1)) * arenaSize;
const center = theoreticalSpatialScore(x, y);
if (center &lt; settings.peakThreshold) continue;
let isLocalMax = true;
for (let oy = -1; oy &lt;= 1 &amp;&amp; isLocalMax; oy += 1) {
for (let ox = -1; ox &lt;= 1; ox += 1) {
if (ox === 0 &amp;&amp; oy === 0) continue;
const nx = ((ix + ox) / (samples - 1)) * arenaSize;
const ny = ((iy + oy) / (samples - 1)) * arenaSize;
if (theoreticalSpatialScore(nx, ny) > center) {
isLocalMax = false;
break;
}
}
}
if (isLocalMax) {
markers.push({ x, y, value: center });
}
}
}
return markers;
}
function toPixelX(x, width) {
return (x / arenaSize) * width;
}
function toPixelY(y, height) {
return height - (y / arenaSize) * height;
}
function syncTargetFromEvent(event) {
const rect = arenaCanvas.getBoundingClientRect();
const localX = clamp((event.clientX - rect.left) / rect.width, 0, 1);
const localY = clamp((event.clientY - rect.top) / rect.height, 0, 1);
return {
x: localX * arenaSize,
y: (1 - localY) * arenaSize
};
}
function pointerPosition(event) {
const next = syncTargetFromEvent(event);
return next;
}
function depositHeat(x, y, amount) {
if (amount &lt;= 0.001) return;
const cx = Math.round((x / arenaSize) * (heatSize - 1));
const cy = Math.round((y / arenaSize) * (heatSize - 1));
for (let oy = -settings.depositRadius; oy &lt;= settings.depositRadius; oy += 1) {
for (let ox = -settings.depositRadius; ox &lt;= settings.depositRadius; ox += 1) {
const ix = cx + ox;
const iy = cy + oy;
if (ix &lt; 0 || ix >= heatSize || iy &lt; 0 || iy >= heatSize) continue;
const distance = Math.hypot(ox, oy);
const falloff = Math.exp(-(distance * distance) / 4.5);
const index = iy * heatSize + ix;
heat[index] = clamp(heat[index] + amount * falloff, 0, 1.45);
}
}
}
function currentWaveSignals() {
const theta = Math.sin(state.thetaPhase);
const waves = state.directionalPhases.map((phase) => Math.sin(phase));
const raw = theta + waves[0] + waves[1] + waves[2];
const score = (raw + 4) / 8;
const firing = score >= settings.peakThreshold
? Math.pow((score - settings.peakThreshold) / (1 - settings.peakThreshold), 1.15)
: 0;
return { theta, waves, raw, score, firing };
}
function step(dt) {
state.thetaPhase += settings.omega0 * dt;
for (let i = 0; i &lt; waveAngles.length; i += 1) {
const ux = Math.cos(waveAngles[i]);
const uy = Math.sin(waveAngles[i]);
const projectedVelocity = state.vx * ux + state.vy * uy;
state.directionalPhases[i] += (settings.omega0 + settings.beta * projectedVelocity) * dt;
}
const { firing } = currentWaveSignals();
depositHeat(state.x, state.y, firing * 0.26);
trail.push({ x: state.x, y: state.y });
if (trail.length > 260) trail.shift();
}
function drawArena() {
const width = arenaCanvas.width;
const height = arenaCanvas.height;
arenaCtx.clearRect(0, 0, width, height);
arenaCtx.fillStyle = '#f9f6f0';
arenaCtx.fillRect(0, 0, width, height);
arenaCtx.strokeStyle = 'rgba(19, 21, 24, 0.1)';
arenaCtx.lineWidth = 1;
for (let i = 0; i &lt;= 4; i += 1) {
const x = (width / 4) * i;
const y = (height / 4) * i;
arenaCtx.beginPath();
arenaCtx.moveTo(x, 0);
arenaCtx.lineTo(x, height);
arenaCtx.stroke();
arenaCtx.beginPath();
arenaCtx.moveTo(0, y);
arenaCtx.lineTo(width, y);
arenaCtx.stroke();
}
arenaCtx.strokeStyle = 'rgba(21, 58, 99, 0.28)';
arenaCtx.lineWidth = 2;
arenaCtx.strokeRect(1, 1, width - 2, height - 2);
if (trail.length > 1) {
arenaCtx.save();
arenaCtx.strokeStyle = 'rgba(21, 58, 99, 0.22)';
arenaCtx.lineWidth = 2.2;
arenaCtx.beginPath();
trail.forEach((point, index) => {
const px = toPixelX(point.x, width);
const py = toPixelY(point.y, height);
if (index === 0) {
arenaCtx.moveTo(px, py);
} else {
arenaCtx.lineTo(px, py);
}
});
arenaCtx.stroke();
arenaCtx.restore();
}
const ratX = toPixelX(state.x, width);
const ratY = toPixelY(state.y, height);
arenaCtx.fillStyle = '#131518';
arenaCtx.beginPath();
arenaCtx.arc(ratX, ratY, 8, 0, Math.PI * 2);
arenaCtx.fill();
arenaCtx.fillStyle = 'rgba(19, 21, 24, 0.64)';
arenaCtx.font = '12px Instrument Sans, Segoe UI, sans-serif';
arenaCtx.fillText('0', 6, height - 8);
arenaCtx.fillText('2', width - 14, height - 8);
arenaCtx.fillText('2', 6, 14);
}
function drawHeatmap() {
const width = heatmapCanvas.width;
const height = heatmapCanvas.height;
const image = heatmapCtx.createImageData(width, height);
const data = image.data;
let ptr = 0;
for (let py = 0; py &lt; height; py += 1) {
for (let px = 0; px &lt; width; px += 1) {
const ix = Math.floor((px / width) * heatSize);
const iy = Math.floor(((height - py) / height) * heatSize);
const value = clamp(heat[Math.min(heatSize - 1, iy) * heatSize + Math.min(heatSize - 1, ix)] || 0, 0, 1);
const r = 249 - 24 * value;
const g = 245 - 126 * value;
const b = 238 - 187 * value;
data[ptr] = Math.round(r);
data[ptr + 1] = Math.round(g);
data[ptr + 2] = Math.round(b);
data[ptr + 3] = 255;
ptr += 4;
}
}
heatmapCtx.putImageData(image, 0, 0);
heatmapCtx.save();
const markers = theoreticalMarkers();
markers.forEach((marker) => {
const px = toPixelX(marker.x, width);
const py = toPixelY(marker.y, height);
const strength = (marker.value - settings.peakThreshold) / Math.max(1 - settings.peakThreshold, 1e-6);
const radius = 5 + 7 * strength;
heatmapCtx.fillStyle = 'rgba(143, 29, 29, 0.10)';
heatmapCtx.beginPath();
heatmapCtx.arc(px, py, radius, 0, Math.PI * 2);
heatmapCtx.fill();
heatmapCtx.strokeStyle = 'rgba(143, 29, 29, 0.26)';
heatmapCtx.lineWidth = 1;
heatmapCtx.beginPath();
heatmapCtx.arc(px, py, radius, 0, Math.PI * 2);
heatmapCtx.stroke();
});
heatmapCtx.strokeStyle = 'rgba(19, 21, 24, 0.1)';
heatmapCtx.lineWidth = 1;
for (let i = 0; i &lt;= 4; i += 1) {
const x = (width / 4) * i;
const y = (height / 4) * i;
heatmapCtx.beginPath();
heatmapCtx.moveTo(x, 0);
heatmapCtx.lineTo(x, height);
heatmapCtx.stroke();
heatmapCtx.beginPath();
heatmapCtx.moveTo(0, y);
heatmapCtx.lineTo(width, y);
heatmapCtx.stroke();
}
heatmapCtx.restore();
const ratX = toPixelX(state.x, width);
const ratY = toPixelY(state.y, height);
heatmapCtx.fillStyle = 'rgba(19, 21, 24, 0.72)';
heatmapCtx.beginPath();
heatmapCtx.arc(ratX, ratY, 4.5, 0, Math.PI * 2);
heatmapCtx.fill();
}
function drawWaveTrace(canvas, label, color, sampleFn) {
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const paddingX = 18;
const baseline = height / 2;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f9f6f0';
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(19, 21, 24, 0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(paddingX, baseline);
ctx.lineTo(width - paddingX, baseline);
ctx.stroke();
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2.5;
ctx.beginPath();
for (let i = 0; i &lt;= 240; i += 1) {
const t = i / 240;
const x = paddingX + t * (width - paddingX * 2);
const y = baseline - sampleFn(t) * (height * 0.3);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
ctx.restore();
ctx.fillStyle = 'rgba(19, 21, 24, 0.72)';
ctx.font = '12px Instrument Sans, Segoe UI, sans-serif';
ctx.fillText(label, 14, 16);
}
function drawInterferenceTrace(canvas, sampleFn) {
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const paddingX = 18;
const baseline = height / 2;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f9f6f0';
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(19, 21, 24, 0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(paddingX, baseline);
ctx.lineTo(width - paddingX, baseline);
ctx.stroke();
const amplitudeScale = height * 0.11;
const thresholdAmplitude = settings.peakThreshold * 8 - 4;
const thresholdY = baseline - thresholdAmplitude * amplitudeScale;
ctx.save();
ctx.strokeStyle = 'rgba(143, 29, 29, 0.45)';
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 5]);
ctx.beginPath();
ctx.moveTo(paddingX, thresholdY);
ctx.lineTo(width - paddingX, thresholdY);
ctx.stroke();
ctx.restore();
ctx.save();
ctx.strokeStyle = '#8f1d1d';
ctx.lineWidth = 2.5;
ctx.beginPath();
for (let i = 0; i &lt;= 240; i += 1) {
const t = i / 240;
const x = paddingX + t * (width - paddingX * 2);
const y = baseline - sampleFn(t) * amplitudeScale;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
ctx.restore();
ctx.fillStyle = 'rgba(19, 21, 24, 0.72)';
ctx.font = '12px Instrument Sans, Segoe UI, sans-serif';
ctx.fillText('interference', 14, 16);
ctx.fillStyle = 'rgba(143, 29, 29, 0.75)';
ctx.fillText('threshold', width - 86, thresholdY - 6);
ctx.fillStyle = 'rgba(19, 21, 24, 0.52)';
ctx.fillText('+4', width - 28, 16);
ctx.fillText('-4', width - 28, height - 8);
}
function drawWaves() {
const spatialFrequency = 3.2;
const thetaTrace = (t) => Math.sin(spatialFrequency * (t * Math.PI * 2) + state.thetaPhase);
const waveTraces = state.directionalPhases.map(
(phase) => (t) => Math.sin(spatialFrequency * (t * Math.PI * 2) + phase)
);
const sumTrace = (t) => (
thetaTrace(t) +
waveTraces[0](t) +
waveTraces[1](t) +
waveTraces[2](t)
);
drawWaveTrace(waveCanvases.theta, 'theta', '#6d1f7a', thetaTrace);
drawWaveTrace(waveCanvases.wave1, 'wave 1', waveColors[0], waveTraces[0]);
drawWaveTrace(waveCanvases.wave2, 'wave 2', waveColors[1], waveTraces[1]);
drawWaveTrace(waveCanvases.wave3, 'wave 3', waveColors[2], waveTraces[2]);
drawInterferenceTrace(interferenceCanvas, sumTrace);
}
function renderFrame(timestamp) {
if (state.lastTime == null) {
state.lastTime = timestamp;
}
const dt = Math.min((timestamp - state.lastTime) / 1000, 0.04);
state.lastTime = timestamp;
step(dt);
drawArena();
drawHeatmap();
drawWaves();
state.vx = 0;
state.vy = 0;
window.requestAnimationFrame(renderFrame);
}
resetButton.addEventListener('click', clearHeat);
thresholdInput.addEventListener('input', () => {
settings.peakThreshold = Number(thresholdInput.value);
thresholdOutput.textContent = settings.peakThreshold.toFixed(2);
});
arenaCanvas.addEventListener('pointerdown', (event) => {
state.dragging = true;
state.vx = 0;
state.vy = 0;
state.lastPointerTime = performance.now();
arenaCanvas.setPointerCapture(event.pointerId);
});
arenaCanvas.addEventListener('pointermove', (event) => {
if (!state.dragging) return;
const next = pointerPosition(event);
const now = performance.now();
const dt = Math.max((now - (state.lastPointerTime || now)) / 1000, 1e-3);
state.vx = (next.x - state.x) / dt;
state.vy = (next.y - state.y) / dt;
state.x = next.x;
state.y = next.y;
state.lastPointerTime = now;
});
arenaCanvas.addEventListener('pointerup', (event) => {
state.dragging = false;
state.vx = 0;
state.vy = 0;
state.lastPointerTime = null;
arenaCanvas.releasePointerCapture(event.pointerId);
});
arenaCanvas.addEventListener('pointercancel', () => {
state.dragging = false;
state.vx = 0;
state.vy = 0;
state.lastPointerTime = null;
});
arenaCanvas.addEventListener('pointerleave', () => {
state.dragging = false;
state.vx = 0;
state.vy = 0;
state.lastPointerTime = null;
});
arenaCanvas.setAttribute('tabindex', '0');
clearHeat();
thresholdOutput.textContent = settings.peakThreshold.toFixed(2);
window.requestAnimationFrame(renderFrame);
})();
&lt;/script></description></item><item><title>Grid Cell Notes 01: Oscillatory Interference Along a Preferred Direction</title><link>https://spidermonk7.github.io/grid-cell-notes/grid-cell-lattice-intuition/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://spidermonk7.github.io/grid-cell-notes/grid-cell-lattice-intuition/</guid><description>&lt;p>This column is meant to be a running notebook. I do not want it to be limited to prose. For grid cells, intuition often becomes much clearer when formulas and visual structure are placed side by side, and even clearer when some parameters can be manipulated directly.&lt;/p>
&lt;p>This first note focuses on the simplest case of the oscillatory-interference picture: the animal moves along the preferred direction of one wave, that wave changes frequency relative to a background pacemaker, and the drifting phase difference generates a fixed firing spacing.&lt;/p>
&lt;h2 id="symbol-setup">Symbol setup&lt;/h2>
&lt;p>To keep the notation fixed, I will use:&lt;/p>
&lt;ul>
&lt;li>$\theta_i$: the preferred direction of wave $i$&lt;/li>
&lt;li>$\Phi_0$: the phase of the background pacemaker in the brain&lt;/li>
&lt;li>$\Phi_i$: the phase of wave $i$&lt;/li>
&lt;li>$\omega_0$: the baseline angular frequency of the pacemaker&lt;/li>
&lt;li>$\beta$: the gain that converts motion into frequency shift&lt;/li>
&lt;li>$v(t)$: the animal&amp;rsquo;s instantaneous speed&lt;/li>
&lt;/ul>
&lt;p>In this note, I only consider the case where the animal is moving exactly along the preferred direction of wave $i$. Under that assumption, the frequency of the wave changes according to&lt;/p>
$$
\omega_i(t)=\omega_0+\beta v(t).
$$&lt;p>So the directional dependence is absorbed into the assumption itself: I am already on the preferred axis of this wave.&lt;/p>
&lt;h2 id="why-focus-on-relative-phase">Why focus on relative phase&lt;/h2>
&lt;p>Before writing down formulas, the key intuition is this: in an oscillatory-interference view of grid cells, what matters is not an isolated wave by itself, but how one oscillation lines up against another.&lt;/p>
&lt;p>Here the background pacemaker provides one oscillatory reference, and the direction-tuned wave provides another. If their peaks keep overlapping, the interference is strong. If they drift apart, the interference weakens.&lt;/p>
&lt;p>So if we assume that a grid cell tends to fire when wave peaks align strongly enough, then the central object to track is not the absolute phase of either oscillation alone, but their &lt;strong>relative phase&lt;/strong>.&lt;/p>
&lt;p>That is why the quantity&lt;/p>
$$
\Phi_i - \Phi_0
$$&lt;p>becomes the natural variable. Once motion changes the frequency of the direction-tuned wave, this relative phase starts to drift. Every time the two waves come back into peak alignment, the interference becomes strong again, and that repeated re-alignment is what sets a spatial firing period.&lt;/p>
&lt;h2 id="phase-drift-relative-to-the-pacemaker">Phase drift relative to the pacemaker&lt;/h2>
&lt;p>The key quantity is not just the absolute phase of a wave, but the phase difference relative to the background pacemaker:&lt;/p>
$$
\Delta \Phi_i(t)=\Phi_i(t)-\Phi_0(t).
$$&lt;p>Its time derivative is&lt;/p>
$$
\frac{d}{dt}\Delta \Phi_i(t)=\omega_i(t)-\omega_0.
$$&lt;p>Therefore,&lt;/p>
$$
\frac{d}{dt}\Delta \Phi_i(t)=\beta\, v(t).
$$&lt;p>So your basic intuition is correct: once movement changes the frequency of wave $i$, the phase difference $\Phi_i-\Phi_0$ is no longer fixed and starts drifting over time.&lt;/p>
&lt;p>If the speed is constant, this becomes&lt;/p>
$$
\Delta \Phi_i(t)=\Delta \Phi_i(0)+\beta vt.
$$&lt;h2 id="re-alignment-first-think-with-constant-speed">Re-alignment: first think with constant speed&lt;/h2>
&lt;p>The next question is: when do the two waves meet again at their peaks?&lt;/p>
&lt;p>The clean way to say it is not “when $\Delta\Phi_i(t)=2\pi$”, because the initial relative phase may not be zero. What matters is that the &lt;strong>change&lt;/strong> in relative phase reaches another full cycle:&lt;/p>
$$
\Delta\Phi_i(t)-\Delta\Phi_i(0)=2\pi.
$$&lt;p>Under the constant-speed assumption,&lt;/p>
$$
\Delta \Phi_i(t)=\Delta \Phi_i(0)+\beta vt,
$$&lt;p>so the re-alignment condition becomes&lt;/p>
$$
\beta vt = 2\pi.
$$&lt;p>From this, the time between successive peak re-alignments is&lt;/p>
$$
T_i = \frac{2\pi}{\beta v}.
$$&lt;p>This is the first way to think about the problem:&lt;/p>
&lt;ol>
&lt;li>Assume $v$ is constant.&lt;/li>
&lt;li>Solve for the time it takes the relative phase to accumulate another $2\pi$.&lt;/li>
&lt;li>Convert that time interval into a traveled distance.&lt;/li>
&lt;/ol>
&lt;p>In one such period, the animal moves&lt;/p>
$$
d=vT_i=v\frac{2\pi}{\beta v}=\frac{2\pi}{\beta}.
$$&lt;p>So under constant speed, the firing spacing is already a constant determined only by $\beta$.&lt;/p>
&lt;h2 id="re-alignment-now-remove-the-constant-speed-assumption">Re-alignment: now remove the constant-speed assumption&lt;/h2>
&lt;p>The more interesting question is whether this result depends on $v$ being constant.&lt;/p>
&lt;p>It turns out that it does not.&lt;/p>
&lt;p>Instead of solving for a fixed period $T_i$, we can directly track accumulated phase drift:&lt;/p>
$$
\frac{d}{dt}\Delta\Phi_i(t)=\beta v(t).
$$&lt;p>Integrating from time $0$ to time $t$ gives&lt;/p>
$$
\Delta\Phi_i(t)-\Delta\Phi_i(0)=\int_0^t \beta v(\tau)\, d\tau.
$$&lt;p>If we ask for the next firing event, we impose the same re-alignment condition:&lt;/p>
$$
\Delta\Phi_i(t)-\Delta\Phi_i(0)=2\pi.
$$&lt;p>So we obtain&lt;/p>
$$
\int_0^t \beta v(\tau)\, d\tau = 2\pi.
$$&lt;p>Pulling $\beta$ out,&lt;/p>
$$
\beta \int_0^t v(\tau)\, d\tau = 2\pi.
$$&lt;p>But&lt;/p>
$$
\int_0^t v(\tau)\, d\tau
$$&lt;p>is exactly the distance traveled along this direction during that interval. Therefore the firing distance satisfies&lt;/p>
$$
d=\int_0^t v(\tau)\, d\tau=\frac{2\pi}{\beta}.
$$&lt;p>This is the more robust formulation:&lt;/p>
&lt;ul>
&lt;li>it does not require constant speed&lt;/li>
&lt;li>it only requires that movement stays along the preferred direction&lt;/li>
&lt;li>it shows directly that the relevant spatial spacing is fixed by $\beta$&lt;/li>
&lt;/ul>
&lt;p>So the realignment story can be understood in two equivalent ways:&lt;/p>
&lt;ol>
&lt;li>Constant-speed view: solve for the re-alignment period first, then multiply by speed.&lt;/li>
&lt;li>Integral view: accumulate phase drift until it reaches $2\pi$, and read off the distance directly.&lt;/li>
&lt;/ol>
&lt;p>The second view is the stronger one, because the conclusion survives even when $v(t)$ changes over time.&lt;/p>
&lt;h2 id="interpretation">Interpretation&lt;/h2>
&lt;p>The logic can now be summarized very compactly:&lt;/p>
&lt;ol>
&lt;li>The background pacemaker oscillates at $\omega_0$.&lt;/li>
&lt;li>Motion along the preferred direction changes the wave frequency to $\omega_0+\beta v$.&lt;/li>
&lt;li>That creates a phase drift rate of $\beta v(t)$ relative to the pacemaker.&lt;/li>
&lt;li>Each time the accumulated phase drift reaches another $2\pi$, the peaks re-align.&lt;/li>
&lt;li>The traveled distance associated with that re-alignment is
$$
d=\frac{2\pi}{\beta}.
$$&lt;/li>
&lt;/ol>
&lt;p>So one wave already gives a stripe-like periodic firing structure with a spacing fixed by $\beta$. The next conceptual step is then to ask how several direction-tuned waves, each with its own preferred direction, can combine to form a 2D grid-like firing pattern.&lt;/p>
&lt;h2 id="interactive-sketch">Interactive sketch&lt;/h2>
&lt;p>The demo for this note is intentionally restricted to the one-wave case. It keeps only one preferred direction and assumes the movement direction is parallel to it, matching the assumptions of the derivation above.&lt;/p>
&lt;div class="gridcell-demo">
&lt;div class="gridcell-demo-intro">
&lt;p class="gridcell-demo-stage-note">
This demo keeps only one wave, and assumes the movement direction is parallel to that wave's preferred direction.
&lt;/p>
&lt;/div>
&lt;div class="gridcell-demo-controls">
&lt;label>
&lt;span>beta&lt;/span>
&lt;input type="range" min="0.06" max="0.28" value="0.14" step="0.005" data-role="beta">
&lt;output data-role="beta-value">0.140&lt;/output>
&lt;/label>
&lt;label>
&lt;span>theta_1&lt;/span>
&lt;input type="range" min="0" max="180" value="0" step="1" data-role="theta1">
&lt;output data-role="theta1-value">0 deg&lt;/output>
&lt;/label>
&lt;/div>
&lt;div class="gridcell-demo-stage gridcell-demo-stage-split">
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Wave&lt;/h4>
&lt;canvas width="520" height="320" data-role="wave-canvas">&lt;/canvas>
&lt;div class="gridcell-demo-caption">
A single oscillatory component along the preferred direction.
&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Grid Cell Firing&lt;/h4>
&lt;canvas width="520" height="520" data-role="firing-canvas">&lt;/canvas>
&lt;div class="gridcell-demo-caption">
With only one wave, the firing pattern is a stripe-like grating rather than a full 2D hexagonal grid.
&lt;/div>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;script>
(() => {
const root = document.currentScript.previousElementSibling;
if (!root || !root.classList.contains('gridcell-demo')) return;
const controls = {
beta: root.querySelector('[data-role="beta"]'),
theta1: root.querySelector('[data-role="theta1"]')
};
const outputs = {
beta: root.querySelector('[data-role="beta-value"]'),
theta1: root.querySelector('[data-role="theta1-value"]')
};
const waveCanvas = root.querySelector('[data-role="wave-canvas"]');
const waveCtx = waveCanvas.getContext('2d');
const firingCanvas = root.querySelector('[data-role="firing-canvas"]');
const firingCtx = firingCanvas.getContext('2d');
const firingImage = firingCtx.createImageData(firingCanvas.width, firingCanvas.height);
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function degToRad(deg) {
return deg * Math.PI / 180;
}
function waveValue(x, y, beta, theta) {
const ux = Math.cos(theta);
const uy = Math.sin(theta);
return Math.cos(beta * (x * ux + y * uy));
}
function firingValue(raw) {
const normalized = clamp01((raw + 1) / 2);
return Math.pow(normalized, 1.9);
}
function colorizeFiring(value) {
const t = clamp01(value);
const r = Math.round(247 - 182 * t);
const g = Math.round(242 - 132 * t);
const b = Math.round(231 - 24 * t);
return [r, g, b];
}
function renderFiringMap(beta, theta) {
const width = firingImage.width;
const height = firingImage.height;
const data = firingImage.data;
const xCenter = width / 2;
const yCenter = height / 2;
let ptr = 0;
for (let py = 0; py &lt; height; py += 1) {
for (let px = 0; px &lt; width; px += 1) {
const x = px - xCenter;
const y = py - yCenter;
const raw = waveValue(x, y, beta, theta);
const value = firingValue(raw);
const [r, g, b] = colorizeFiring(value);
data[ptr] = r;
data[ptr + 1] = g;
data[ptr + 2] = b;
data[ptr + 3] = 255;
ptr += 4;
}
}
firingCtx.putImageData(firingImage, 0, 0);
firingCtx.save();
firingCtx.strokeStyle = 'rgba(15, 23, 42, 0.16)';
firingCtx.lineWidth = 1;
firingCtx.beginPath();
firingCtx.moveTo(width / 2, 0);
firingCtx.lineTo(width / 2, height);
firingCtx.moveTo(0, height / 2);
firingCtx.lineTo(width, height / 2);
firingCtx.stroke();
firingCtx.restore();
}
function drawWavePlot(beta, theta) {
const width = waveCanvas.width;
const height = waveCanvas.height;
const paddingX = 36;
const paddingY = 28;
const plotWidth = width - paddingX * 2;
const plotHeight = height - paddingY * 2;
const baselineY = paddingY + plotHeight / 2;
const xMin = -120;
const xMax = 120;
waveCtx.clearRect(0, 0, width, height);
waveCtx.fillStyle = '#f9f6f0';
waveCtx.fillRect(0, 0, width, height);
waveCtx.strokeStyle = 'rgba(19, 21, 24, 0.12)';
waveCtx.lineWidth = 1;
waveCtx.beginPath();
waveCtx.moveTo(paddingX, baselineY);
waveCtx.lineTo(width - paddingX, baselineY);
waveCtx.moveTo(width / 2, paddingY);
waveCtx.lineTo(width / 2, height - paddingY);
waveCtx.stroke();
waveCtx.fillStyle = 'rgba(19, 21, 24, 0.58)';
waveCtx.font = '12px Instrument Sans, Segoe UI, sans-serif';
waveCtx.fillText('x', width - paddingX + 8, baselineY + 4);
waveCtx.fillText('wave', width / 2 + 8, paddingY - 4);
function projectX(x) {
return paddingX + ((x - xMin) / (xMax - xMin)) * plotWidth;
}
function projectY(v) {
return baselineY - v * (plotHeight * 0.36);
}
waveCtx.save();
waveCtx.strokeStyle = '#153a63';
waveCtx.lineWidth = 3;
waveCtx.beginPath();
for (let i = 0; i &lt;= 240; i += 1) {
const x = xMin + (i / 240) * (xMax - xMin);
const y = waveValue(x, 0, beta, theta);
const px = projectX(x);
const py = projectY(y);
if (i === 0) {
waveCtx.moveTo(px, py);
} else {
waveCtx.lineTo(px, py);
}
}
waveCtx.stroke();
waveCtx.restore();
}
function render() {
const beta = Number(controls.beta.value);
const theta = degToRad(Number(controls.theta1.value));
outputs.beta.textContent = beta.toFixed(3);
outputs.theta1.textContent = `${controls.theta1.value} deg`;
drawWavePlot(beta, theta);
renderFiringMap(beta, theta);
}
Object.values(controls).forEach((input) => {
input.addEventListener('input', render);
});
render();
})();
&lt;/script>
&lt;p>Later, I want to add a more faithful demo: control a moving rat in a 2D scene, update several direction-tuned waves according to velocity-dependent phase drift, and then simulate the resulting grid-cell firing pattern over the trajectory.&lt;/p>
&lt;h2 id="why-this-is-only-a-starting-point">Why this is only a starting point&lt;/h2>
&lt;p>This picture is still incomplete, even in the aligned-direction case.&lt;/p>
&lt;p>Real grid-cell theory quickly brings in questions such as:&lt;/p>
&lt;ul>
&lt;li>How should the pacemaker itself be modeled?&lt;/li>
&lt;li>How exactly do several direction-tuned oscillators interact?&lt;/li>
&lt;li>Why are multiple modules needed?&lt;/li>
&lt;li>How does path integration update phase over time?&lt;/li>
&lt;li>What dynamical mechanism stabilizes the lattice?&lt;/li>
&lt;li>How does a population of grid cells support decoding?&lt;/li>
&lt;/ul>
&lt;p>Those are the questions I want this column to gradually move toward. For now, the point is narrower: make the one-direction phase-drift logic precise before moving into a full spatial simulation.&lt;/p></description></item><item><title>Grid Cell Notes 02: Multiple Waves and Velocity Projection</title><link>https://spidermonk7.github.io/grid-cell-notes/grid-cell-multiwave-projections/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://spidermonk7.github.io/grid-cell-notes/grid-cell-multiwave-projections/</guid><description>&lt;p>In the first note, I only kept the simplest aligned case: one wave, one preferred direction, and motion parallel to that direction. That already gives a clean stripe-like firing periodicity with spacing&lt;/p>
$$
d=\frac{2\pi}{\beta}.
$$&lt;p>Now I want to move one step closer to the full grid-cell picture by introducing multiple waves.&lt;/p>
&lt;h2 id="fixed-movement-direction-multiple-preferred-directions">Fixed movement direction, multiple preferred directions&lt;/h2>
&lt;p>I now assume:&lt;/p>
&lt;ul>
&lt;li>the animal moves at constant speed $v$&lt;/li>
&lt;li>the movement direction is fixed&lt;/li>
&lt;li>wave $i$ has preferred direction $\theta_i$&lt;/li>
&lt;li>$\Phi_0$ is still the background pacemaker phase&lt;/li>
&lt;/ul>
&lt;p>The projection of velocity onto wave $i$ is&lt;/p>
$$
v\cos\theta_i,
$$&lt;p>where $\theta_i$ is the angular difference between the fixed movement direction and the preferred direction of wave $i$.&lt;/p>
&lt;p>So the frequency of wave $i$ becomes&lt;/p>
$$
\omega_i=\omega_0+\beta v\cos\theta_i.
$$&lt;p>The relative phase drift satisfies&lt;/p>
$$
\frac{d}{dt}\Delta\Phi_i=\omega_i-\omega_0=\beta v\cos\theta_i.
$$&lt;p>Therefore, the re-alignment period of wave $i$ is&lt;/p>
$$
T_i=\frac{2\pi}{\beta v\cos\theta_i}.
$$&lt;p>If I care about the distance measured along wave $i$&amp;rsquo;s own preferred direction, then during one re-alignment period the effective displacement is the projected velocity multiplied by the period:&lt;/p>
$$
d_i=(v\cos\theta_i)T_i=\frac{2\pi}{\beta}.
$$&lt;p>So the key point is:&lt;/p>
&lt;ul>
&lt;li>the time between two re-alignments depends on $\cos\theta_i$&lt;/li>
&lt;li>but the distance measured along the preferred direction of that wave is still the same constant $2\pi/\beta$&lt;/li>
&lt;/ul>
&lt;p>The projection factor changes how quickly phase accumulates in time, not the preferred-direction spacing set by $\beta$.&lt;/p>
&lt;h2 id="one-wave">One wave&lt;/h2>
&lt;p>With only one preferred direction, the picture is still the stripe case from Note 01. The wave repeatedly re-aligns with the pacemaker after every projected distance&lt;/p>
$$
\frac{2\pi}{\beta},
$$&lt;p>so the firing pattern is a family of parallel bands orthogonal to that wave vector.&lt;/p>
&lt;h2 id="two-waves">Two waves&lt;/h2>
&lt;p>Now add a second preferred direction. Each wave has its own projected phase drift,&lt;/p>
$$
\frac{d}{dt}\Delta\Phi_1=\beta v\cos\theta_1,\qquad
\frac{d}{dt}\Delta\Phi_2=\beta v\cos\theta_2.
$$&lt;p>Each wave alone would define its own stripe family. When both are present, the firing pattern is strongest where the two stripe families cross. So the geometry is no longer a single banded pattern; it becomes a lattice of pairwise crossings.&lt;/p>
&lt;p>The main role of $\theta_1$ and $\theta_2$ is to control the angle between those two stripe families, and therefore the shape and spacing of the crossing structure.&lt;/p>
&lt;h2 id="three-waves">Three waves&lt;/h2>
&lt;p>Adding a third preferred direction gives a third stripe family. Now the most salient firing locations are the points where all three families come into alignment at once.&lt;/p>
&lt;p>This is the step that moves the picture from simple stripe crossings toward the familiar grid-like arrangement. In the symmetric case, when the preferred directions are separated in a balanced way, the triple intersections become distributed across space in a regular pattern.&lt;/p>
&lt;p>So in this note the logic is:&lt;/p>
&lt;ol>
&lt;li>each wave contributes a stripe family&lt;/li>
&lt;li>each stripe family is controlled by its projected phase drift&lt;/li>
&lt;li>pairwise crossings already create localized structure&lt;/li>
&lt;li>three-wave crossings push that structure toward a grid-cell-like firing pattern&lt;/li>
&lt;/ol>
&lt;h2 id="a-geometric-superposition-picture">A geometric superposition picture&lt;/h2>
&lt;p>A simple geometric way to write the multi-wave field is&lt;/p>
$$
w_i(x)=\cos(k_i^\top x+\phi_i),
$$&lt;p>where the orientation of $k_i$ is tied to $\theta_i$.&lt;/p>
&lt;p>Then the combined field is&lt;/p>
$$
s(x)=\frac{1}{N}\sum_{i=1}^{N} w_i(x),
$$&lt;p>and the firing map is produced through a readout&lt;/p>
$$
g(x)=f(s(x)).
$$&lt;p>This is still a simplified spatial picture, but it is a useful bridge between the oscillatory phase-drift intuition and the 2D firing pattern we want to understand.&lt;/p>
&lt;h2 id="interactive-sketch">Interactive sketch&lt;/h2>
&lt;p>The demo below keeps the model at this geometric-superposition level. You can introduce one wave, then two, then three, while adjusting the key parameters:&lt;/p>
&lt;ul>
&lt;li>$\beta$&lt;/li>
&lt;li>$\theta_1$&lt;/li>
&lt;li>$\theta_2$&lt;/li>
&lt;li>$\theta_3$&lt;/li>
&lt;/ul>
&lt;p>The left panel shows the active waves and their superposition in a 1D slice. The right panel shows each wave&amp;rsquo;s stripe family in its own color, together with highlighted intersections.&lt;/p>
&lt;div class="gridcell-demo">
&lt;div class="gridcell-demo-intro">
&lt;div class="gridcell-demo-tabs" role="tablist" aria-label="Wave introduction stages">
&lt;button type="button" class="gridcell-demo-tab is-active" data-role="stage" data-stage="1">Introduce wave 1&lt;/button>
&lt;button type="button" class="gridcell-demo-tab" data-role="stage" data-stage="2">Introduce wave 2&lt;/button>
&lt;button type="button" class="gridcell-demo-tab" data-role="stage" data-stage="3">Introduce wave 3&lt;/button>
&lt;/div>
&lt;p class="gridcell-demo-stage-note" data-role="stage-note">
With only wave 1 active, the model produces a stripe-like periodic structure set by beta and theta_1.
&lt;/p>
&lt;/div>
&lt;div class="gridcell-demo-controls">
&lt;label>
&lt;span>beta&lt;/span>
&lt;input type="range" min="0.06" max="0.28" value="0.14" step="0.005" data-role="beta">
&lt;output data-role="beta-value">0.140&lt;/output>
&lt;/label>
&lt;label>
&lt;span>wave grids&lt;/span>
&lt;button type="button" class="gridcell-demo-tab" data-role="grid-toggle">Fade grids&lt;/button>
&lt;/label>
&lt;/div>
&lt;div class="gridcell-demo-wave-grid">
&lt;section class="gridcell-demo-wave-card" data-wave-card="1">
&lt;h4>Wave 1&lt;/h4>
&lt;label>
&lt;span>theta_1&lt;/span>
&lt;input type="range" min="0" max="180" value="0" step="1" data-role="theta1">
&lt;output data-role="theta1-value">0 deg&lt;/output>
&lt;/label>
&lt;/section>
&lt;section class="gridcell-demo-wave-card is-muted" data-wave-card="2">
&lt;h4>Wave 2&lt;/h4>
&lt;label>
&lt;span>theta_2&lt;/span>
&lt;input type="range" min="0" max="180" value="60" step="1" data-role="theta2">
&lt;output data-role="theta2-value">60 deg&lt;/output>
&lt;/label>
&lt;/section>
&lt;section class="gridcell-demo-wave-card is-muted" data-wave-card="3">
&lt;h4>Wave 3&lt;/h4>
&lt;label>
&lt;span>theta_3&lt;/span>
&lt;input type="range" min="0" max="180" value="120" step="1" data-role="theta3">
&lt;output data-role="theta3-value">120 deg&lt;/output>
&lt;/label>
&lt;/section>
&lt;/div>
&lt;div class="gridcell-demo-stage gridcell-demo-stage-split">
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Wave&lt;/h4>
&lt;canvas width="520" height="320" data-role="wave-canvas">&lt;/canvas>
&lt;div class="gridcell-demo-caption">
A 1D spatial slice showing the active waves and their superposition under the current beta and theta_i values.
&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Grid Cell Firing&lt;/h4>
&lt;canvas width="520" height="520" data-role="firing-canvas">&lt;/canvas>
&lt;div class="gridcell-demo-caption">
Each active wave contributes one colored stripe family. Bright highlights mark where the currently introduced stripe families intersect.
&lt;/div>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;script>
(() => {
const root = document.currentScript.previousElementSibling;
if (!root || !root.classList.contains('gridcell-demo')) return;
const stageButtons = Array.from(root.querySelectorAll('[data-role="stage"]'));
const stageNote = root.querySelector('[data-role="stage-note"]');
const waveCards = [1, 2, 3].map((index) => root.querySelector(`[data-wave-card="${index}"]`));
const controls = {
beta: root.querySelector('[data-role="beta"]'),
theta1: root.querySelector('[data-role="theta1"]'),
theta2: root.querySelector('[data-role="theta2"]'),
theta3: root.querySelector('[data-role="theta3"]')
};
const gridToggle = root.querySelector('[data-role="grid-toggle"]');
const outputs = {
beta: root.querySelector('[data-role="beta-value"]'),
theta1: root.querySelector('[data-role="theta1-value"]'),
theta2: root.querySelector('[data-role="theta2-value"]'),
theta3: root.querySelector('[data-role="theta3-value"]')
};
const stageNotes = {
1: 'With only wave 1 active, the model produces a stripe-like periodic structure set by beta and theta_1.',
2: 'After adding wave 2, the interference geometry depends on beta together with the angle between theta_1 and theta_2.',
3: 'After adding wave 3, the three preferred directions theta_1, theta_2, and theta_3 can support a grid-like firing layout.'
};
const waveCanvas = root.querySelector('[data-role="wave-canvas"]');
const waveCtx = waveCanvas.getContext('2d');
const firingCanvas = root.querySelector('[data-role="firing-canvas"]');
const firingCtx = firingCanvas.getContext('2d');
const firingImage = firingCtx.createImageData(firingCanvas.width, firingCanvas.height);
let activeStage = 1;
let fadeWaveGrids = false;
const stripeColors = [
[21, 58, 99],
[176, 106, 43],
[47, 106, 79]
];
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function degToRad(deg) {
return deg * Math.PI / 180;
}
function readWaves() {
return [1, 2, 3].map((index) => ({
angle: degToRad(Number(controls[`theta${index}`].value))
}));
}
function waveValue(x, y, beta, wave) {
const ux = Math.cos(wave.angle);
const uy = Math.sin(wave.angle);
return Math.cos(beta * (x * ux + y * uy));
}
function interferenceValue(x, y, beta, waves, count) {
let sum = 0;
for (let i = 0; i &lt; count; i += 1) {
sum += waveValue(x, y, beta, waves[i]);
}
return sum / count;
}
function stripeStrength(raw) {
const threshold = 0.92;
return Math.pow(clamp01((raw - threshold) / (1 - threshold)), 0.8);
}
function renderFiringMap(beta, waves, count) {
const width = firingImage.width;
const height = firingImage.height;
const data = firingImage.data;
const xCenter = width / 2;
const yCenter = height / 2;
let ptr = 0;
for (let py = 0; py &lt; height; py += 1) {
for (let px = 0; px &lt; width; px += 1) {
const x = px - xCenter;
const y = py - yCenter;
let r = 246;
let g = 241;
let b = 232;
const strengths = [];
for (let i = 0; i &lt; count; i += 1) {
const strength = stripeStrength(waveValue(x, y, beta, waves[i]));
strengths.push(strength);
if (strength > 0) {
const stripeOpacity = fadeWaveGrids ? 0.22 : 0.68;
r -= stripeColors[i][0] * strength * stripeOpacity;
g -= stripeColors[i][1] * strength * stripeOpacity;
b -= stripeColors[i][2] * strength * stripeOpacity;
}
}
let intersection = 0;
if (count === 2) {
intersection = Math.min(strengths[0] || 0, strengths[1] || 0);
} else if (count >= 3) {
intersection = Math.min(strengths[0] || 0, strengths[1] || 0, strengths[2] || 0);
}
if (intersection > 0) {
const boost = Math.pow(intersection, 0.8);
r = r * (1 - 0.88 * boost) + 255 * 0.88 * boost;
g = g * (1 - 0.78 * boost) + 246 * 0.78 * boost;
b = b * (1 - 0.35 * boost) + 196 * 0.35 * boost;
}
if (count >= 3 &amp;&amp; intersection > 0.62) {
const marker = Math.pow((intersection - 0.62) / 0.38, 1.05);
r = r * (1 - marker) + 255 * marker;
g = g * (1 - marker) + 236 * marker;
b = b * (1 - marker) + 120 * marker;
}
data[ptr] = Math.round(clamp01(r / 255) * 255);
data[ptr + 1] = Math.round(clamp01(g / 255) * 255);
data[ptr + 2] = Math.round(clamp01(b / 255) * 255);
data[ptr + 3] = 255;
ptr += 4;
}
}
firingCtx.putImageData(firingImage, 0, 0);
firingCtx.save();
firingCtx.strokeStyle = 'rgba(15, 23, 42, 0.07)';
firingCtx.lineWidth = 1;
firingCtx.beginPath();
firingCtx.moveTo(width / 2, 0);
firingCtx.lineTo(width / 2, height);
firingCtx.moveTo(0, height / 2);
firingCtx.lineTo(width, height / 2);
firingCtx.stroke();
firingCtx.strokeStyle = 'rgba(15, 23, 42, 0.04)';
firingCtx.beginPath();
for (let step = 1; step &lt; 4; step += 1) {
const x = (width / 4) * step;
const y = (height / 4) * step;
firingCtx.moveTo(x, 0);
firingCtx.lineTo(x, height);
firingCtx.moveTo(0, y);
firingCtx.lineTo(width, y);
}
firingCtx.stroke();
firingCtx.restore();
}
function drawWavePlot(beta, waves, count) {
const ctx = waveCtx;
const canvas = waveCanvas;
const width = canvas.width;
const height = canvas.height;
const paddingX = 36;
const paddingY = 28;
const plotWidth = width - paddingX * 2;
const plotHeight = height - paddingY * 2;
const baselineY = paddingY + plotHeight / 2;
const xMin = -120;
const xMax = 120;
const waveColors = ['#153a63', '#b06a2b', '#2f6a4f'];
const sumColor = '#8f1d1d';
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f9f6f0';
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(19, 21, 24, 0.12)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(paddingX, baselineY);
ctx.lineTo(width - paddingX, baselineY);
ctx.moveTo(width / 2, paddingY);
ctx.lineTo(width / 2, height - paddingY);
ctx.stroke();
ctx.fillStyle = 'rgba(19, 21, 24, 0.58)';
ctx.font = '12px Instrument Sans, Segoe UI, sans-serif';
ctx.fillText('x', width - paddingX + 8, baselineY + 4);
ctx.fillText('wave', width / 2 + 8, paddingY - 4);
function projectX(x) {
return paddingX + ((x - xMin) / (xMax - xMin)) * plotWidth;
}
function projectY(v) {
return baselineY - v * (plotHeight * 0.36);
}
function drawCurve(sampleFn, color, lineWidth, alpha) {
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.globalAlpha = alpha;
ctx.beginPath();
for (let i = 0; i &lt;= 240; i += 1) {
const x = xMin + (i / 240) * (xMax - xMin);
const y = sampleFn(x);
const px = projectX(x);
const py = projectY(y);
if (i === 0) {
ctx.moveTo(px, py);
} else {
ctx.lineTo(px, py);
}
}
ctx.stroke();
ctx.restore();
}
for (let i = 0; i &lt; count; i += 1) {
drawCurve((x) => waveValue(x, 0, beta, waves[i]), waveColors[i], 2, 0.95);
}
drawCurve((x) => interferenceValue(x, 0, beta, waves, count), sumColor, 3, 1);
const legend = [
...Array.from({ length: count }, (_, index) => ({ label: `wave ${index + 1}`, color: waveColors[index] })),
{ label: 'sum', color: sumColor }
];
let legendX = paddingX;
const legendY = height - 10;
ctx.font = '12px Instrument Sans, Segoe UI, sans-serif';
legend.forEach((item) => {
ctx.fillStyle = item.color;
ctx.fillRect(legendX, legendY - 10, 14, 3);
ctx.fillStyle = 'rgba(19, 21, 24, 0.68)';
ctx.fillText(item.label, legendX + 20, legendY - 6);
legendX += 68;
});
}
function refreshLabels() {
outputs.beta.textContent = Number(controls.beta.value).toFixed(3);
[1, 2, 3].forEach((index) => {
outputs[`theta${index}`].textContent = `${controls[`theta${index}`].value} deg`;
});
}
function refreshStageUI() {
stageButtons.forEach((button) => {
const isActive = Number(button.dataset.stage) === activeStage;
button.classList.toggle('is-active', isActive);
});
waveCards.forEach((card, index) => {
const enabled = index + 1 &lt;= activeStage;
card.classList.toggle('is-muted', !enabled);
Array.from(card.querySelectorAll('input')).forEach((input) => {
input.disabled = !enabled;
});
});
stageNote.textContent = stageNotes[activeStage];
gridToggle.classList.toggle('is-active', fadeWaveGrids);
gridToggle.textContent = fadeWaveGrids ? 'Show grids' : 'Fade grids';
}
function render() {
refreshLabels();
refreshStageUI();
const beta = Number(controls.beta.value);
const waves = readWaves();
drawWavePlot(beta, waves, activeStage);
renderFiringMap(beta, waves, activeStage);
}
stageButtons.forEach((button) => {
button.addEventListener('click', () => {
activeStage = Number(button.dataset.stage);
render();
});
});
gridToggle.addEventListener('click', () => {
fadeWaveGrids = !fadeWaveGrids;
render();
});
Object.values(controls).forEach((input) => {
input.addEventListener('input', render);
});
render();
})();
&lt;/script>
&lt;h2 id="what-this-note-adds">What this note adds&lt;/h2>
&lt;p>Compared with Note 01, the conceptual change is small but important:&lt;/p>
&lt;ol>
&lt;li>Motion is no longer assumed to be aligned with every wave.&lt;/li>
&lt;li>Each wave sees only the projected component $v\cos\theta_i$.&lt;/li>
&lt;li>Different waves therefore drift against the pacemaker at different rates in time.&lt;/li>
&lt;li>Each wave still carries the same preferred-direction spacing $2\pi/\beta$.&lt;/li>
&lt;li>Their combined interference can move from stripes to crossings and eventually to grid-like firing.&lt;/li>
&lt;/ol>
&lt;p>The next step after this note is to stop treating the pattern as static geometry and instead directly simulate phase accumulation while a rat moves through space.&lt;/p></description></item></channel></rss>