Below is a 1-D grid of 40 cells. The left half starts dense (density = 1.0) and the right half is light (density = 0.2) — a classic shock initial condition. Each Step advances time by one tick using the Lax-Friedrichs flux formula, the simplest conservative FVM scheme. Watch the interface smear and propagate rightward; the total mass reported below the chart never changes by even a rounding error.
<!-- FVM shock demo — 1-D Lax-Friedrichs finite volume scheme -->
<div class="demo-wrap">
<div class="info-row">
<span class="label">Total mass:</span>
<span id="mass-display" class="value">—</span>
<span class="label step-label">Step:</span>
<span id="step-display" class="value">0</span>
</div>
<canvas id="chart" width="560" height="200"></canvas>
<div class="status-row" id="status-row">Ready — press Step or Run.</div>
<div class="btns">
<button id="btn-step" type="button">Step</button>
<button id="btn-run" type="button">Run</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<p class="hint">Each bar is one finite-volume cell. The Lax-Friedrichs scheme moves mass from cell to cell via face fluxes, so the total (shown above) never changes — even as the shock smears across the grid.</p>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.demo-wrap { display: flex; flex-direction: column; gap: .5rem; }
.info-row { display: flex; align-items: center; gap: .4rem; font-size: .9rem; }
.label { color: #555; }
.value { font-weight: 700; color: #1d3557; font-variant-numeric: tabular-nums; min-width: 3ch; }
.step-label { margin-left: .8rem; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; width: 100%; max-width: 560px; background: #f8fafc; }
.status-row { font-size: .95rem; font-weight: 600; min-height: 1.4em; color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; transition: opacity .15s; }
button:disabled { opacity: .45; cursor: default; }
button.ghost { background: #fff; color: #1d3557; }
.hint { font-size: .82rem; color: #555; margin: 0; line-height: 1.5; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Finite Volume Method demo: 1-D advection / shock-tube.
// Each step applies the Lax-Friedrichs conservative flux formula.
// Total mass = sum(u[i]*dx) stays exactly constant (to float64 rounding).
const N = 40; // number of finite-volume cells
const DX = 1.0 / N; // cell width (domain = [0,1])
const DT = 0.4 * DX; // time step chosen for stability (CFL ~ 0.4)
const MAX_STEPS = 120; // run for at most this many steps
// Initial condition: left half dense, right half light (shock tube).
function makeInitial() {
const u = new Float64Array(N);
for (let i = 0; i < N; i++) u[i] = i < N / 2 ? 1.0 : 0.2;
return u;
}
// Lax-Friedrichs flux: F = 0.5*(uL+uR) - 0.5*(dx/dt)*(uR-uL).
// F_{i+1/2} = 0.5*(u[i] + u[i+1]) - 0.5*(DX/DT)*(u[i+1] - u[i])
function laxFriedrichs(u) {
const un = new Float64Array(N);
// Outflow (copy) boundary conditions at both ends.
const F = new Float64Array(N + 1);
F[0] = 0;
F[N] = 0;
for (let i = 0; i < N - 1; i++) {
F[i + 1] = 0.5 * (u[i] + u[i + 1]) - 0.5 * (DX / DT) * (u[i + 1] - u[i]);
}
for (let i = 0; i < N; i++) {
un[i] = u[i] - (DT / DX) * (F[i + 1] - F[i]);
}
return un;
}
// Compute total mass = integral of density over [0,1].
function totalMass(u) {
let s = 0;
for (let i = 0; i < N; i++) s += u[i] * DX;
return s;
}
let state = makeInitial();
let step = 0;
let animId = null;
const initialMass = totalMass(makeInitial());
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const massEl = document.getElementById('mass-display');
const stepEl = document.getElementById('step-display');
const statusEl = document.getElementById('status-row');
const btnStep = document.getElementById('btn-step');
const btnRun = document.getElementById('btn-run');
const btnReset = document.getElementById('btn-reset');
function draw(u) {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// Background shading for initial-condition regions
ctx.fillStyle = '#e8eef3';
ctx.fillRect(0, H - 24, W / 2, 24);
ctx.fillStyle = '#cfd8e3';
ctx.fillRect(W / 2, H - 24, W / 2, 24);
// Draw density bars for each cell
const barW = W / N;
for (let i = 0; i < N; i++) {
const v = Math.max(0, Math.min(1.5, u[i]));
const bH = Math.round((v / 1.5) * (H - 28));
ctx.fillStyle = `hsl(${210 + v * 40}, 55%, ${65 - v * 20}%)`;
ctx.fillRect(Math.round(i * barW), H - 24 - bH, Math.ceil(barW) - 1, bH);
}
// Horizontal axis line
ctx.strokeStyle = '#aab4be';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, H - 24); ctx.lineTo(W, H - 24); ctx.stroke();
// Density labels for the initial regions
ctx.fillStyle = '#666';
ctx.font = '11px system-ui, sans-serif';
ctx.fillText('ρ = 1.0', 4, H - 28);
ctx.fillText('ρ = 0.2', W - 46, H - 28);
}
function updateUI() {
const m = totalMass(state);
massEl.textContent = m.toFixed(5);
stepEl.textContent = step;
draw(state);
const err = Math.abs(m - initialMass);
if (step >= MAX_STEPS) {
stopRun();
statusEl.textContent = i18nFmt('Finished after %n% steps. Mass conserved.', { n: step });
} else if (step > 0) {
statusEl.textContent = i18nFmt('Running — mass error: %err%', { err: err.toExponential(1) });
} else {
statusEl.textContent = 'Ready — press Step or Run.';
}
}
function doStep() {
if (step >= MAX_STEPS) return;
state = laxFriedrichs(state);
step++;
updateUI();
}
function startRun() {
if (animId) return;
btnRun.textContent = 'Pause';
btnStep.disabled = true;
function tick() {
if (step >= MAX_STEPS) { stopRun(); return; }
doStep();
animId = requestAnimationFrame(tick);
}
animId = requestAnimationFrame(tick);
}
function stopRun() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
btnRun.textContent = 'Run';
btnStep.disabled = false;
}
function reset() {
stopRun();
state = makeInitial();
step = 0;
statusEl.textContent = 'Ready — press Step or Run.';
updateUI();
}
btnStep.addEventListener('click', doStep);
btnRun.addEventListener('click', () => { animId ? stopRun() : startRun(); });
btnReset.addEventListener('click', reset);
updateUI();
<!-- FVM shock demo — 1-D Lax-Friedrichs finite volume scheme -->
<div class="demo-wrap">
<div class="info-row">
<span class="label">Total mass:</span>
<span id="mass-display" class="value">—</span>
<span class="label step-label">Step:</span>
<span id="step-display" class="value">0</span>
</div>
<canvas id="chart" width="560" height="200"></canvas>
<div class="status-row" id="status-row">Ready — press Step or Run.</div>
<div class="btns">
<button id="btn-step" type="button">Step</button>
<button id="btn-run" type="button">Run</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<p class="hint">Each bar is one finite-volume cell. The Lax-Friedrichs scheme moves mass from cell to cell via face fluxes, so the total (shown above) never changes — even as the shock smears across the grid.</p>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.demo-wrap { display: flex; flex-direction: column; gap: .5rem; }
.info-row { display: flex; align-items: center; gap: .4rem; font-size: .9rem; }
.label { color: #555; }
.value { font-weight: 700; color: #1d3557; font-variant-numeric: tabular-nums; min-width: 3ch; }
.step-label { margin-left: .8rem; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; width: 100%; max-width: 560px; background: #f8fafc; }
.status-row { font-size: .95rem; font-weight: 600; min-height: 1.4em; color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; transition: opacity .15s; }
button:disabled { opacity: .45; cursor: default; }
button.ghost { background: #fff; color: #1d3557; }
.hint { font-size: .82rem; color: #555; margin: 0; line-height: 1.5; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Finite Volume Method demo: 1-D advection / shock-tube.
// Each step applies the Lax-Friedrichs conservative flux formula.
// Total mass = sum(u[i]*dx) stays exactly constant (to float64 rounding).
const N = 40; // number of finite-volume cells
const DX = 1.0 / N; // cell width (domain = [0,1])
const DT = 0.4 * DX; // time step chosen for stability (CFL ~ 0.4)
const MAX_STEPS = 120; // run for at most this many steps
// Initial condition: left half dense, right half light (shock tube).
function makeInitial() {
const u = new Float64Array(N);
for (let i = 0; i < N; i++) u[i] = i < N / 2 ? 1.0 : 0.2;
return u;
}
// Lax-Friedrichs flux: F = 0.5*(uL+uR) - 0.5*(dx/dt)*(uR-uL).
// F_{i+1/2} = 0.5*(u[i] + u[i+1]) - 0.5*(DX/DT)*(u[i+1] - u[i])
function laxFriedrichs(u) {
const un = new Float64Array(N);
// Outflow (copy) boundary conditions at both ends.
const F = new Float64Array(N + 1);
F[0] = 0;
F[N] = 0;
for (let i = 0; i < N - 1; i++) {
F[i + 1] = 0.5 * (u[i] + u[i + 1]) - 0.5 * (DX / DT) * (u[i + 1] - u[i]);
}
for (let i = 0; i < N; i++) {
un[i] = u[i] - (DT / DX) * (F[i + 1] - F[i]);
}
return un;
}
// Compute total mass = integral of density over [0,1].
function totalMass(u) {
let s = 0;
for (let i = 0; i < N; i++) s += u[i] * DX;
return s;
}
let state = makeInitial();
let step = 0;
let animId = null;
const initialMass = totalMass(makeInitial());
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const massEl = document.getElementById('mass-display');
const stepEl = document.getElementById('step-display');
const statusEl = document.getElementById('status-row');
const btnStep = document.getElementById('btn-step');
const btnRun = document.getElementById('btn-run');
const btnReset = document.getElementById('btn-reset');
function draw(u) {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// Background shading for initial-condition regions
ctx.fillStyle = '#e8eef3';
ctx.fillRect(0, H - 24, W / 2, 24);
ctx.fillStyle = '#cfd8e3';
ctx.fillRect(W / 2, H - 24, W / 2, 24);
// Draw density bars for each cell
const barW = W / N;
for (let i = 0; i < N; i++) {
const v = Math.max(0, Math.min(1.5, u[i]));
const bH = Math.round((v / 1.5) * (H - 28));
ctx.fillStyle = `hsl(${210 + v * 40}, 55%, ${65 - v * 20}%)`;
ctx.fillRect(Math.round(i * barW), H - 24 - bH, Math.ceil(barW) - 1, bH);
}
// Horizontal axis line
ctx.strokeStyle = '#aab4be';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, H - 24); ctx.lineTo(W, H - 24); ctx.stroke();
// Density labels for the initial regions
ctx.fillStyle = '#666';
ctx.font = '11px system-ui, sans-serif';
ctx.fillText('ρ = 1.0', 4, H - 28);
ctx.fillText('ρ = 0.2', W - 46, H - 28);
}
function updateUI() {
const m = totalMass(state);
massEl.textContent = m.toFixed(5);
stepEl.textContent = step;
draw(state);
const err = Math.abs(m - initialMass);
if (step >= MAX_STEPS) {
stopRun();
statusEl.textContent = i18nFmt('Finished after %n% steps. Mass conserved.', { n: step });
} else if (step > 0) {
statusEl.textContent = i18nFmt('Running — mass error: %err%', { err: err.toExponential(1) });
} else {
statusEl.textContent = 'Ready — press Step or Run.';
}
}
function doStep() {
if (step >= MAX_STEPS) return;
state = laxFriedrichs(state);
step++;
updateUI();
}
function startRun() {
if (animId) return;
btnRun.textContent = 'Pause';
btnStep.disabled = true;
function tick() {
if (step >= MAX_STEPS) { stopRun(); return; }
doStep();
animId = requestAnimationFrame(tick);
}
animId = requestAnimationFrame(tick);
}
function stopRun() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
btnRun.textContent = 'Run';
btnStep.disabled = false;
}
function reset() {
stopRun();
state = makeInitial();
step = 0;
statusEl.textContent = 'Ready — press Step or Run.';
updateUI();
}
btnStep.addEventListener('click', doStep);
btnRun.addEventListener('click', () => { animId ? stopRun() : startRun(); });
btnReset.addEventListener('click', reset);
updateUI();
Notice that even after many steps the sum of all cell densities stays constant. That is the FVM guarantee: fluxes cancel at shared faces, so nothing is created or destroyed. Press Reset to restart from the initial condition, or Run to animate automatically.
Comments
Loading comments...