diff --git a/_posts/2025-09-29-Separatrix-Locator.md b/_posts/2025-09-29-Separatrix-Locator.md index 38e7e20b..6b82d172 100644 --- a/_posts/2025-09-29-Separatrix-Locator.md +++ b/_posts/2025-09-29-Separatrix-Locator.md @@ -98,6 +98,8 @@ Separatrices! These are boundaries between basins of attraction in dynamical sys **Code**: We provide a Python package implementing this method at [github.com/KabirDabholkar/separatrix_locator](https://github.com/KabirDabholkar/separatrix_locator). +**Interactive version**: if the interactive plots below don't work click [here](https://unireps.github.io/blog/2025/Separatrix-Locator/). + ## Introduction: Fixed Points and Beyond Many natural and artificial systems — from neural circuits making decisions to ecosystems switching between healthy and diseased states — are modelled as **multistable dynamical systems**. Their behaviour is governed by multiple **attractors** in state space, each corresponding to a stable mode of activity. Understanding these systems often boils down to understanding their **geometry**: where are the stable states, and how are the different basins of attraction separated? @@ -106,8 +108,9 @@ For the last decade, a workhorse of neural circuit analysis has been **fixed poi ### Finding Fixed Points First consider a bistable dynamical system in 2 dimensions. Below is a phase-portrait of such a system, with two stable fixed points and one unstable fixed point. Click on plot to realise trajectories of the dynamics. +[(click here if the plot below doesn't load)](https://unireps.github.io/blog/2025/Separatrix-Locator/)
- @@ -119,8 +122,9 @@ Now try to click somewhere that will lead you exactly to the saddle point. Did y This motivates developing a principled way to find such points. One solution is to define a specific scalar function of the dynamics whose only minima are given by all the fixed points. One such function is the kinetic energy $$q(\boldsymbol x)=\frac{1}{2}\Vert f(\boldsymbol x)\Vert^2$$ . By differentiating this function, one can perform gradient descent to find these minima. The interactive plot below realises such trajectories. +[(click here if the plot below doesn't load)](https://unireps.github.io/blog/2025/Separatrix-Locator/)
- @@ -136,8 +140,9 @@ When a system receives a perturbation — for example, a sensory input or an opt Below is an example of such a function that we constructed for this simple system (click on it to run trajectories). +[(click here if the plot below doesn't load)](https://unireps.github.io/blog/2025/Separatrix-Locator/)
- @@ -166,7 +171,7 @@ where $$\dot \square$$ is shorthand for the time derivative $$\frac{d}{dt}\squar Any bistable system can be decomposed as follows: it will have two attractors, their respective basins of attraction and the separatrix between them. This is like a cheese sandwich: the attractors are slices of bread, and the separatrix is the slice of cheese between them. We can call this the **Sandwich of Bistability**. In general, this sandwich could be arbitrarily oriented in $$\mathbb R^N$$ and even nonlinearly warped.
- Sandwich of Bistability + Sandwich of Bistability
The Sandwich of Bistability: Two attractors and their basins of attraction (bread slices) separated by a separatrix (cheese slice). We only care about mapping the coordinates along bistable axis.
@@ -187,7 +192,7 @@ $$ $$
- +
@@ -195,7 +200,7 @@ $$ with $$\lambda>0$$, dropping the $$\boldsymbol x$$ notation for a moment for clarity. This system has fixed point attractors at $$\pm 1$$ and an unstable fixed point (a separatrix) at $$0$$ -- a 1D Sandwich of Bistability.
- Mapping + Mapping
Mapping the high-D state to a 1D bistable system.
@@ -487,7 +492,7 @@ Now that we know what we are looking for (PDE equation), and how to find it (DNN We train a DNN on a bistable damped oscillator, and on a 2D GRU trained on a 1-bit flip-flop task. In both cases, the resulting $$\psi$$ has a zero level set on the separatrix.
- Two 2D Examples + Two 2D Examples
A: ODEs for the damped duffing oscillator. B: Kinetic energy function identifies stable and unstable fixed points. C: DNN approximation of the sKEF and it's level sets. The zero-level set (orange) aligns with the separatrix. D,E,F: Same for a 2D GRU RNN trained on a 1-bit flip flop task.
@@ -496,7 +501,7 @@ We train a DNN on a bistable damped oscillator, and on a 2D GRU trained on a 1-b Finally, we take a published $$N=668$$ unit RNN trained to reproduce the activity of neurones from anterior lateral motor cortex of mice trained to respond to optogenetic stimulation of their somatosensory cortex . By simulating the RNN we can locate the two attractors. The separatrix is an $$(N-1)$$-dimensional manifold in $$\mathbb{R}^N$$. To evaluate our method, we sample this high-D space by drawing random cubic Hermite curves that connect the two attractors (Fig. **A**). We then run many simulations via a binary-search along each curve (parameterised by $$\alpha\in[0,1]$$) to find the true separatrix crossing, and compare with $$\psi=0$$, finding close agreement (Fig. **B**). This also allows us to design optimal perturbations. If we want to change the network's decision, pushing the system towards the desired attractor may not be the most efficient direction. Using $$\psi$$, we design minimal perturbations that cross the separatrix. The resulting perturbation size is smaller than perturbations aimed at the target fixed point or random separatrix locations (Fig. **C**).
- Two 2D Examples + Two 2D Examples
A: Hermite curves connecting attractors of a data-trained RNN (2D projection from 668D) with true separatrix points (red). B: sKEF zeroes versus true separatrix points along each curve. C: Norm of perturbations to reach separatrix from base point $\boldsymbol{x}_\text{base}$.
diff --git a/assets/html/2025-09-29-Separatrix-Locator/absolute_value_gradient_descent.html b/assets/html/2025-09-29-Separatrix/absolute_value_gradient_descent.html similarity index 81% rename from assets/html/2025-09-29-Separatrix-Locator/absolute_value_gradient_descent.html rename to assets/html/2025-09-29-Separatrix/absolute_value_gradient_descent.html index b5d729c8..b48431bc 100644 --- a/assets/html/2025-09-29-Separatrix-Locator/absolute_value_gradient_descent.html +++ b/assets/html/2025-09-29-Separatrix/absolute_value_gradient_descent.html @@ -1,12 +1,13 @@ + - Gradient Descent on Modified Energy Function + Separatrix Locator +
-

Gradient Descent on |x+y|

+

Separatrix Locator

@@ -133,10 +165,9 @@

Gradient Descent on |x+y|

-
- +
Shift+Drag to pan · Double‑click to recenter · Click to draw trajectories
@@ -148,39 +179,39 @@

Gradient Descent on |x+y|

// ======================================================================== // DYNAMICAL SYSTEM DEFINITION // ======================================================================== - + // Energy function function q(x, y) { return Math.abs(x + y); } - + // Gradient descent dynamics with smoothing near singularities const SYSTEM = { f: (x, y) => { const eps = 1e-6; // smoothing parameter for numerical stability const u = x + y; - + // Smooth approximation of sign function near zero // grad(|u|) = sign(u), but we smooth it near u=0 const smoothSign = u / Math.sqrt(u * u + eps * eps); - - return { + + return { dx: -smoothSign, dy: -smoothSign }; }, - + equations: { dx: '−∂f/∂x', dy: '−∂f/∂y' }, - + energy: 'f(x,y) = |x+y|' }; // ======================================================================== // Update title with system equations - document.getElementById('systemDesc').innerHTML = + document.getElementById('systemDesc').innerHTML = 'Click anywhere to trace the trajectory forward in time from that initial condition.'; // ---------- Viewport / transforms ---------- @@ -193,16 +224,17 @@

Gradient Descent on |x+y|

dt: 0.02, nsteps: 4000, eps: 1e-6, gx: 28, gy: 28, trajectories: [], - pan: {active: false, sx:0, sy:0, ox:0, oy:0}, + pan: { active: false, sx: 0, sy: 0, ox: 0, oy: 0 }, + animating: false, }; function rk4Step(x, y, h) { const k1 = SYSTEM.f(x, y); - const k2 = SYSTEM.f(x + 0.5*h*k1.dx, y + 0.5*h*k1.dy); - const k3 = SYSTEM.f(x + 0.5*h*k2.dx, y + 0.5*h*k2.dy); - const k4 = SYSTEM.f(x + h*k3.dx, y + h*k3.dy); - const nx = x + (h/6)*(k1.dx + 2*k2.dx + 2*k3.dx + k4.dx); - const ny = y + (h/6)*(k1.dy + 2*k2.dy + 2*k3.dy + k4.dy); + const k2 = SYSTEM.f(x + 0.5 * h * k1.dx, y + 0.5 * h * k1.dy); + const k3 = SYSTEM.f(x + 0.5 * h * k2.dx, y + 0.5 * h * k2.dy); + const k4 = SYSTEM.f(x + h * k3.dx, y + h * k3.dy); + const nx = x + (h / 6) * (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx); + const ny = y + (h / 6) * (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy); return { x: nx, y: ny }; } @@ -239,7 +271,7 @@

Gradient Descent on |x+y|

const rect = canvas.getBoundingClientRect(); canvas.width = Math.max(400, Math.floor(rect.width * DPR)); canvas.height = Math.max(320, Math.floor(rect.height * DPR)); - ctx.setTransform(1,0,0,1,0,0); + ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(DPR, DPR); draw(); } @@ -263,7 +295,7 @@

Gradient Descent on |x+y|

// ---------- Drawing ---------- function draw() { const rect = canvas.getBoundingClientRect(); - ctx.clearRect(0,0,rect.width,rect.height); + ctx.clearRect(0, 0, rect.width, rect.height); drawContours(); drawGrid(); @@ -277,12 +309,12 @@

Gradient Descent on |x+y|

const resolution = 2; // pixels per sample const cols = Math.ceil(rect.width / resolution); const rows = Math.ceil(rect.height / resolution); - + // Sample energy values let minQ = Infinity; let maxQ = -Infinity; const qValues = new Array(rows); - + for (let j = 0; j < rows; j++) { qValues[j] = new Array(cols); for (let i = 0; i < cols; i++) { @@ -295,25 +327,25 @@

Gradient Descent on |x+y|

maxQ = Math.max(maxQ, qVal); } } - + // Clamp maxQ for better visualization const qRange = maxQ - minQ; maxQ = minQ + qRange * 0.7; // Use lower percentile for better contrast - + // Create image data const imgData = ctx.createImageData(cols, rows); - + for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { const qVal = qValues[j][i]; const normalized = Math.min(1, Math.max(0, (qVal - minQ) / (maxQ - minQ))); - + // Blue colormap: dark blue (low) to light blue/white (high) const t = Math.pow(normalized, 0.5); // Gamma correction for better perception const r = Math.floor(20 + t * 235); const g = Math.floor(40 + t * 215); const b = Math.floor(100 + t * 155); - + const idx = (j * cols + i) * 4; imgData.data[idx] = r; imgData.data[idx + 1] = g; @@ -321,7 +353,7 @@

Gradient Descent on |x+y|

imgData.data[idx + 3] = 180; // Alpha for blend with background } } - + // Draw contours with scaling ctx.save(); ctx.globalAlpha = 0.6; @@ -344,15 +376,15 @@

Gradient Descent on |x+y|

// Grid lines (semi-transparent over contours) const gridColor = getComputedStyle(document.documentElement).getPropertyValue('--grid').trim(); ctx.strokeStyle = gridColor; - const xStep = niceStep(W()/10); - const yStep = niceStep(H()/10); - for (let x = Math.ceil(state.xmin/xStep)*xStep; x <= state.xmax; x += xStep) { + const xStep = niceStep(W() / 10); + const yStep = niceStep(H() / 10); + for (let x = Math.ceil(state.xmin / xStep) * xStep; x <= state.xmax; x += xStep) { const [u] = worldToCanvas(x, 0); ctx.beginPath(); wobbleLine(ctx, u, 0, u, rect.height, 1); ctx.stroke(); } - for (let y = Math.ceil(state.ymin/yStep)*yStep; y <= state.ymax; y += yStep) { + for (let y = Math.ceil(state.ymin / yStep) * yStep; y <= state.ymax; y += yStep) { const [, v] = worldToCanvas(0, y); ctx.beginPath(); wobbleLine(ctx, 0, v, rect.width, v, 1); @@ -379,17 +411,17 @@

Gradient Descent on |x+y|

const inkColor = getComputedStyle(document.documentElement).getPropertyValue('--ink').trim(); ctx.font = "12px 'Comic Neue', 'Comic Sans MS', cursive"; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - for (let x = Math.ceil(state.xmin/xStep)*xStep; x <= state.xmax; x += xStep) { + for (let x = Math.ceil(state.xmin / xStep) * xStep; x <= state.xmax; x += xStep) { const [u, v] = worldToCanvas(x, 0); const label = formatNumber(x); ctx.fillStyle = panelColor; const metrics = ctx.measureText(label); - ctx.fillRect(u - metrics.width/2 - 2, v + 4 - 2, metrics.width + 4, 14); + ctx.fillRect(u - metrics.width / 2 - 2, v + 4 - 2, metrics.width + 4, 14); ctx.fillStyle = inkColor; ctx.fillText(label, u, v + 4); } ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - for (let y = Math.ceil(state.ymin/yStep)*yStep; y <= state.ymax; y += yStep) { + for (let y = Math.ceil(state.ymin / yStep) * yStep; y <= state.ymax; y += yStep) { const [u, v] = worldToCanvas(0, y); const label = formatNumber(y); const metrics = ctx.measureText(label); @@ -422,8 +454,8 @@

Gradient Descent on |x+y|

const u = i * du + du * 0.5; const v = j * dv + dv * 0.5; const [x, y] = canvasToWorld(u, v); - const {dx, dy} = SYSTEM.f(x, y); - + const { dx, dy } = SYSTEM.f(x, y); + // Transform vector from world coordinates to canvas coordinates const canvas_dx = dx * (rect.width / W()); const canvas_dy = -dy * (rect.height / H()); // Note: y is inverted @@ -433,18 +465,18 @@

Gradient Descent on |x+y|

// Draw arrow centered at (u,v) with wobble ctx.beginPath(); - wobbleLine(ctx, u - sx*0.6, v - sy*0.6, u + sx*0.6, v + sy*0.6, 0.8); + wobbleLine(ctx, u - sx * 0.6, v - sy * 0.6, u + sx * 0.6, v + sy * 0.6, 0.8); ctx.stroke(); // Arrowhead with wobble const angle = Math.atan2(sy, sx); const ah = 6; - const tailX = u + sx*0.6, tailY = v + sy*0.6; + const tailX = u + sx * 0.6, tailY = v + sy * 0.6; ctx.beginPath(); - wobbleLine(ctx, tailX, tailY, tailX - ah*Math.cos(angle - Math.PI/6), tailY - ah*Math.sin(angle - Math.PI/6), 0.8); + wobbleLine(ctx, tailX, tailY, tailX - ah * Math.cos(angle - Math.PI / 6), tailY - ah * Math.sin(angle - Math.PI / 6), 0.8); ctx.stroke(); ctx.beginPath(); - wobbleLine(ctx, tailX, tailY, tailX - ah*Math.cos(angle + Math.PI/6), tailY - ah*Math.sin(angle + Math.PI/6), 0.8); + wobbleLine(ctx, tailX, tailY, tailX - ah * Math.cos(angle + Math.PI / 6), tailY - ah * Math.sin(angle + Math.PI / 6), 0.8); ctx.stroke(); } } @@ -453,9 +485,9 @@

Gradient Descent on |x+y|

function drawAnnotations() { ctx.save(); - + const panelColor = getComputedStyle(document.documentElement).getPropertyValue('--panel').trim(); - + // 1. Dashed line along y = -x (separatrix) ctx.setLineDash([8, 4]); ctx.strokeStyle = '#ff6600'; @@ -468,7 +500,7 @@

Gradient Descent on |x+y|

wobbleLine(ctx, u1, v1, u2, v2, 1.5); ctx.stroke(); ctx.setLineDash([]); - + // Label separatrix with background (positioned at top-left) ctx.font = "bold 14px 'Comic Neue', 'Comic Sans MS', cursive"; ctx.textAlign = 'left'; @@ -484,7 +516,7 @@

Gradient Descent on |x+y|

ctx.globalAlpha = 1.0; ctx.fillStyle = '#ff6600'; ctx.fillText(sepLabel, uLabel, vLabel); - + // 2. Stable fixed points at (-1, 0) and (1, 0) ctx.fillStyle = '#00ff88'; ctx.font = "bold 13px 'Comic Neue', 'Comic Sans MS', cursive"; @@ -498,33 +530,33 @@

Gradient Descent on |x+y|

ctx.arc(u, v, 6, 0, 2 * Math.PI); ctx.fill(); ctx.shadowBlur = 0; - + // Label below each point with background (3 lines) ctx.textAlign = 'center'; ctx.textBaseline = 'top'; const offset = 12; const lineHeight = 14; const labels = ['stable', 'fixed', 'point']; - + // Calculate background size const maxWidth = Math.max(...labels.map(l => ctx.measureText(l).width)); const bgWidth = maxWidth + 6; const bgHeight = lineHeight * 3 + 4; const bgX = u - bgWidth / 2; const bgY = v + offset; - + ctx.fillStyle = panelColor; ctx.globalAlpha = 0.9; ctx.fillRect(bgX, bgY - 2, bgWidth, bgHeight); ctx.globalAlpha = 1.0; ctx.fillStyle = '#00ff88'; - + // Draw each line labels.forEach((label, i) => { ctx.fillText(label, u, v + offset + i * lineHeight); }); } - + // 3. Unstable fixed point at (0, 0) - draw a cross const [u0, v0] = worldToCanvas(0, 0); ctx.strokeStyle = '#ff6600'; @@ -539,7 +571,7 @@

Gradient Descent on |x+y|

wobbleLine(ctx, u0 - crossSize, v0 + crossSize, u0 + crossSize, v0 - crossSize, 1); ctx.stroke(); ctx.shadowBlur = 0; - + // Label unstable fixed point with background (positioned below, 3 lines) ctx.fillStyle = '#ff6600'; ctx.font = "bold 13px 'Comic Neue', 'Comic Sans MS', cursive"; @@ -548,25 +580,25 @@

Gradient Descent on |x+y|

const unstableOffset = 12; const unstableLineHeight = 14; const unstableLabels = ['unstable', 'fixed', 'point']; - + // Calculate background size const unstableMaxWidth = Math.max(...unstableLabels.map(l => ctx.measureText(l).width)); const unstableBgWidth = unstableMaxWidth + 6; const unstableBgHeight = unstableLineHeight * 3 + 4; const unstableBgX = u0 - unstableBgWidth / 2; const unstableBgY = v0 + unstableOffset; - + ctx.fillStyle = panelColor; ctx.globalAlpha = 0.9; ctx.fillRect(unstableBgX, unstableBgY - 2, unstableBgWidth, unstableBgHeight); ctx.globalAlpha = 1.0; ctx.fillStyle = '#ff6600'; - + // Draw each line unstableLabels.forEach((label, i) => { ctx.fillText(label, u0, v0 + unstableOffset + i * unstableLineHeight); }); - + ctx.restore(); } @@ -579,14 +611,16 @@

Gradient Descent on |x+y|

ctx.strokeStyle = tr.color; ctx.shadowColor = tr.color; ctx.beginPath(); - + + const limit = Math.floor(tr.visibleCount || tr.pts.length); + // Draw wobbly trajectory - for (let i = 0; i < tr.pts.length; i++) { + for (let i = 0; i < limit; i++) { const [u, v] = worldToCanvas(tr.pts[i].x, tr.pts[i].y); if (i === 0) { ctx.moveTo(u, v); } else { - const [prevU, prevV] = worldToCanvas(tr.pts[i-1].x, tr.pts[i-1].y); + const [prevU, prevV] = worldToCanvas(tr.pts[i - 1].x, tr.pts[i - 1].y); const steps = 2; for (let s = 1; s <= steps; s++) { const t = s / steps; @@ -600,25 +634,25 @@

Gradient Descent on |x+y|

// start marker const [u0, v0] = worldToCanvas(tr.pts[0].x, tr.pts[0].y); - ctx.fillStyle = tr.color; - ctx.beginPath(); - ctx.arc(u0, v0, 4, 0, 2*Math.PI); + ctx.fillStyle = tr.color; + ctx.beginPath(); + ctx.arc(u0, v0, 4, 0, 2 * Math.PI); ctx.fill(); - // flow arrow at halfway point - if (tr.pts.length > 2) { - const midIdx = Math.floor(tr.pts.length / 2); + // flow arrow at halfway point (of the visible segment) + if (limit > 2) { + const midIdx = Math.floor(limit / 2); const prevIdx = Math.max(0, midIdx - 1); const nextIdx = Math.min(tr.pts.length - 1, midIdx + 1); - + const [um, vm] = worldToCanvas(tr.pts[midIdx].x, tr.pts[midIdx].y); const [un, vn] = worldToCanvas(tr.pts[nextIdx].x, tr.pts[nextIdx].y); - + const dx = un - um; const dy = vn - vm; const mag = Math.hypot(dx, dy) || 1e-9; const angle = Math.atan2(dy, dx); - + // Draw arrowhead with wobble ctx.fillStyle = tr.color; ctx.strokeStyle = tr.color; @@ -626,10 +660,10 @@

Gradient Descent on |x+y|

ctx.lineWidth = 2.0; const arrowSize = 12; ctx.beginPath(); - wobbleLine(ctx, um, vm, um - arrowSize * Math.cos(angle - Math.PI/6), vm - arrowSize * Math.sin(angle - Math.PI/6), 0.8); + wobbleLine(ctx, um, vm, um - arrowSize * Math.cos(angle - Math.PI / 6), vm - arrowSize * Math.sin(angle - Math.PI / 6), 0.8); ctx.stroke(); ctx.beginPath(); - wobbleLine(ctx, um, vm, um - arrowSize * Math.cos(angle + Math.PI/6), vm - arrowSize * Math.sin(angle + Math.PI/6), 0.8); + wobbleLine(ctx, um, vm, um - arrowSize * Math.cos(angle + Math.PI / 6), vm - arrowSize * Math.sin(angle + Math.PI / 6), 0.8); ctx.stroke(); } } @@ -645,28 +679,65 @@

Gradient Descent on |x+y|

const dt = state.dt; const n = Math.max(1, Math.floor(state.nsteps)); - const pts = [{x:x0, y:y0}]; + const pts = [{ x: x0, y: y0 }]; // forward in time only let x = x0, y = y0; - for (let i=0; i= tr.pts.length) { + tr.visibleCount = tr.pts.length; + tr.finished = true; + } else { + active = true; + } + } + } + draw(); + + if (active) { + requestAnimationFrame(stepAnimation); + } else { + state.animating = false; + } } - function outOfBounds(x,y) { + function outOfBounds(x, y) { const pad = 0.08; - return x < state.xmin - pad*W() || x > state.xmax + pad*W() || - y < state.ymin - pad*H() || y > state.ymax + pad*H(); + return x < state.xmin - pad * W() || x > state.xmax + pad * W() || + y < state.ymin - pad * H() || y > state.ymax + pad * H(); } // ---------- Interaction ---------- @@ -697,28 +768,23 @@

Gradient Descent on |x+y|

draw(); }); canvas.addEventListener('pointerup', (ev) => { - state.pan.active = false; + state.pan.active = false; canvas.releasePointerCapture(ev.pointerId); }); // Recenter (double click) canvas.addEventListener('dblclick', () => { - state.xmin = -2; state.xmax = 2; state.ymin = -2; state.ymax = 2; + state.xmin = -2; state.xmax = 2; state.ymin = -2; state.ymax = 2; draw(); }); // Button controls - document.getElementById('clearBtn').addEventListener('click', () => { - state.trajectories.length = 0; - draw(); + document.getElementById('clearBtn').addEventListener('click', () => { + state.trajectories.length = 0; + draw(); }); - document.getElementById('downloadBtn').addEventListener('click', () => { - const a = document.createElement('a'); - a.download = 'absolute-value-gradient-descent.png'; - a.href = canvas.toDataURL('image/png'); - a.click(); - }); + // Utilities function niceStep(raw) { @@ -728,9 +794,9 @@

Gradient Descent on |x+y|

const s = snaps.find(v => n <= v) || 10; return s * pow10; } - function formatNumber(x) { - const a = Math.abs(x); - return a >= 1e4 || (a>0 && a < 1e-3) ? x.toExponential(1) : (+x.toFixed(3)).toString(); + function formatNumber(x) { + const a = Math.abs(x); + return a >= 1e4 || (a > 0 && a < 1e-3) ? x.toExponential(1) : (+x.toFixed(3)).toString(); } let colorIdx = 0; @@ -742,17 +808,17 @@

Gradient Descent on |x+y|

'hsl(210 50% 40%)', 'hsl(300 45% 40%)', 'hsl(12 70% 45%)', 'hsl(140 50% 35%)', 'hsl(340 60% 45%)', 'hsl(280 45% 40%)', ]; - function pickColor() { + function pickColor() { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const palette = isDark ? paletteDark : paletteLight; - const c = palette[colorIdx % palette.length]; - colorIdx++; - return c; + const c = palette[colorIdx % palette.length]; + colorIdx++; + return c; } function notify(msg) { const t = document.getElementById('toast'); - t.textContent = msg; + t.textContent = msg; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 1500); } @@ -783,7 +849,7 @@

Gradient Descent on |x+y|

darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; } } - + document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light'); if (canvas.width > 0) { // Only redraw if canvas is initialized draw(); @@ -823,8 +889,5 @@

Gradient Descent on |x+y|

setCanvasSize(); // This will also call draw() - - - - + \ No newline at end of file diff --git a/assets/html/2025-09-29-Separatrix-Locator/bistable_1d_plot.html b/assets/html/2025-09-29-Separatrix/bistable_1d_plot.html similarity index 100% rename from assets/html/2025-09-29-Separatrix-Locator/bistable_1d_plot.html rename to assets/html/2025-09-29-Separatrix/bistable_1d_plot.html diff --git a/assets/html/2025-09-29-Separatrix-Locator/clickable_phase_portrait.html b/assets/html/2025-09-29-Separatrix/clickable_phase_portrait.html similarity index 100% rename from assets/html/2025-09-29-Separatrix-Locator/clickable_phase_portrait.html rename to assets/html/2025-09-29-Separatrix/clickable_phase_portrait.html diff --git a/assets/html/2025-09-29-Separatrix-Locator/clickable_phase_portrait_simple.html b/assets/html/2025-09-29-Separatrix/clickable_phase_portrait_simple.html similarity index 80% rename from assets/html/2025-09-29-Separatrix-Locator/clickable_phase_portrait_simple.html rename to assets/html/2025-09-29-Separatrix/clickable_phase_portrait_simple.html index 6be92f14..5f720df2 100644 --- a/assets/html/2025-09-29-Separatrix-Locator/clickable_phase_portrait_simple.html +++ b/assets/html/2025-09-29-Separatrix/clickable_phase_portrait_simple.html @@ -1,12 +1,13 @@ + - Interactive Phase Portrait + Bistable dynamics +
-

Interactive Phase Portrait

+

Bistable dynamics

@@ -133,10 +165,9 @@

Interactive Phase Portrait

-
- +
Shift+Drag to pan · Double‑click to recenter · Click to draw trajectories
@@ -152,18 +183,18 @@

Interactive Phase Portrait

// The dynamical system: returns {dx, dy} given (x, y) f: (x, y) => { const s = x + y; - return { - dx: x + 2*y - s*s*s, - dy: -y + return { + dx: x + 2 * y - s * s * s, + dy: -y }; }, - + // Description for display equations: { dx: 'x + 2y − (x+y)³', dy: '−y' }, - + // Nullclines: curves where dx=0 and dy=0 nullclines: { // dx/dt = 0: curve where x + 2y = (x+y)³ @@ -182,7 +213,7 @@

Interactive Phase Portrait

// ======================================================================== // Update title with system equations - document.getElementById('systemDesc').innerHTML = + document.getElementById('systemDesc').innerHTML = 'Click anywhere to trace the trajectory forward in time from that initial condition.'; // ---------- Viewport / transforms ---------- @@ -195,16 +226,17 @@

Interactive Phase Portrait

dt: 0.02, nsteps: 4000, eps: 1e-6, gx: 28, gy: 28, trajectories: [], - pan: {active: false, sx:0, sy:0, ox:0, oy:0}, + pan: { active: false, sx: 0, sy: 0, ox: 0, oy: 0 }, + animating: false, }; function rk4Step(x, y, h) { const k1 = SYSTEM.f(x, y); - const k2 = SYSTEM.f(x + 0.5*h*k1.dx, y + 0.5*h*k1.dy); - const k3 = SYSTEM.f(x + 0.5*h*k2.dx, y + 0.5*h*k2.dy); - const k4 = SYSTEM.f(x + h*k3.dx, y + h*k3.dy); - const nx = x + (h/6)*(k1.dx + 2*k2.dx + 2*k3.dx + k4.dx); - const ny = y + (h/6)*(k1.dy + 2*k2.dy + 2*k3.dy + k4.dy); + const k2 = SYSTEM.f(x + 0.5 * h * k1.dx, y + 0.5 * h * k1.dy); + const k3 = SYSTEM.f(x + 0.5 * h * k2.dx, y + 0.5 * h * k2.dy); + const k4 = SYSTEM.f(x + h * k3.dx, y + h * k3.dy); + const nx = x + (h / 6) * (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx); + const ny = y + (h / 6) * (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy); return { x: nx, y: ny }; } @@ -241,7 +273,7 @@

Interactive Phase Portrait

const rect = canvas.getBoundingClientRect(); canvas.width = Math.max(400, Math.floor(rect.width * DPR)); canvas.height = Math.max(320, Math.floor(rect.height * DPR)); - ctx.setTransform(1,0,0,1,0,0); + ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(DPR, DPR); draw(); } @@ -265,7 +297,7 @@

Interactive Phase Portrait

// ---------- Drawing ---------- function draw() { const rect = canvas.getBoundingClientRect(); - ctx.clearRect(0,0,rect.width,rect.height); + ctx.clearRect(0, 0, rect.width, rect.height); drawGrid(); // drawVectorField(); // Disabled @@ -281,19 +313,19 @@

Interactive Phase Portrait

// Background ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--panel').trim(); - ctx.fillRect(0,0,rect.width,rect.height); + ctx.fillRect(0, 0, rect.width, rect.height); // Grid lines ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--grid').trim(); - const xStep = niceStep(W()/10); - const yStep = niceStep(H()/10); - for (let x = Math.ceil(state.xmin/xStep)*xStep; x <= state.xmax; x += xStep) { + const xStep = niceStep(W() / 10); + const yStep = niceStep(H() / 10); + for (let x = Math.ceil(state.xmin / xStep) * xStep; x <= state.xmax; x += xStep) { const [u] = worldToCanvas(x, 0); ctx.beginPath(); wobbleLine(ctx, u, 0, u, rect.height, 1); ctx.stroke(); } - for (let y = Math.ceil(state.ymin/yStep)*yStep; y <= state.ymax; y += yStep) { + for (let y = Math.ceil(state.ymin / yStep) * yStep; y <= state.ymax; y += yStep) { const [, v] = worldToCanvas(0, y); ctx.beginPath(); wobbleLine(ctx, 0, v, rect.width, v, 1); @@ -318,12 +350,12 @@

Interactive Phase Portrait

ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--muted').trim(); ctx.font = "12px 'Comic Neue', 'Comic Sans MS', cursive"; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - for (let x = Math.ceil(state.xmin/xStep)*xStep; x <= state.xmax; x += xStep) { + for (let x = Math.ceil(state.xmin / xStep) * xStep; x <= state.xmax; x += xStep) { const [u, v] = worldToCanvas(x, 0); ctx.fillText(formatNumber(x), u, v + 4); } ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - for (let y = Math.ceil(state.ymin/yStep)*yStep; y <= state.ymax; y += yStep) { + for (let y = Math.ceil(state.ymin / yStep) * yStep; y <= state.ymax; y += yStep) { const [u, v] = worldToCanvas(0, y); ctx.fillText(formatNumber(y), u - 6, v); } @@ -349,8 +381,8 @@

Interactive Phase Portrait

const u = i * du + du * 0.5; const v = j * dv + dv * 0.5; const [x, y] = canvasToWorld(u, v); - const {dx, dy} = SYSTEM.f(x, y); - + const { dx, dy } = SYSTEM.f(x, y); + // Transform vector from world coordinates to canvas coordinates const canvas_dx = dx * (rect.width / W()); const canvas_dy = -dy * (rect.height / H()); // Note: y is inverted @@ -360,18 +392,18 @@

Interactive Phase Portrait

// Draw arrow centered at (u,v) with wobble ctx.beginPath(); - wobbleLine(ctx, u - sx*0.6, v - sy*0.6, u + sx*0.6, v + sy*0.6, 0.8); + wobbleLine(ctx, u - sx * 0.6, v - sy * 0.6, u + sx * 0.6, v + sy * 0.6, 0.8); ctx.stroke(); // Arrowhead with wobble const angle = Math.atan2(sy, sx); const ah = 6; - const tailX = u + sx*0.6, tailY = v + sy*0.6; + const tailX = u + sx * 0.6, tailY = v + sy * 0.6; ctx.beginPath(); - wobbleLine(ctx, tailX, tailY, tailX - ah*Math.cos(angle - Math.PI/6), tailY - ah*Math.sin(angle - Math.PI/6), 0.8); + wobbleLine(ctx, tailX, tailY, tailX - ah * Math.cos(angle - Math.PI / 6), tailY - ah * Math.sin(angle - Math.PI / 6), 0.8); ctx.stroke(); ctx.beginPath(); - wobbleLine(ctx, tailX, tailY, tailX - ah*Math.cos(angle + Math.PI/6), tailY - ah*Math.sin(angle + Math.PI/6), 0.8); + wobbleLine(ctx, tailX, tailY, tailX - ah * Math.cos(angle + Math.PI / 6), tailY - ah * Math.sin(angle + Math.PI / 6), 0.8); ctx.stroke(); } } @@ -380,7 +412,7 @@

Interactive Phase Portrait

function drawNullclines() { ctx.save(); - ctx.setLineDash([6,6]); + ctx.setLineDash([6, 6]); ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--nullcline'); ctx.lineWidth = 1.5; @@ -408,7 +440,7 @@

Interactive Phase Portrait

const ySamples = 100; for (let j = 0; j <= ySamples; j++) { const y = state.ymin + (state.ymax - state.ymin) * j / ySamples; - const {dx} = SYSTEM.f(x, y); + const { dx } = SYSTEM.f(x, y); if (Math.abs(dx) < minDx) { minDx = Math.abs(dx); bestY = y; @@ -444,9 +476,9 @@

Interactive Phase Portrait

function drawAnnotations() { ctx.save(); - + const panelColor = getComputedStyle(document.documentElement).getPropertyValue('--panel').trim(); - + // 1. Dashed line along y = -x (separatrix) ctx.setLineDash([8, 4]); ctx.strokeStyle = '#ff6600'; @@ -459,7 +491,7 @@

Interactive Phase Portrait

wobbleLine(ctx, u1, v1, u2, v2, 1.5); ctx.stroke(); ctx.setLineDash([]); - + // Label separatrix with background (positioned at top-left) ctx.font = 'bold 14px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial'; ctx.textAlign = 'left'; @@ -475,7 +507,7 @@

Interactive Phase Portrait

ctx.globalAlpha = 1.0; ctx.fillStyle = '#ff6600'; ctx.fillText(sepLabel, uLabel, vLabel); - + // 2. Stable fixed points at (-1, 0) and (1, 0) ctx.fillStyle = '#00ff88'; ctx.font = 'bold 13px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial'; @@ -489,33 +521,33 @@

Interactive Phase Portrait

ctx.arc(u, v, 6, 0, 2 * Math.PI); ctx.fill(); ctx.shadowBlur = 0; - + // Label below each point with background (3 lines) ctx.textAlign = 'center'; ctx.textBaseline = 'top'; const offset = 12; const lineHeight = 14; const labels = ['stable', 'fixed', 'point']; - + // Calculate background size const maxWidth = Math.max(...labels.map(l => ctx.measureText(l).width)); const bgWidth = maxWidth + 6; const bgHeight = lineHeight * 3 + 4; const bgX = u - bgWidth / 2; const bgY = v + offset; - + ctx.fillStyle = panelColor; ctx.globalAlpha = 0.9; ctx.fillRect(bgX, bgY - 2, bgWidth, bgHeight); ctx.globalAlpha = 1.0; ctx.fillStyle = '#00ff88'; - + // Draw each line labels.forEach((label, i) => { ctx.fillText(label, u, v + offset + i * lineHeight); }); } - + // 3. Unstable fixed point at (0, 0) - draw a cross const [u0, v0] = worldToCanvas(0, 0); ctx.strokeStyle = '#ff6600'; @@ -530,7 +562,7 @@

Interactive Phase Portrait

wobbleLine(ctx, u0 - crossSize, v0 + crossSize, u0 + crossSize, v0 - crossSize, 1); ctx.stroke(); ctx.shadowBlur = 0; - + // Label unstable fixed point with background (positioned below, 3 lines) ctx.fillStyle = '#ff6600'; ctx.font = "bold 13px 'Comic Neue', 'Comic Sans MS', cursive"; @@ -539,25 +571,25 @@

Interactive Phase Portrait

const unstableOffset = 12; const unstableLineHeight = 14; const unstableLabels = ['unstable', 'fixed', 'point']; - + // Calculate background size const unstableMaxWidth = Math.max(...unstableLabels.map(l => ctx.measureText(l).width)); const unstableBgWidth = unstableMaxWidth + 6; const unstableBgHeight = unstableLineHeight * 3 + 4; const unstableBgX = u0 - unstableBgWidth / 2; const unstableBgY = v0 + unstableOffset; - + ctx.fillStyle = panelColor; ctx.globalAlpha = 0.9; ctx.fillRect(unstableBgX, unstableBgY - 2, unstableBgWidth, unstableBgHeight); ctx.globalAlpha = 1.0; ctx.fillStyle = '#ff6600'; - + // Draw each line unstableLabels.forEach((label, i) => { ctx.fillText(label, u0, v0 + unstableOffset + i * unstableLineHeight); }); - + ctx.restore(); } @@ -568,14 +600,16 @@

Interactive Phase Portrait

for (const tr of state.trajectories) { ctx.strokeStyle = tr.color; ctx.beginPath(); - + + const limit = Math.floor(tr.visibleCount || tr.pts.length); + // Draw wobbly trajectory - for (let i = 0; i < tr.pts.length; i++) { + for (let i = 0; i < limit; i++) { const [u, v] = worldToCanvas(tr.pts[i].x, tr.pts[i].y); if (i === 0) { ctx.moveTo(u, v); } else { - const [prevU, prevV] = worldToCanvas(tr.pts[i-1].x, tr.pts[i-1].y); + const [prevU, prevV] = worldToCanvas(tr.pts[i - 1].x, tr.pts[i - 1].y); const steps = 2; for (let s = 1; s <= steps; s++) { const t = s / steps; @@ -589,35 +623,35 @@

Interactive Phase Portrait

// start marker const [u0, v0] = worldToCanvas(tr.pts[0].x, tr.pts[0].y); - ctx.fillStyle = tr.color; - ctx.beginPath(); - ctx.arc(u0, v0, 3.2, 0, 2*Math.PI); + ctx.fillStyle = tr.color; + ctx.beginPath(); + ctx.arc(u0, v0, 3.2, 0, 2 * Math.PI); ctx.fill(); - // flow arrow at halfway point - if (tr.pts.length > 2) { - const midIdx = Math.floor(tr.pts.length / 2); + // flow arrow at halfway point (of the visible segment) + if (limit > 2) { + const midIdx = Math.floor(limit / 2); const prevIdx = Math.max(0, midIdx - 1); const nextIdx = Math.min(tr.pts.length - 1, midIdx + 1); - + const [um, vm] = worldToCanvas(tr.pts[midIdx].x, tr.pts[midIdx].y); const [un, vn] = worldToCanvas(tr.pts[nextIdx].x, tr.pts[nextIdx].y); - + const dx = un - um; const dy = vn - vm; const mag = Math.hypot(dx, dy) || 1e-9; const angle = Math.atan2(dy, dx); - + // Draw arrowhead with wobble ctx.fillStyle = tr.color; ctx.strokeStyle = tr.color; ctx.lineWidth = 1.8; const arrowSize = 10; ctx.beginPath(); - wobbleLine(ctx, um, vm, um - arrowSize * Math.cos(angle - Math.PI/6), vm - arrowSize * Math.sin(angle - Math.PI/6), 0.8); + wobbleLine(ctx, um, vm, um - arrowSize * Math.cos(angle - Math.PI / 6), vm - arrowSize * Math.sin(angle - Math.PI / 6), 0.8); ctx.stroke(); ctx.beginPath(); - wobbleLine(ctx, um, vm, um - arrowSize * Math.cos(angle + Math.PI/6), vm - arrowSize * Math.sin(angle + Math.PI/6), 0.8); + wobbleLine(ctx, um, vm, um - arrowSize * Math.cos(angle + Math.PI / 6), vm - arrowSize * Math.sin(angle + Math.PI / 6), 0.8); ctx.stroke(); } } @@ -626,7 +660,7 @@

Interactive Phase Portrait

function integrateTrajectory(x0, y0) { // If near an equilibrium, inform and do nothing - const sp0 = Math.hypot(SYSTEM.f(x0,y0).dx, SYSTEM.f(x0,y0).dy); + const sp0 = Math.hypot(SYSTEM.f(x0, y0).dx, SYSTEM.f(x0, y0).dy); if (sp0 < state.eps) { notify('That point is (numerically) stationary. Try a different start.'); return; @@ -634,28 +668,65 @@

Interactive Phase Portrait

const dt = state.dt; const n = Math.max(1, Math.floor(state.nsteps)); - const pts = [{x:x0, y:y0}]; + const pts = [{ x: x0, y: y0 }]; // forward in time only let x = x0, y = y0; - for (let i=0; i= tr.pts.length) { + tr.visibleCount = tr.pts.length; + tr.finished = true; + } else { + active = true; + } + } + } + draw(); + + if (active) { + requestAnimationFrame(stepAnimation); + } else { + state.animating = false; + } } - function outOfBounds(x,y) { + function outOfBounds(x, y) { const pad = 0.08; - return x < state.xmin - pad*W() || x > state.xmax + pad*W() || - y < state.ymin - pad*H() || y > state.ymax + pad*H(); + return x < state.xmin - pad * W() || x > state.xmax + pad * W() || + y < state.ymin - pad * H() || y > state.ymax + pad * H(); } // ---------- Interaction ---------- @@ -686,28 +757,23 @@

Interactive Phase Portrait

draw(); }); canvas.addEventListener('pointerup', (ev) => { - state.pan.active = false; + state.pan.active = false; canvas.releasePointerCapture(ev.pointerId); }); // Recenter (double click) canvas.addEventListener('dblclick', () => { - state.xmin = -2; state.xmax = 2; state.ymin = -2; state.ymax = 2; + state.xmin = -2; state.xmax = 2; state.ymin = -2; state.ymax = 2; draw(); }); // Button controls - document.getElementById('clearBtn').addEventListener('click', () => { - state.trajectories.length = 0; - draw(); + document.getElementById('clearBtn').addEventListener('click', () => { + state.trajectories.length = 0; + draw(); }); - document.getElementById('downloadBtn').addEventListener('click', () => { - const a = document.createElement('a'); - a.download = 'phase-portrait.png'; - a.href = canvas.toDataURL('image/png'); - a.click(); - }); + // Utilities function niceStep(raw) { @@ -717,9 +783,9 @@

Interactive Phase Portrait

const s = snaps.find(v => n <= v) || 10; return s * pow10; } - function formatNumber(x) { - const a = Math.abs(x); - return a >= 1e4 || (a>0 && a < 1e-3) ? x.toExponential(1) : (+x.toFixed(3)).toString(); + function formatNumber(x) { + const a = Math.abs(x); + return a >= 1e4 || (a > 0 && a < 1e-3) ? x.toExponential(1) : (+x.toFixed(3)).toString(); } let colorIdx = 0; @@ -731,17 +797,17 @@

Interactive Phase Portrait

'hsl(210 50% 40%)', 'hsl(190 60% 35%)', 'hsl(140 50% 35%)', 'hsl(300 45% 40%)', 'hsl(12 70% 45%)', 'hsl(220 50% 40%)', 'hsl(0 60% 45%)', ]; - function pickColor() { + function pickColor() { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const palette = isDark ? paletteDark : paletteLight; - const c = palette[colorIdx % palette.length]; - colorIdx++; - return c; + const c = palette[colorIdx % palette.length]; + colorIdx++; + return c; } function notify(msg) { const t = document.getElementById('toast'); - t.textContent = msg; + t.textContent = msg; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 1500); } @@ -772,7 +838,7 @@

Interactive Phase Portrait

darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; } } - + document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light'); if (canvas.width > 0) { // Only redraw if canvas is initialized draw(); @@ -812,4 +878,5 @@

Interactive Phase Portrait

setCanvasSize(); // This will also call draw() - + + \ No newline at end of file diff --git a/assets/html/2025-09-29-Separatrix-Locator/gradient_descent_phase_portrait.html b/assets/html/2025-09-29-Separatrix/gradient_descent_phase_portrait.html similarity index 80% rename from assets/html/2025-09-29-Separatrix-Locator/gradient_descent_phase_portrait.html rename to assets/html/2025-09-29-Separatrix/gradient_descent_phase_portrait.html index bb50cbfe..3b80e1d9 100644 --- a/assets/html/2025-09-29-Separatrix-Locator/gradient_descent_phase_portrait.html +++ b/assets/html/2025-09-29-Separatrix/gradient_descent_phase_portrait.html @@ -1,12 +1,13 @@ + - Gradient Descent Phase Portrait + Fixed Point Finder +
-

Gradient Descent Phase Portrait

+

Fixed Point Finder

@@ -133,10 +165,9 @@

Gradient Descent Phase Portrait

-
- +
Shift+Drag to pan · Double‑click to recenter · Click to draw trajectories
@@ -148,36 +179,36 @@

Gradient Descent Phase Portrait

// ======================================================================== // DYNAMICAL SYSTEM DEFINITION // ======================================================================== - + // Kinetic energy function function q(x, y) { - const term1 = x + 2*y - Math.pow(x + y, 3); + const term1 = x + 2 * y - Math.pow(x + y, 3); return 0.5 * (term1 * term1 + y * y); } - + // Gradient descent dynamics const SYSTEM = { f: (x, y) => { const s = x + y; - const term = x + 2*y - s*s*s; + const term = x + 2 * y - s * s * s; const s2 = s * s; - return { - dx: -term * (1 - 3*s2), - dy: -term * (2 - 3*s2) - y + return { + dx: -term * (1 - 3 * s2), + dy: -term * (2 - 3 * s2) - y }; }, - + equations: { dx: '−(x+2y−(x+y)³)(1−3(x+y)²)', dy: '−(x+2y−(x+y)³)(2−3(x+y)²) − y' }, - + energy: 'q(x,y) = ½[(x+2y−(x+y)³)² + y²]' }; // ======================================================================== // Update title with system equations - document.getElementById('systemDesc').innerHTML = + document.getElementById('systemDesc').innerHTML = 'Click anywhere to trace the trajectory forward in time from that initial condition.'; // ---------- Viewport / transforms ---------- @@ -190,16 +221,17 @@

Gradient Descent Phase Portrait

dt: 0.02, nsteps: 4000, eps: 1e-6, gx: 28, gy: 28, trajectories: [], - pan: {active: false, sx:0, sy:0, ox:0, oy:0}, + pan: { active: false, sx: 0, sy: 0, ox: 0, oy: 0 }, + animating: false, }; function rk4Step(x, y, h) { const k1 = SYSTEM.f(x, y); - const k2 = SYSTEM.f(x + 0.5*h*k1.dx, y + 0.5*h*k1.dy); - const k3 = SYSTEM.f(x + 0.5*h*k2.dx, y + 0.5*h*k2.dy); - const k4 = SYSTEM.f(x + h*k3.dx, y + h*k3.dy); - const nx = x + (h/6)*(k1.dx + 2*k2.dx + 2*k3.dx + k4.dx); - const ny = y + (h/6)*(k1.dy + 2*k2.dy + 2*k3.dy + k4.dy); + const k2 = SYSTEM.f(x + 0.5 * h * k1.dx, y + 0.5 * h * k1.dy); + const k3 = SYSTEM.f(x + 0.5 * h * k2.dx, y + 0.5 * h * k2.dy); + const k4 = SYSTEM.f(x + h * k3.dx, y + h * k3.dy); + const nx = x + (h / 6) * (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx); + const ny = y + (h / 6) * (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy); return { x: nx, y: ny }; } @@ -236,7 +268,7 @@

Gradient Descent Phase Portrait

const rect = canvas.getBoundingClientRect(); canvas.width = Math.max(400, Math.floor(rect.width * DPR)); canvas.height = Math.max(320, Math.floor(rect.height * DPR)); - ctx.setTransform(1,0,0,1,0,0); + ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(DPR, DPR); draw(); } @@ -260,7 +292,7 @@

Gradient Descent Phase Portrait

// ---------- Drawing ---------- function draw() { const rect = canvas.getBoundingClientRect(); - ctx.clearRect(0,0,rect.width,rect.height); + ctx.clearRect(0, 0, rect.width, rect.height); drawContours(); drawGrid(); @@ -274,12 +306,12 @@

Gradient Descent Phase Portrait

const resolution = 2; // pixels per sample const cols = Math.ceil(rect.width / resolution); const rows = Math.ceil(rect.height / resolution); - + // Sample energy values let minQ = Infinity; let maxQ = -Infinity; const qValues = new Array(rows); - + for (let j = 0; j < rows; j++) { qValues[j] = new Array(cols); for (let i = 0; i < cols; i++) { @@ -295,25 +327,25 @@

Gradient Descent Phase Portrait

maxQ = Math.max(maxQ, qVal); } } - + // Clamp maxQ for better visualization const qRange = maxQ - minQ; maxQ = minQ + qRange * 0.8; // Use lower percentile for better contrast - + // Create image data const imgData = ctx.createImageData(cols, rows); - + for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { const qVal = qValues[j][i]; const normalized = Math.min(1, Math.max(0, (qVal - minQ) / (maxQ - minQ))); - + // Blue colormap: dark blue (low) to light blue/white (high) const t = Math.pow(normalized, 0.7); // Gamma correction for better perception const r = Math.floor(20 + t * 235); const g = Math.floor(40 + t * 215); const b = Math.floor(100 + t * 155); - + const idx = (j * cols + i) * 4; imgData.data[idx] = r; imgData.data[idx + 1] = g; @@ -321,7 +353,7 @@

Gradient Descent Phase Portrait

imgData.data[idx + 3] = 180; // Alpha for blend with background } } - + // Draw contours with scaling ctx.save(); ctx.globalAlpha = 0.6; @@ -344,15 +376,15 @@

Gradient Descent Phase Portrait

// Grid lines (semi-transparent over contours) const gridColor = getComputedStyle(document.documentElement).getPropertyValue('--grid').trim(); ctx.strokeStyle = gridColor; - const xStep = niceStep(W()/10); - const yStep = niceStep(H()/10); - for (let x = Math.ceil(state.xmin/xStep)*xStep; x <= state.xmax; x += xStep) { + const xStep = niceStep(W() / 10); + const yStep = niceStep(H() / 10); + for (let x = Math.ceil(state.xmin / xStep) * xStep; x <= state.xmax; x += xStep) { const [u] = worldToCanvas(x, 0); ctx.beginPath(); wobbleLine(ctx, u, 0, u, rect.height, 1); ctx.stroke(); } - for (let y = Math.ceil(state.ymin/yStep)*yStep; y <= state.ymax; y += yStep) { + for (let y = Math.ceil(state.ymin / yStep) * yStep; y <= state.ymax; y += yStep) { const [, v] = worldToCanvas(0, y); ctx.beginPath(); wobbleLine(ctx, 0, v, rect.width, v, 1); @@ -379,17 +411,17 @@

Gradient Descent Phase Portrait

const inkColor = getComputedStyle(document.documentElement).getPropertyValue('--ink').trim(); ctx.font = "12px 'Comic Neue', 'Comic Sans MS', cursive"; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - for (let x = Math.ceil(state.xmin/xStep)*xStep; x <= state.xmax; x += xStep) { + for (let x = Math.ceil(state.xmin / xStep) * xStep; x <= state.xmax; x += xStep) { const [u, v] = worldToCanvas(x, 0); const label = formatNumber(x); ctx.fillStyle = panelColor; const metrics = ctx.measureText(label); - ctx.fillRect(u - metrics.width/2 - 2, v + 4 - 2, metrics.width + 4, 14); + ctx.fillRect(u - metrics.width / 2 - 2, v + 4 - 2, metrics.width + 4, 14); ctx.fillStyle = inkColor; ctx.fillText(label, u, v + 4); } ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - for (let y = Math.ceil(state.ymin/yStep)*yStep; y <= state.ymax; y += yStep) { + for (let y = Math.ceil(state.ymin / yStep) * yStep; y <= state.ymax; y += yStep) { const [u, v] = worldToCanvas(0, y); const label = formatNumber(y); const metrics = ctx.measureText(label); @@ -422,8 +454,8 @@

Gradient Descent Phase Portrait

const u = i * du + du * 0.5; const v = j * dv + dv * 0.5; const [x, y] = canvasToWorld(u, v); - const {dx, dy} = SYSTEM.f(x, y); - + const { dx, dy } = SYSTEM.f(x, y); + // Transform vector from world coordinates to canvas coordinates const canvas_dx = dx * (rect.width / W()); const canvas_dy = -dy * (rect.height / H()); // Note: y is inverted @@ -433,18 +465,18 @@

Gradient Descent Phase Portrait

// Draw arrow centered at (u,v) with wobble ctx.beginPath(); - wobbleLine(ctx, u - sx*0.6, v - sy*0.6, u + sx*0.6, v + sy*0.6, 0.8); + wobbleLine(ctx, u - sx * 0.6, v - sy * 0.6, u + sx * 0.6, v + sy * 0.6, 0.8); ctx.stroke(); // Arrowhead with wobble const angle = Math.atan2(sy, sx); const ah = 6; - const tailX = u + sx*0.6, tailY = v + sy*0.6; + const tailX = u + sx * 0.6, tailY = v + sy * 0.6; ctx.beginPath(); - wobbleLine(ctx, tailX, tailY, tailX - ah*Math.cos(angle - Math.PI/6), tailY - ah*Math.sin(angle - Math.PI/6), 0.8); + wobbleLine(ctx, tailX, tailY, tailX - ah * Math.cos(angle - Math.PI / 6), tailY - ah * Math.sin(angle - Math.PI / 6), 0.8); ctx.stroke(); ctx.beginPath(); - wobbleLine(ctx, tailX, tailY, tailX - ah*Math.cos(angle + Math.PI/6), tailY - ah*Math.sin(angle + Math.PI/6), 0.8); + wobbleLine(ctx, tailX, tailY, tailX - ah * Math.cos(angle + Math.PI / 6), tailY - ah * Math.sin(angle + Math.PI / 6), 0.8); ctx.stroke(); } } @@ -453,9 +485,9 @@

Gradient Descent Phase Portrait

function drawAnnotations() { ctx.save(); - + const panelColor = getComputedStyle(document.documentElement).getPropertyValue('--panel').trim(); - + // 1. Dashed line along y = -x (separatrix) ctx.setLineDash([8, 4]); ctx.strokeStyle = '#ff6600'; @@ -468,7 +500,7 @@

Gradient Descent Phase Portrait

wobbleLine(ctx, u1, v1, u2, v2, 1.5); ctx.stroke(); ctx.setLineDash([]); - + // Label separatrix with background (positioned at top-left) ctx.font = "bold 14px 'Comic Neue', 'Comic Sans MS', cursive"; ctx.textAlign = 'left'; @@ -484,7 +516,7 @@

Gradient Descent Phase Portrait

ctx.globalAlpha = 1.0; ctx.fillStyle = '#ff6600'; ctx.fillText(sepLabel, uLabel, vLabel); - + // 2. Stable fixed points at (-1, 0) and (1, 0) ctx.fillStyle = '#00ff88'; ctx.font = "bold 13px 'Comic Neue', 'Comic Sans MS', cursive"; @@ -498,33 +530,33 @@

Gradient Descent Phase Portrait

ctx.arc(u, v, 6, 0, 2 * Math.PI); ctx.fill(); ctx.shadowBlur = 0; - + // Label below each point with background (3 lines) ctx.textAlign = 'center'; ctx.textBaseline = 'top'; const offset = 12; const lineHeight = 14; const labels = ['stable', 'fixed', 'point']; - + // Calculate background size const maxWidth = Math.max(...labels.map(l => ctx.measureText(l).width)); const bgWidth = maxWidth + 6; const bgHeight = lineHeight * 3 + 4; const bgX = u - bgWidth / 2; const bgY = v + offset; - + ctx.fillStyle = panelColor; ctx.globalAlpha = 0.9; ctx.fillRect(bgX, bgY - 2, bgWidth, bgHeight); ctx.globalAlpha = 1.0; ctx.fillStyle = '#00ff88'; - + // Draw each line labels.forEach((label, i) => { ctx.fillText(label, u, v + offset + i * lineHeight); }); } - + // 3. Unstable fixed point at (0, 0) - draw a cross const [u0, v0] = worldToCanvas(0, 0); ctx.strokeStyle = '#ff6600'; @@ -539,7 +571,7 @@

Gradient Descent Phase Portrait

wobbleLine(ctx, u0 - crossSize, v0 + crossSize, u0 + crossSize, v0 - crossSize, 1); ctx.stroke(); ctx.shadowBlur = 0; - + // Label unstable fixed point with background (positioned below, 3 lines) ctx.fillStyle = '#ff6600'; ctx.font = "bold 13px 'Comic Neue', 'Comic Sans MS', cursive"; @@ -548,25 +580,25 @@

Gradient Descent Phase Portrait

const unstableOffset = 12; const unstableLineHeight = 14; const unstableLabels = ['unstable', 'fixed', 'point']; - + // Calculate background size const unstableMaxWidth = Math.max(...unstableLabels.map(l => ctx.measureText(l).width)); const unstableBgWidth = unstableMaxWidth + 6; const unstableBgHeight = unstableLineHeight * 3 + 4; const unstableBgX = u0 - unstableBgWidth / 2; const unstableBgY = v0 + unstableOffset; - + ctx.fillStyle = panelColor; ctx.globalAlpha = 0.9; ctx.fillRect(unstableBgX, unstableBgY - 2, unstableBgWidth, unstableBgHeight); ctx.globalAlpha = 1.0; ctx.fillStyle = '#ff6600'; - + // Draw each line unstableLabels.forEach((label, i) => { ctx.fillText(label, u0, v0 + unstableOffset + i * unstableLineHeight); }); - + ctx.restore(); } @@ -579,14 +611,16 @@

Gradient Descent Phase Portrait

ctx.strokeStyle = tr.color; ctx.shadowColor = tr.color; ctx.beginPath(); - + + const limit = Math.floor(tr.visibleCount || tr.pts.length); + // Draw wobbly trajectory - for (let i = 0; i < tr.pts.length; i++) { + for (let i = 0; i < limit; i++) { const [u, v] = worldToCanvas(tr.pts[i].x, tr.pts[i].y); if (i === 0) { ctx.moveTo(u, v); } else { - const [prevU, prevV] = worldToCanvas(tr.pts[i-1].x, tr.pts[i-1].y); + const [prevU, prevV] = worldToCanvas(tr.pts[i - 1].x, tr.pts[i - 1].y); const steps = 2; for (let s = 1; s <= steps; s++) { const t = s / steps; @@ -600,25 +634,25 @@

Gradient Descent Phase Portrait

// start marker const [u0, v0] = worldToCanvas(tr.pts[0].x, tr.pts[0].y); - ctx.fillStyle = tr.color; - ctx.beginPath(); - ctx.arc(u0, v0, 4, 0, 2*Math.PI); + ctx.fillStyle = tr.color; + ctx.beginPath(); + ctx.arc(u0, v0, 4, 0, 2 * Math.PI); ctx.fill(); - // flow arrow at halfway point - if (tr.pts.length > 2) { - const midIdx = Math.floor(tr.pts.length / 2); + // flow arrow at halfway point (of the visible segment) + if (limit > 2) { + const midIdx = Math.floor(limit / 2); const prevIdx = Math.max(0, midIdx - 1); const nextIdx = Math.min(tr.pts.length - 1, midIdx + 1); - + const [um, vm] = worldToCanvas(tr.pts[midIdx].x, tr.pts[midIdx].y); const [un, vn] = worldToCanvas(tr.pts[nextIdx].x, tr.pts[nextIdx].y); - + const dx = un - um; const dy = vn - vm; const mag = Math.hypot(dx, dy) || 1e-9; const angle = Math.atan2(dy, dx); - + // Draw arrowhead with wobble ctx.fillStyle = tr.color; ctx.strokeStyle = tr.color; @@ -626,10 +660,10 @@

Gradient Descent Phase Portrait

ctx.lineWidth = 2.0; const arrowSize = 12; ctx.beginPath(); - wobbleLine(ctx, um, vm, um - arrowSize * Math.cos(angle - Math.PI/6), vm - arrowSize * Math.sin(angle - Math.PI/6), 0.8); + wobbleLine(ctx, um, vm, um - arrowSize * Math.cos(angle - Math.PI / 6), vm - arrowSize * Math.sin(angle - Math.PI / 6), 0.8); ctx.stroke(); ctx.beginPath(); - wobbleLine(ctx, um, vm, um - arrowSize * Math.cos(angle + Math.PI/6), vm - arrowSize * Math.sin(angle + Math.PI/6), 0.8); + wobbleLine(ctx, um, vm, um - arrowSize * Math.cos(angle + Math.PI / 6), vm - arrowSize * Math.sin(angle + Math.PI / 6), 0.8); ctx.stroke(); } } @@ -638,7 +672,7 @@

Gradient Descent Phase Portrait

function integrateTrajectory(x0, y0) { // If near an equilibrium, inform and do nothing - const sp0 = Math.hypot(SYSTEM.f(x0,y0).dx, SYSTEM.f(x0,y0).dy); + const sp0 = Math.hypot(SYSTEM.f(x0, y0).dx, SYSTEM.f(x0, y0).dy); if (sp0 < state.eps) { notify('That point is (numerically) stationary. Try a different start.'); return; @@ -646,28 +680,65 @@

Gradient Descent Phase Portrait

const dt = state.dt; const n = Math.max(1, Math.floor(state.nsteps)); - const pts = [{x:x0, y:y0}]; + const pts = [{ x: x0, y: y0 }]; // forward in time only let x = x0, y = y0; - for (let i=0; i= tr.pts.length) { + tr.visibleCount = tr.pts.length; + tr.finished = true; + } else { + active = true; + } + } + } + draw(); + + if (active) { + requestAnimationFrame(stepAnimation); + } else { + state.animating = false; + } } - function outOfBounds(x,y) { + function outOfBounds(x, y) { const pad = 0.08; - return x < state.xmin - pad*W() || x > state.xmax + pad*W() || - y < state.ymin - pad*H() || y > state.ymax + pad*H(); + return x < state.xmin - pad * W() || x > state.xmax + pad * W() || + y < state.ymin - pad * H() || y > state.ymax + pad * H(); } // ---------- Interaction ---------- @@ -698,28 +769,23 @@

Gradient Descent Phase Portrait

draw(); }); canvas.addEventListener('pointerup', (ev) => { - state.pan.active = false; + state.pan.active = false; canvas.releasePointerCapture(ev.pointerId); }); // Recenter (double click) canvas.addEventListener('dblclick', () => { - state.xmin = -2; state.xmax = 2; state.ymin = -2; state.ymax = 2; + state.xmin = -2; state.xmax = 2; state.ymin = -2; state.ymax = 2; draw(); }); // Button controls - document.getElementById('clearBtn').addEventListener('click', () => { - state.trajectories.length = 0; - draw(); + document.getElementById('clearBtn').addEventListener('click', () => { + state.trajectories.length = 0; + draw(); }); - document.getElementById('downloadBtn').addEventListener('click', () => { - const a = document.createElement('a'); - a.download = 'gradient-descent-phase-portrait.png'; - a.href = canvas.toDataURL('image/png'); - a.click(); - }); + // Utilities function niceStep(raw) { @@ -729,9 +795,9 @@

Gradient Descent Phase Portrait

const s = snaps.find(v => n <= v) || 10; return s * pow10; } - function formatNumber(x) { - const a = Math.abs(x); - return a >= 1e4 || (a>0 && a < 1e-3) ? x.toExponential(1) : (+x.toFixed(3)).toString(); + function formatNumber(x) { + const a = Math.abs(x); + return a >= 1e4 || (a > 0 && a < 1e-3) ? x.toExponential(1) : (+x.toFixed(3)).toString(); } let colorIdx = 0; @@ -743,17 +809,17 @@

Gradient Descent Phase Portrait

'hsl(210 50% 40%)', 'hsl(300 45% 40%)', 'hsl(12 70% 45%)', 'hsl(140 50% 35%)', 'hsl(340 60% 45%)', 'hsl(280 45% 40%)', ]; - function pickColor() { + function pickColor() { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const palette = isDark ? paletteDark : paletteLight; - const c = palette[colorIdx % palette.length]; - colorIdx++; - return c; + const c = palette[colorIdx % palette.length]; + colorIdx++; + return c; } function notify(msg) { const t = document.getElementById('toast'); - t.textContent = msg; + t.textContent = msg; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 1500); } @@ -784,7 +850,7 @@

Gradient Descent Phase Portrait

darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; } } - + document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light'); if (canvas.width > 0) { // Only redraw if canvas is initialized draw(); @@ -824,8 +890,5 @@

Gradient Descent Phase Portrait

setCanvasSize(); // This will also call draw() - - - - + \ No newline at end of file diff --git a/assets/html/2025-09-29-Separatrix-Locator/phase_portrait_minimal.html b/assets/html/2025-09-29-Separatrix/phase_portrait_minimal.html similarity index 100% rename from assets/html/2025-09-29-Separatrix-Locator/phase_portrait_minimal.html rename to assets/html/2025-09-29-Separatrix/phase_portrait_minimal.html diff --git a/assets/img/2025-09-29-Separatrix-Locator/finkelstein_blog.png b/assets/img/2025-09-29-Separatrix/finkelstein_blog.png similarity index 100% rename from assets/img/2025-09-29-Separatrix-Locator/finkelstein_blog.png rename to assets/img/2025-09-29-Separatrix/finkelstein_blog.png diff --git a/assets/img/2025-09-29-Separatrix-Locator/mapping_illustration_horizontal.png b/assets/img/2025-09-29-Separatrix/mapping_illustration_horizontal.png similarity index 100% rename from assets/img/2025-09-29-Separatrix-Locator/mapping_illustration_horizontal.png rename to assets/img/2025-09-29-Separatrix/mapping_illustration_horizontal.png diff --git a/assets/img/2025-09-29-Separatrix-Locator/sandwich_of_bistability.png b/assets/img/2025-09-29-Separatrix/sandwich_of_bistability.png similarity index 100% rename from assets/img/2025-09-29-Separatrix-Locator/sandwich_of_bistability.png rename to assets/img/2025-09-29-Separatrix/sandwich_of_bistability.png diff --git a/assets/img/2025-09-29-Separatrix-Locator/two_2D_examples_squashed.png b/assets/img/2025-09-29-Separatrix/two_2D_examples_squashed.png similarity index 100% rename from assets/img/2025-09-29-Separatrix-Locator/two_2D_examples_squashed.png rename to assets/img/2025-09-29-Separatrix/two_2D_examples_squashed.png