+
+ the paths themselves, in the metric the count gives — aimed a little wide of the
+ critical impact parameter a ray winds round and leaves, a little narrow and it
+ winds round and falls in. That winding is why the ring is bright
+
+
+ ({
+ frame: (s: Surface) => {
+ const { ctx, width, height: H } = s;
+ ctx.clearRect(0, 0, width, H);
+ ctx.fillStyle = BACK; ctx.fillRect(0, 0, width, H);
+ const SPAN = 22, k = Math.min(width, H) / (2 * SPAN);
+ const cx = width / 2, cy = H / 2;
+ const bc = COUNTED.crit;
+ for (let i = -6; i <= 6; i++) {
+ const b = bc * (1 + i * 0.045);
+ const { pts, escaped } = route(COUNTED, b);
+ if (pts.length < 2) continue;
+ ctx.strokeStyle = escaped ? "rgba(61,220,255,0.55)" : "rgba(255,122,69,0.55)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ pts.forEach(([x, y], j) =>
+ j ? ctx.lineTo(cx + x * k, cy - y * k) : ctx.moveTo(cx + x * k, cy - y * k));
+ ctx.stroke();
+ }
+ ctx.setLineDash([3, 4]); ctx.strokeStyle = "rgba(140,147,168,0.65)";
+ ctx.beginPath(); ctx.arc(cx, cy, bc * k, 0, 2 * Math.PI); ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.fillStyle = "rgba(200,205,220,0.8)";
+ ctx.beginPath(); ctx.arc(cx, cy, 2.5, 0, 2 * Math.PI); ctx.fill();
+ ctx.font = "11px system-ui, sans-serif"; ctx.textAlign = "left";
+ ctx.fillStyle = "#3ddcff"; ctx.fillText("escapes", 12, 16);
+ ctx.fillStyle = "#ff7a45"; ctx.fillText("captured", 12, 31);
+ ctx.fillStyle = "#5a5f6e";
+ ctx.fillText(`dashed: b = 2e M = ${bc.toFixed(3)} M`, 12, H - 12);
+ },
+ })} />
+
+
;
+
+// ─── against the two images there are ───────────────────────────────────────
+
+/**
+ * WHAT THE EVENT HORIZON TELESCOPE HAS ALREADY SAID ABOUT IT.
+ *
+ * The derivation gives one number and no others: a shadow 4.63% larger than general
+ * relativity's at the same mass. The collaboration publishes exactly the quantity that
+ * number is a prediction for —
+ *
+ * δ = θ_measured / θ_Schwarzschild − 1
+ *
+ * with θ_Schwarzschild built from a mass and a distance measured some other way. That
+ * is "measure the mass from orbits and the shadow from imaging", which is the whole of
+ * the test, so the panel is one axis with δ on it and everything else is annotation.
+ *
+ * THREE ROWS FOR TWO OBJECTS. Sgr A* appears twice because the same image is measured
+ * against two independent mass calibrations, VLTI and Keck; those two cannot be
+ * averaged with each other, though either can be averaged with M87*.
+ *
+ * AND THE AMBER BAND IS THE HONEST PART. General relativity's own δ is not a point:
+ * Kerr runs from −0.08 at high spin down to 0 at none, so the range relativity already
+ * covers is nearly twice the excess being looked for. A shadow measured against an
+ * orbital mass therefore cannot settle this alone — it needs a spin from somewhere
+ * else, or an object known to be spinning slowly. The prediction stays falsifiable and
+ * stops being a one-measurement test, and drawing the band is the only way to say that
+ * without the reader having to take it on trust.
+ */
+type Image = { of: string; delta: number; e: number; note: string };
+const IMAGES: Image[] = [
+ { of: "M87*", delta: -0.01, e: 0.17,
+ note: "EHT 2019 VI · Gebhardt+2011's stellar-dynamical mass" },
+ { of: "Sgr A*", delta: -0.08, e: 0.09, note: "EHT 2022 VI · VLTI orbital mass" },
+ { of: "Sgr A*", delta: -0.04, e: 0.09, note: "EHT 2022 VI · Keck orbital mass" },
+];
+
+const EXCESS = (2 * Math.E) / (3 * Math.sqrt(3)) - 1;
+const KERR_LO = -0.08;
+
+/**
+ * AND THE MODEL GETS A BAND TOO, WHICH IS NOT THE SAME KIND OF BAND.
+ *
+ * The amber one is SPIN: Kerr's δ genuinely runs from −0.08 to 0 as a real black hole
+ * turns, so general relativity does not predict a number, it predicts a range, and the
+ * range is a property of the object.
+ *
+ * The blue one is IGNORANCE. `metric/ring-as-imaged` traces the ring an optically thin
+ * plasma casts around each geometry and finds the observable ratio depends on where
+ * that plasma sits — 1.010 anchored at the same areal radius, 1.038 at each geometry's
+ * own ISCO, 1.062 scaled to each photon sphere. Nothing in this model picks between
+ * them. So the width is not something the black hole is doing, it is something this
+ * page does not know, and drawing the two the same way would be a lie of composition.
+ * They are labelled apart, and the model's own spin range is not in there at all
+ * because nothing here has a rotating solution to take it from.
+ */
+const readRing = (name: string) => {
+ const f = findingOf("metric/ring-as-imaged", name);
+ return typeof f?.value === "number" ? f.value : NaN;
+};
+const OBSERVED = () => readRing(
+ "THE OBSERVABLE RATIO — plasma truncated at each geometry's own ISCO") - 1;
+const BAND_LO = () => readRing(
+ "the observable ratio, plasma at the same areal radius in both") - 1;
+const BAND_HI = () => readRing(
+ "the observable ratio, plasma scaled to each photon sphere") - 1;
+
+const eht = (s: Surface) => {
+ const { ctx } = s;
+ ctx.fillStyle = BACK; ctx.fillRect(0, 0, s.width, s.height);
+ const L = 58, R = 18, T = 46, B = 46;
+ const w = s.width - L - R, h = s.height - T - B;
+ const LO = -0.30, HI = 0.30;
+ const X = (d: number) => L + w * (d - LO) / (HI - LO);
+ const rowY = (i: number) => T + h * (i + 0.65) / (IMAGES.length + 0.6);
+
+ ctx.font = "400 10px ui-monospace, Menlo, monospace";
+ ctx.strokeStyle = "rgba(120,127,148,0.13)"; ctx.lineWidth = 1;
+ for (let d = -0.3; d <= 0.301; d += 0.1) {
+ ctx.beginPath(); ctx.moveTo(X(d), T); ctx.lineTo(X(d), T + h); ctx.stroke();
+ ctx.fillStyle = "#5a5f6e"; ctx.textAlign = "center";
+ ctx.fillText(`${d > 0.001 ? "+" : ""}${d.toFixed(1)}`, X(d), T + h + 16);
+ }
+
+ // the range general relativity itself covers, over spin and viewing angle
+ ctx.fillStyle = "rgba(212,180,139,0.10)";
+ ctx.fillRect(X(KERR_LO), T, X(0) - X(KERR_LO), h);
+ ctx.strokeStyle = "#d4b48b"; ctx.lineWidth = 1.8;
+ ctx.beginPath(); ctx.moveTo(X(0), T); ctx.lineTo(X(0), T + h); ctx.stroke();
+
+ // the geometry alone — a sharp line, and no longer the thing to compare against
+ ctx.strokeStyle = "#4aa8eb"; ctx.globalAlpha = 0.40; ctx.lineWidth = 1.2;
+ ctx.setLineDash([2, 4]);
+ ctx.beginPath(); ctx.moveTo(X(EXCESS), T); ctx.lineTo(X(EXCESS), T + h); ctx.stroke();
+ ctx.setLineDash([]); ctx.globalAlpha = 1;
+
+ // and what an instrument would see: a band, because the plasma is not pinned down
+ const lo = BAND_LO(), hi = BAND_HI(), mid = OBSERVED();
+ if (Number.isFinite(lo) && Number.isFinite(hi)) {
+ ctx.fillStyle = "rgba(74,168,235,0.13)";
+ ctx.fillRect(X(lo), T, X(hi) - X(lo), h);
+ }
+ if (Number.isFinite(mid)) {
+ ctx.strokeStyle = "#4aa8eb"; ctx.lineWidth = 2.2; ctx.setLineDash([6, 3]);
+ ctx.beginPath(); ctx.moveTo(X(mid), T); ctx.lineTo(X(mid), T + h); ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ IMAGES.forEach((im, i) => {
+ const y = rowY(i);
+ ctx.strokeStyle = "#eef0f5"; ctx.lineWidth = 1.4;
+ ctx.beginPath(); ctx.moveTo(X(im.delta - im.e), y); ctx.lineTo(X(im.delta + im.e), y); ctx.stroke();
+ for (const q of [im.delta - im.e, im.delta + im.e]) {
+ ctx.beginPath(); ctx.moveTo(X(q), y - 4); ctx.lineTo(X(q), y + 4); ctx.stroke();
+ }
+ ctx.fillStyle = "#eef0f5";
+ ctx.beginPath(); ctx.arc(X(im.delta), y, 3.2, 0, 2 * Math.PI); ctx.fill();
+
+ ctx.textAlign = "right"; ctx.font = "400 11px ui-monospace, Menlo, monospace";
+ ctx.fillText(im.of, L - 8, y + 4);
+ /*
+ * THE ANNOTATION IS RIGHT-ALIGNED TO THE FRAME, not hung off the end of the bar.
+ * M87*'s error is ±0.17 and its bar reaches most of the way across, so text placed
+ * after it ran off the panel and was cut in half — which is how the first render
+ * of this figure came out.
+ */
+ ctx.textAlign = "right"; ctx.font = "400 8.5px ui-monospace, Menlo, monospace";
+ ctx.fillStyle = "#5a5f6e";
+ ctx.fillText(im.note, s.width - R, y - 4);
+ const at = Number.isFinite(mid) ? mid : EXCESS;
+ ctx.fillText(`${(Math.abs(at - im.delta) / im.e).toFixed(2)}σ from this model,` +
+ ` ${(Math.abs(im.delta) / im.e).toFixed(2)}σ from relativity`, s.width - R, y + 8);
+ });
+
+ ctx.font = "400 9.5px ui-monospace, Menlo, monospace";
+ ctx.textAlign = "left";
+ ctx.fillStyle = "#4aa8eb";
+ ctx.fillText(Number.isFinite(mid)
+ ? `this model, AS IMAGED — δ = +${mid.toFixed(4)}, and the blue band is where the plasma could put it`
+ : "this model — the ray-traced ring is NOT IN THE REPORT", L + 4, T - 32);
+ ctx.fillStyle = "rgba(74,168,235,0.55)";
+ ctx.fillText(`the faint line is the geometry alone, δ = 2e/3√3 − 1 = +${EXCESS.toFixed(4)} — not what a telescope reads`,
+ L + 4, T - 20);
+ ctx.fillStyle = "#d4b48b";
+ ctx.fillText("general relativity — δ = 0 at no spin, and the amber band is Kerr's own range over spin",
+ L + 4, T - 8);
+ ctx.fillStyle = "#5a5f6e"; ctx.textAlign = "center";
+ ctx.fillText("δ = measured shadow / relativity's shadow at the same mass − 1",
+ L + w / 2, s.height - 8);
+};
+
+export const ShadowAgainstEht = ({ height = 300 }: { height?: number } = {}) =>
+
+
{note}
+
+ {
+ let t: Two;
+ let acc = 0, warmed = 0;
+ const BUDGET = 12; // ms a frame may spend warming
+ // headless draws ONE frame and stops, so there the average has to be finished
+ // before it — see CANVAS.tsx
+ const HEADLESS = typeof IntersectionObserver === "undefined";
+ return {
+ start: () => {
+ t = make(qL, qR, sep, theory, empty, since ? warm : 0); warmed = since ? warm : 0;
+ // a `since` panel settled inside make(); its clock starts at the body
+ if (HEADLESS && !since) for (; warmed < warm; warmed++) t.w.tick();
+ if (HEADLESS && since) for (let i = 0; i < 24; i++) t.w.tick();
+ },
+ stop: () => { (t as unknown) = undefined; },
+ frame: (sur: Surface, dt: number) => {
+ if (warmed < warm) {
+ /*
+ * THE AVERAGE IS THE MEASUREMENT, so it has to exist — but building it
+ * inside `start()` froze the tab for a second or two per panel, on the
+ * main thread, as the reader scrolled past. It is spread over frames on a
+ * time budget instead, and the panel fills in while it is watched.
+ */
+ const t0 = performance.now();
+ while (warmed < warm && performance.now() - t0 < BUDGET) { t.w.tick(); warmed++; }
+ } else {
+ acc += Math.min(dt, 0.05);
+ while (acc > 1 / 20) { t.w.tick(); acc -= 1 / 20; }
+ /*
+ * AND THEN IT STARTS AGAIN, for the panel whose point is the EMERGENCE.
+ * A finished average is a picture of a result; watching the shadow climb
+ * out of the noise as √n is the thing being claimed, and it can only be
+ * seen from the beginning.
+ */
+ if (restart && t.w.stats.ticks > restart) { t = make(qL, qR, sep, theory, empty, since ? warm : 0); warmed = since ? warm : 0; }
+ }
+ paint(t, sur, label, right);
+ },
+ };
+ }} />
+
+
;
+
+/** two opposite charges: the annihilation piles up between them */
+export const LatticeAttract = ({ height = 300 }: { height?: number } = {}) =>
+
+
two bodies in the vacuum — the shortfall each leaves, and the push it makes: none
+
+ {
+ let w: World, bodies: Source[];
+ let sum: Float64Array, n = 0, acc = 0;
+ const HEADLESS = typeof IntersectionObserver === "undefined";
+
+ /** back to a fresh vacuum and an empty average, so the shadow climbs out again */
+ const restart = () => {
+ w = new World({
+ /*
+ * GRAVITY+MAGNETISM, because pure gravity has nothing to average.
+ *
+ * (G/2) is unconditional, so under gravity every point splits every tick
+ * and every one of those meetings is neutral and annihilates: one per
+ * edge, every tick, forever. Nothing survives, the destruction rate is
+ * uniform to the last digit, and the only structure anywhere is the rim
+ * of the body itself — which is what this panel drew, correctly and
+ * uselessly. Add polarity and half the meetings TURN instead: the vacuum
+ * persists at fill 0.5001, the derived ½, and a body's shortfall has
+ * something to be a shortfall IN. Measured, 41% deep at the body.
+ */
+ theory: GRAVITY_MAGNETISM, geometry: GEOM, N, seed: (Math.random() * 1e9) | 0,
+ boundary: "wrap",
+ });
+ bodies = [-GAP_CELLS / 2, GAP_CELLS / 2].map(dx =>
+ w.add({ at: [C + dx, C], radius: 2, emits: 0, absorbs: true, duty: 0 }));
+ sum = new Float64Array(w.backend.size());
+
+ n = 0;
+ };
+
+ /*
+ * WHAT IS COUNTED IS DESTRUCTION, NOT OCCUPANCY — and under gravity that is
+ * the only choice, because occupancy is identically zero.
+ *
+ * (G/2) is unconditional, so under gravity every point splits every tick and
+ * every one of those meetings is neutral and annihilates: measured on a 41²
+ * triangular lattice, 1,681 splits and 5,043 annihilations a tick against
+ * 5,043 edges — exactly one per edge, every tick, forever. Nothing SURVIVES,
+ * so `fill` is 0.0000 and a panel drawn from occupancy is black. It was.
+ *
+ * The vacuum is not idle, it is perfectly balanced: an enormous amount of work
+ * whose net is nothing. What a body does is break that balance where it sits,
+ * and the quantity that records it is how much space was destroyed — which is
+ * what this book says gravity IS.
+ */
+ /*
+ * OCCUPANCY, BECAUSE UNDER GRAVITY+MAGNETISM IT PERSISTS. Half of head-on
+ * meetings are alike and TURN rather than annihilate, so the vacuum holds at
+ * fill 0.5001 and "how many are missing" is a question with an answer. Under
+ * pure gravity it is not: nothing survives a tick, occupancy is identically
+ * zero, and this panel was black — which is why it runs g+m.
+ */
+ const occ = (k: number) => {
+ let c = 0;
+ for (let d = 0; d < GEOM.DEG; d++) if (w.backend.active(k, d)) c++;
+ return c / GEOM.DEG;
+ };
+ const step = () => {
+ w.tick();
+ n++;
+ w.backend.forEachLocal(k => { sum[k] += occ(k); });
+ };
+
+ return {
+ start: () => { restart(); if (HEADLESS) for (let i = 0; i < 2000; i++) step(); },
+ stop: () => { (w as unknown) = undefined; sum = new Float64Array(0); },
+ frame: (sur: Surface, dt: number) => {
+ acc += dt;
+ /*
+ * IT RUNS TO 2400 RATHER THAN THE ARCHIVE'S 900, because the arrow is a
+ * measurement and at 900 it is not one. Measured over eight seeds, the
+ * left body's differential push comes to +0.008 ± 0.028 at 560 ticks —
+ * the sign is a coin flip, [-+-+-+++] — and +0.023 ± 0.019 at 2000, where
+ * seven of eight are positive and the mean is 3.4σ from zero. So the
+ * arrow is drawn only once there is something to draw.
+ */
+ while (acc > 1 / 90) { acc -= 1 / 90; if (n >= 2400) restart(); else step(); }
+
+ const { ctx, width, height: H } = sur;
+ ctx.clearRect(0, 0, width, H);
+ ctx.fillStyle = BACK; ctx.fillRect(0, 0, width, H);
+
+ const TOP = 20, BOT = 18, GAP = 10;
+ const cw = (width - GAP) / 2;
+ const side = Math.min(cw, H - TOP - BOT);
+ const pz = side / (2 * VIEW + 1);
+ const top = TOP + Math.max(0, (H - TOP - BOT - side) / 2);
+
+ /*
+ * THE LEVEL FAR FROM EITHER BODY, which is the zero the right half is drawn
+ * against. A body's shadow is a DIFFERENCE from what the vacuum does
+ * anyway, so the vacuum's own level has to be measured rather than assumed
+ * — it moves with occupancy, geometry and rate.
+ */
+ let bg = 0, bn = 0;
+ w.backend.forEachLocal(k => {
+ if (w.isSource(k) || !n) return;
+ const p = px(w, k);
+ if (Math.hypot(p[0] - (C - GAP_CELLS / 2), p[1] - C) < 34) return;
+ if (Math.hypot(p[0] - (C + GAP_CELLS / 2), p[1] - C) < 34) return;
+ bg += sum[k] / n; bn++;
+ });
+ bg = bn ? bg / bn : 0;
+
+ /*
+ * THE SHORTFALL, SMOOTHED BEFORE IT IS LOGGED — without this the log makes
+ * things worse rather than better.
+ *
+ * Per cell the mean occupancy still carries shot noise about the size of
+ * the signal at r = 8, which is 1.8% of the far field. A log scale lifts
+ * small numbers, so it lifts that noise exactly as faithfully as it lifts
+ * the halo, and the panel comes out an even orange speckle with two bright
+ * dots in it. Averaging over a neighbourhood divides the noise by the
+ * number of cells in the box and leaves the structure alone — the same √n
+ * the time average buys — and only then is the log honest. Nothing is
+ * scaled up; the noise is taken down.
+ */
+ /*
+ * A ROUND KERNEL, because a square one prints its own shape. Box-averaged,
+ * each halo came out with straight edges and corners — the window showing
+ * through as structure, which is the one thing a smoothing window must not
+ * do. A disc has no orientation to leak.
+ *
+ * AND THE WIDTH IS DOING VISIBLE WORK, which has to be said rather than
+ * left for someone to find. Measured raw, with no smoothing at all, the
+ * shortfall around a body of radius 2 is:
+ *
+ * r 3 4 5 6 12 30
+ * 0.2525 0.0951 0.0002 0.0002 −0.0007 −0.0006
+ *
+ * Three orders of magnitude across two cells. The field itself is a RIM,
+ * not a halo — at p = 1 the vacuum is refreshed completely every tick, so
+ * nothing carries the shadow outward and there is no tail to reveal. The
+ * gradient this panel shows is that rim convolved with a disc of radius
+ * `B`, which is a legitimate way to see where a small feature sits and is
+ * NOT a picture of the field falling off gently. Read the width of the
+ * glow as the width of the filter.
+ */
+ const B = 7;
+ const KER: number[][] = [];
+ for (let i = -B; i <= B; i++) for (let j = -B; j <= B; j++)
+ if (i * i + j * j <= B * B) KER.push([i, j]);
+ const WIN = KER.length;
+ const fld = new Float64Array(N * N).fill(NaN);
+ w.backend.forEachLocal(k => {
+ const q = px(w, k);
+ fld[Math.round(q[0]) * N + Math.round(q[1])] = sum[k] / Math.max(n, 1);
+ });
+ const smooth = new Float64Array(N * N).fill(NaN);
+ for (let x = B; x < N - B; x++) for (let y = B; y < N - B; y++) {
+ let acc2 = 0, m = 0;
+ for (const [i, j] of KER) {
+ const v2 = fld[(x + i) * N + (y + j)];
+ if (!Number.isNaN(v2)) { acc2 += v2; m++; }
+ }
+ if (m >= WIN * 0.5) smooth[x * N + y] = acc2 / m;
+ }
+
+ for (const col of [0, 1]) {
+ const cx = (col === 0 ? cw / 2 : cw + GAP + cw / 2), cy = top + side / 2;
+ w.backend.forEachLocal(k => {
+ const p = px(w, k);
+ const x = p[0] - C, y = p[1] - C;
+ if (Math.abs(x) > VIEW || Math.abs(y) > VIEW) return;
+ // left: what is there. right: how much is MISSING.
+ /*
+ * THE FAR FIELD'S OWN LEVEL SETS BOTH SCALES, rather than a constant
+ * measured once and left behind. Left: this tick's destructions
+ * against the mean rate. Right: how far BELOW that mean the running
+ * average sits, full ink at a fifth of it. A hard-coded divisor was
+ * right for one creation rate and silently wrong at the rule's own.
+ */
+ /*
+ * THE SHORTFALL ON A LOG SCALE, because it is a power law and a power
+ * law inked linearly is a dot. Measured on this arrangement, the
+ * shortfall is 41% of the far field AT the body and 1.8% by r = 8 —
+ * a factor of twenty across eight cells — so on a linear scale
+ * everything past the rim sits under the first shade and the panel
+ * reads as two circles on black. It did. Each halving now gets the
+ * same number of shades.
+ */
+ const rate = smooth[Math.round(p[0]) * N + Math.round(p[1])];
+ const d = Number.isNaN(rate) ? 0 : Math.max(0, bg - rate) / Math.max(bg, 1e-9);
+ // low enough that the smoothed tail is still inked rather than clipped
+ const FLOOR = 0.0006;
+ const v = col === 0
+ ? occ(k)
+ : Math.log(1 + d / FLOOR) / Math.log(1 + 1 / FLOOR);
+ if (v <= 0.004) return;
+ ctx.globalAlpha = Math.min(1, v);
+ ctx.fillStyle = col === 0 ? PLUS : MINUS;
+ ctx.fillRect(cx + x * pz - pz / 2, cy + y * pz - pz / 2, pz + 0.6, pz + 0.6);
+ });
+ ctx.globalAlpha = 1;
+
+ for (const b of bodies) {
+ let bx = 0, by = 0;
+ for (const k of b.locals) { const q = px(w, k); bx += q[0]; by += q[1]; }
+ bx = bx / b.locals.length - C; by = by / b.locals.length - C;
+ ctx.strokeStyle = SEEN; ctx.lineWidth = 1.2;
+ ctx.beginPath();
+ ctx.arc(cx + bx * pz, cy + by * pz, 2.8 * pz, 0, 2 * Math.PI);
+ ctx.stroke();
+
+ if (col === 1) { // the measured push, on each body
+ /*
+ * THE MUTUAL FORCE IS THE DIFFERENTIAL PART, and taking it is not a
+ * cosmetic choice — it is the same paired differencing every force
+ * measurement in this project uses.
+ *
+ * Both bodies read a COMMON offset: measured, −0.064 and −0.111 per
+ * tick in x, so both appear pushed the same way. That common part is
+ * what a body of this shape feels in a box of this size anyway —
+ * lattice anisotropy and the wrap — and it is identical for both, so
+ * it cannot be what they do to EACH OTHER. Subtracting the mean
+ * leaves +0.0235 and −0.0235: equal, opposite, and pointing at each
+ * other, which is the claim the panel is making.
+ */
+ const mx = bodies.reduce((a, o) => a + (o.absorbed[0] ?? 0), 0) / bodies.length;
+ const my = bodies.reduce((a, o) => a + (o.absorbed[1] ?? 0), 0) / bodies.length;
+ const spread = Math.max(1e-9, Math.abs(
+ (bodies[1].absorbed[0] ?? 0) - (bodies[0].absorbed[0] ?? 0)) / 2);
+ const sc = 26 / spread;
+ const fx = ((b.absorbed[0] ?? 0) - mx) * sc, fy = ((b.absorbed[1] ?? 0) - my) * sc;
+ if (n < 1200 || Math.hypot(fx, fy) < 2) continue; // not resolved yet
+ const x0 = cx + bx * pz, y0 = cy + by * pz;
+ ctx.strokeStyle = GOOD; ctx.lineWidth = 1.6;
+ ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x0 + fx, y0 + fy); ctx.stroke();
+ const ang = Math.atan2(fy, fx);
+ ctx.beginPath();
+ ctx.moveTo(x0 + fx, y0 + fy);
+ ctx.lineTo(x0 + fx - 5 * Math.cos(ang - 0.4), y0 + fy - 5 * Math.sin(ang - 0.4));
+ ctx.moveTo(x0 + fx, y0 + fy);
+ ctx.lineTo(x0 + fx - 5 * Math.cos(ang + 0.4), y0 + fy - 5 * Math.sin(ang + 0.4));
+ ctx.stroke();
+ }
+ }
+ }
+
+ ctx.font = "11px ui-monospace, monospace";
+ ctx.textAlign = "center";
+ ctx.fillStyle = INK;
+ ctx.fillText("one tick — every edge annihilates, uniformly", cw / 2, 13);
+ ctx.fillText(`averaged over ${n} ticks`, cw + GAP + cw / 2, 13);
+
+ ctx.font = "10px ui-monospace, monospace";
+ ctx.fillStyle = FAINT;
+ const mx0 = bodies.reduce((a, o) => a + (o.absorbed[0] ?? 0), 0) / bodies.length;
+ const push = bodies.map(b =>
+ (((b.absorbed[0] ?? 0) - mx0) / Math.max(n, 1)).toFixed(3));
+ /*
+ * AND THE PUSH IS EXACTLY ZERO, which is the panel's actual result and is
+ * printed rather than hidden. At the rule's own creation rate the vacuum
+ * is refreshed completely every tick, so what arrives at a body is
+ * isotropic no matter what is beside it: measured over 600 ticks in
+ * gravity+magnetism, each body's net absorbed momentum along the line
+ * joining them is 0.0000. Two absorbers do not attract here.
+ */
+ ctx.fillText(`push on each body ${push[0]} and ${push[1]}` +
+ (Number(push[0]) === 0 && Number(push[1]) === 0
+ ? " — exactly zero: nothing reaches across"
+ : " — equal and opposite"),
+ width / 2, H - 5);
+ ctx.textAlign = "left";
+ ctx.fillText("left: the charges themselves.", 8, H - 5);
+ ctx.textAlign = "right";
+ ctx.fillText("right: how many are MISSING.", width - 8, H - 5);
+ ctx.textAlign = "left";
+ },
+ };
+ }} />
+
+
;
+
+/**
+ * ONE BODY IN THE VACUUM, AND HOW FAR ITS DEFICIT REACHES — which is nowhere.
+ *
+ * The same pure-gravity vacuum as above with one absorber dropped into it after it
+ * has settled, so that whatever appears is the body's doing. It is the article's own
+ * sentence — *the deficit expands at c̄* — put to the test at the rule's own rate.
+ *
+ * IT DOES NOT EXPAND. Measured against the far field, at every tick from 4 to 60
+ * without changing:
+ *
+ * r 4 6 9 13 18 24 30
+ * −16.7% 0.0% 0.0% 0.0% 0.0% 0.0% 0.0%
+ *
+ * Exactly the body's own surface, and exactly nothing beyond it. Not a weak signal
+ * under noise — the far field is uniform to the last digit, because the vacuum is
+ * perfectly regular. And it is the same at t = 60 as at t = 4, so nothing is on its
+ * way either.
+ *
+ * THE REASON IS THE UNCONDITIONAL SPLIT. Every point is refreshed completely every
+ * tick — split, annihilated, split again — so the medium has no memory from one tick
+ * to the next, and news cannot ride on a medium with no memory. This is not a limit
+ * on the SPEED of the deficit; there is no deficit out there travelling slowly. It is
+ * that a perfectly balanced breathing is unaffected by what happened next door.
+ *
+ * WHAT THIS PANEL IS FOR, then, is to say that plainly. The gravity arc's mechanism
+ * cannot be a shortfall propagating through the vacuum, because at the rule's own
+ * rate it does not propagate at all. What survives the correction is everything that
+ * does not depend on it — the metric read off annihilation counts, which is local to
+ * where the counting happens, and the results that rest on it.
+ */
+export const DeficitFront = ({ height = 300 }: { height?: number } = {}) =>
+