<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Interactive Demo | Shaoyang Cui</title><link>https://spidermonk7.github.io/tags/interactive-demo/</link><atom:link href="https://spidermonk7.github.io/tags/interactive-demo/index.xml" rel="self" type="application/rss+xml"/><description>Interactive Demo</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>Interactive Demo</title><link>https://spidermonk7.github.io/tags/interactive-demo/</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 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 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></channel></rss>