w5isp.com/static/js/tcpudp.js
Graham McIntire 2097dbc018
Modernize why-signals-bend with project findings, polish scenes
Rewrite the NLOS post to reflect what microwaveprop has learned:
M-refractivity and dM/dh as the actual duct discriminant, the four
duct taxonomy with radiation ducts as the North Texas mechanism,
binary duct detection as a coin flip, low pressure beating high,
shallow boundary layer winning, PWAT sweet spot, seasonal pattern
(spring worst, summer best), troposcatter as the floor, and real
numbers for the 65 km QTH-to-W5HN/B path.

New interactive scenes: N vs M side-by-side refractivity profile,
pressure/HPBL/binary-duct bar chart, band-vs-dawn enhancement,
PWAT sweet spot with draggable cursor, monthly ducting probability,
and a 10 GHz link-budget decomposition showing how the 36 dB
diffraction gap gets closed.

Polish pass across all scenes: softer palette, rounded stroke
caps/joins, prettier slider styling in scene.css and nlos.css,
signal-pulse animations on NF cascade / single-stage / LNA
placement, trail persistence on the ray-trace scene, animated
glints along trapped rays in the duct scene, and animated TCP
sequence diagrams.

Add local-serve preview mode so future-dated posts show up on
zola serve but stay hidden on the deployed build.
2026-04-22 14:10:58 -05:00

822 lines
31 KiB
JavaScript

// TCP / UDP explainer. Vanilla JS + 2D canvas.
// Scoped to a single post via <link>/<script> in the markdown.
(() => {
'use strict';
const C = {
bg: '#1a1d24',
panel: '#242932',
fg: '#d8dce4',
dim: '#8189a0',
grid: '#2d323d',
blue: '#78b5f3',
cyan: '#6dc5d3',
green: '#9dca83',
orange: '#d6a86a',
red: '#e58089',
yellow: '#e2c37d',
magenta: '#c78de0',
};
// ----- framework (same shape as transistor/nlos scenes) -----
function setupCanvas(canvas) {
const ctx = canvas.getContext('2d');
const dpr = Math.max(1, window.devicePixelRatio || 1);
const fit = () => {
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
};
fit();
new ResizeObserver(fit).observe(canvas);
return { ctx, getSize: () => ({ w: canvas.clientWidth, h: canvas.clientHeight }) };
}
function scene(id, { height = 360, controls = [], readout = [], caption = '', threeCol = false } = {}) {
const root = document.getElementById(id);
if (!root) return null;
root.classList.add('scene-box');
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = `${height}px`;
root.appendChild(canvas);
const readoutEl = document.createElement('div');
readoutEl.className = 'scene-readout';
const readoutSpans = {};
readout.forEach(r => {
const item = document.createElement('span');
item.className = 'scene-readout-item';
const label = document.createElement('span');
label.textContent = r.label + ':';
const val = document.createElement('span');
val.textContent = r.init ?? '';
item.appendChild(label); item.appendChild(val);
readoutEl.appendChild(item);
readoutSpans[r.key] = val;
});
if (readout.length) root.appendChild(readoutEl);
const controlsEl = document.createElement('div');
controlsEl.className = 'scene-controls' + (threeCol ? ' scene-controls--three' : '');
const values = {};
controls.forEach(c => {
const wrap = document.createElement('div');
wrap.className = 'scene-control';
const label = document.createElement('label');
const name = document.createElement('span'); name.textContent = c.label;
const valSpan = document.createElement('span'); valSpan.className = 'scene-value';
label.appendChild(name); label.appendChild(valSpan);
const input = document.createElement('input');
input.type = 'range';
input.min = c.min; input.max = c.max; input.step = c.step ?? 'any';
input.value = c.value;
values[c.key] = parseFloat(input.value);
const fmt = c.format || (v => v.toFixed(2));
valSpan.textContent = fmt(values[c.key]);
input.addEventListener('input', () => {
values[c.key] = parseFloat(input.value);
valSpan.textContent = fmt(values[c.key]);
});
wrap.appendChild(label); wrap.appendChild(input);
controlsEl.appendChild(wrap);
});
if (controls.length) root.appendChild(controlsEl);
if (caption) {
const cap = document.createElement('div');
cap.className = 'scene-caption';
cap.textContent = caption;
root.appendChild(cap);
}
const { ctx, getSize } = setupCanvas(canvas);
const setReadout = (key, text) => {
if (readoutSpans[key]) readoutSpans[key].textContent = text;
};
return { canvas, ctx, getSize, values, setReadout };
}
function clear(ctx, w, h) { ctx.clearRect(0, 0, w, h); }
function text(ctx, str, x, y, color = C.fg, align = 'left', baseline = 'alphabetic', size = 12) {
ctx.save();
ctx.fillStyle = color;
ctx.font = `${size}px system-ui, sans-serif`;
ctx.textAlign = align;
ctx.textBaseline = baseline;
ctx.fillText(str, x, y);
ctx.restore();
}
// ----- sequence-diagram primitives -----
// Draw a partial / completed packet. `frac` in 0..1 controls how far along
// the arrow has advanced from (fromX, yStart) toward (toX, yEnd). If the
// packet is lost, the arrow dashes out and an X appears where it dies.
function drawPartialPacket(ctx, fromX, toX, yStart, yEnd, frac, label, color, { lost = false, dropFrac = 0.5 } = {}) {
frac = Math.max(0, Math.min(1, frac));
if (frac <= 0) return;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1.8;
if (lost && frac >= dropFrac) {
// Drew up to drop point then stop.
const mx = fromX + (toX - fromX) * dropFrac;
const my = yStart + (yEnd - yStart) * dropFrac;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(fromX, yStart); ctx.lineTo(mx, my);
ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = C.red;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(mx - 6, my - 6); ctx.lineTo(mx + 6, my + 6);
ctx.moveTo(mx - 6, my + 6); ctx.lineTo(mx + 6, my - 6);
ctx.stroke();
} else {
const endX = fromX + (toX - fromX) * frac;
const endY = yStart + (yEnd - yStart) * frac;
ctx.beginPath();
ctx.moveTo(fromX, yStart);
ctx.lineTo(endX, endY);
if (lost) ctx.setLineDash([4, 4]);
ctx.stroke();
if (lost) ctx.setLineDash([]);
// arrowhead on the advancing tip only when the packet is not lost yet
if (!lost) {
const dx = toX - fromX, dy = yEnd - yStart;
const L = Math.hypot(dx, dy);
const ux = dx / L, uy = dy / L;
const ax = endX - ux * 8;
const ay = endY - uy * 8;
const px = -uy, py = ux;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(endX, endY);
ctx.lineTo(ax + px * 4, ay + py * 4);
ctx.lineTo(ax - px * 4, ay - py * 4);
ctx.closePath();
ctx.fill();
}
}
// Label at midpoint of the traveled line, fading in as the packet advances
const lx = (fromX + (toX - fromX) * frac * 0.5) + fromX * 0 + (frac === 1 ? (fromX + toX) / 2 - fromX : 0);
const midX = fromX + (toX - fromX) * frac / 2;
const midY = yStart + (yEnd - yStart) * frac / 2 - 4;
ctx.globalAlpha = Math.min(1, frac * 2);
text(ctx, label, midX, midY, color, 'center');
ctx.globalAlpha = 1;
ctx.restore();
}
// Back-compat: the original non-animated draw that's still used by the
// handshake scene's progress bar drawing and a couple of other places.
function drawPacket(ctx, fromX, toX, yStart, yEnd, label, color, lost = false, dropFrac = 0.5) {
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1.8;
if (lost) {
ctx.setLineDash([4, 4]);
const mx = fromX + (toX - fromX) * dropFrac;
const my = yStart + (yEnd - yStart) * dropFrac;
ctx.beginPath(); ctx.moveTo(fromX, yStart); ctx.lineTo(mx, my); ctx.stroke();
ctx.setLineDash([]);
// X at drop point
ctx.strokeStyle = C.red;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(mx - 6, my - 6); ctx.lineTo(mx + 6, my + 6);
ctx.moveTo(mx - 6, my + 6); ctx.lineTo(mx + 6, my - 6);
ctx.stroke();
} else {
ctx.beginPath();
ctx.moveTo(fromX, yStart);
ctx.lineTo(toX, yEnd);
ctx.stroke();
// arrowhead
const dx = toX - fromX, dy = yEnd - yStart;
const L = Math.hypot(dx, dy);
const ux = dx / L, uy = dy / L;
const ax = toX - ux * 8;
const ay = yEnd - uy * 8;
const px = -uy, py = ux;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(toX, yEnd);
ctx.lineTo(ax + px * 4, ay + py * 4);
ctx.lineTo(ax - px * 4, ay - py * 4);
ctx.closePath();
ctx.fill();
}
// label, place near the midpoint, slightly above the line
const lx = (fromX + toX) / 2;
const ly = (yStart + yEnd) / 2 - 4;
text(ctx, label, lx, ly, color, 'center');
ctx.restore();
}
function drawTimelines(ctx, leftX, rightX, topY, botY, clientLabel = 'Client', serverLabel = 'Server') {
ctx.save();
ctx.strokeStyle = C.dim;
ctx.lineWidth = 1;
ctx.setLineDash([2, 4]);
ctx.beginPath();
ctx.moveTo(leftX, topY); ctx.lineTo(leftX, botY);
ctx.moveTo(rightX, topY); ctx.lineTo(rightX, botY);
ctx.stroke();
ctx.setLineDash([]);
// boxes at the top
ctx.fillStyle = C.panel;
ctx.strokeStyle = C.cyan;
ctx.fillRect(leftX - 40, topY - 30, 80, 24);
ctx.strokeRect(leftX - 40, topY - 30, 80, 24);
ctx.fillRect(rightX - 40, topY - 30, 80, 24);
ctx.strokeRect(rightX - 40, topY - 30, 80, 24);
text(ctx, clientLabel, leftX, topY - 14, C.fg, 'center');
text(ctx, serverLabel, rightX, topY - 14, C.fg, 'center');
// "time" arrow on the far left
ctx.strokeStyle = C.dim;
ctx.beginPath();
ctx.moveTo(20, topY); ctx.lineTo(20, botY - 8);
ctx.lineTo(16, botY - 14);
ctx.moveTo(20, botY - 8); ctx.lineTo(24, botY - 14);
ctx.stroke();
text(ctx, 'time', 26, botY - 4, C.dim);
ctx.restore();
}
// ---------- Scene 1: TCP three-way handshake ----------
function sceneHandshake() {
const s = scene('scene-handshake', {
height: 360,
readout: [
{ key: 'cseq', label: 'Client seq' },
{ key: 'sseq', label: 'Server seq' },
{ key: 'state', label: 'Connection state' },
],
caption: 'Every TCP connection opens with the same short ritual. The client announces a starting sequence number, the server acknowledges it and announces its own, and the client acknowledges that. After those three packets the connection is open. TCP wants you to have made eye contact before exchanging any data.',
});
if (!s) return;
const CLIENT_SEQ = 1000;
const SERVER_SEQ = 5000;
// Animated cycle schedule (seconds).
const CYCLE = 7.0;
const schedule = [
{ name: 'SYN', t0: 0.3, t1: 1.5, color: C.yellow, dir: 'right', label: `SYN seq=${CLIENT_SEQ}` },
{ name: 'SYN-ACK', t0: 2.0, t1: 3.2, color: C.orange, dir: 'left', label: `SYN-ACK seq=${SERVER_SEQ} ack=${CLIENT_SEQ + 1}` },
{ name: 'ACK', t0: 3.7, t1: 4.9, color: C.green, dir: 'right', label: `ACK seq=${CLIENT_SEQ + 1} ack=${SERVER_SEQ + 1}` },
];
const startWall = performance.now();
function drawPartial(ctx, fromX, toX, yStart, yEnd, frac, label, color) {
// Draw a forward-progress arrow whose head has advanced `frac` along the
// full line from (fromX, yStart) to (toX, yEnd). 1.0 = fully arrived.
frac = Math.max(0, Math.min(1, frac));
if (frac <= 0) return;
const endX = fromX + (toX - fromX) * frac;
const endY = yStart + (yEnd - yStart) * frac;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.moveTo(fromX, yStart);
ctx.lineTo(endX, endY);
ctx.stroke();
// Arrowhead
const dx = toX - fromX, dy = yEnd - yStart;
const L = Math.hypot(dx, dy);
const ux = dx / L, uy = dy / L;
const ax = endX - ux * 8;
const ay = endY - uy * 8;
const px = -uy, py = ux;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(endX, endY);
ctx.lineTo(ax + px * 4, ay + py * 4);
ctx.lineTo(ax - px * 4, ay - py * 4);
ctx.closePath();
ctx.fill();
// Label at midpoint of the traveled portion
const lx = (fromX + endX) / 2;
const ly = (yStart + endY) / 2 - 4;
text(ctx, label, lx, ly, color, 'center');
ctx.restore();
}
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const topY = 60, botY = h - 40;
const leftX = 150, rightX = w - 60;
drawTimelines(ctx, leftX, rightX, topY, botY);
const slant = 24;
const rowH = (botY - topY - slant) / 3.5;
const y1a = topY + rowH * 0.2, y1b = y1a + slant;
const y2a = y1b + rowH * 0.5, y2b = y2a + slant;
const y3a = y2b + rowH * 0.5, y3b = y3a + slant;
const rowsY = [
{ a: y1a, b: y1b },
{ a: y2a, b: y2b },
{ a: y3a, b: y3b },
];
const now = ((performance.now() - startWall) / 1000) % CYCLE;
schedule.forEach((pkt, i) => {
const { a, b } = rowsY[i];
const fromX = pkt.dir === 'right' ? leftX : rightX;
const toX = pkt.dir === 'right' ? rightX : leftX;
const frac = (now - pkt.t0) / (pkt.t1 - pkt.t0);
if (frac <= 0) return;
drawPartial(ctx, fromX, toX, a, b, frac, pkt.label, pkt.color);
});
// Progressive state labels on the client timeline. Place them in the
// vertical gaps between the three packet rows so they never collide with
// each other or with the packet arrows.
const gutterRight = leftX - 10;
const state =
now < schedule[0].t0 ? 'CLOSED' :
now < schedule[1].t1 ? 'SYN-SENT' :
'ESTABLISHED';
const yClosed = topY + 10;
const ySynSent = (y1b + y2a) / 2;
const yEstablished = (y2b + y3a) / 2;
const yOpen = y3b + 22;
text(ctx, 'CLOSED', gutterRight, yClosed, state === 'CLOSED' ? C.fg : C.dim, 'right', 'middle');
text(ctx, 'SYN-SENT', gutterRight, ySynSent, state === 'SYN-SENT' ? C.yellow : C.dim, 'right', 'middle');
text(ctx, 'ESTABLISHED', gutterRight, yEstablished, state === 'ESTABLISHED' ? C.green : C.dim, 'right', 'middle');
if (state === 'ESTABLISHED') {
text(ctx, 'connection open', gutterRight, yOpen, C.green, 'right', 'middle');
}
// Time progress bar along the very top, subtle.
const barY = 18, barX0 = 20, barX1 = w - 20;
ctx.strokeStyle = C.grid;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(barX0, barY); ctx.lineTo(barX1, barY); ctx.stroke();
ctx.fillStyle = C.cyan;
ctx.beginPath();
ctx.arc(barX0 + (barX1 - barX0) * (now / CYCLE), barY, 3, 0, Math.PI * 2);
ctx.fill();
s.setReadout('cseq', `${CLIENT_SEQ + 1}`);
s.setReadout('sseq', `${SERVER_SEQ + 1}`);
s.setReadout('state', state);
}
function loop() { draw(); requestAnimationFrame(loop); }
loop();
}
// ---------- Scene 2: Data with sequence numbers, ACKs, and loss ----------
function sceneData() {
const s = scene('scene-data', {
height: 460,
controls: [
{ key: 'loss', label: 'Packet loss', min: 0, max: 4, value: 1, step: 1,
format: v => ['none', '1 packet', '2 packets', '3 packets', 'burst'][Math.floor(v)] },
],
readout: [
{ key: 'sent', label: 'Segments sent' },
{ key: 'retx', label: 'Retransmissions' },
],
caption: 'Once the connection is open, each byte has a sequence number. The receiver acknowledges the next byte it expects, not the one it just got. If a segment goes missing, the receiver keeps asking for the same byte (duplicate ACKs) and the sender, eventually noticing, hands it over again. This is TCP being stubborn about delivery.',
});
if (!s) return;
// Scenario data: sequence of sends and the receiver response.
const scenarios = [
// 0: none lost
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: false },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'data', seq: 4, lost: false },
],
// 1: one lost (seq 2)
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'data', seq: 4, lost: false },
{ kind: 'retx', seq: 2, lost: false },
],
// 2: two lost (seq 2 and 4)
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'data', seq: 4, lost: true },
{ kind: 'retx', seq: 2, lost: false },
{ kind: 'retx', seq: 4, lost: false },
],
// 3: three lost
[
{ kind: 'data', seq: 1, lost: true },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'retx', seq: 1, lost: false },
{ kind: 'retx', seq: 2, lost: false },
],
// 4: burst loss
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: true },
{ kind: 'data', seq: 4, lost: true },
{ kind: 'retx', seq: 2, lost: false },
{ kind: 'retx', seq: 3, lost: false },
{ kind: 'retx', seq: 4, lost: false },
],
];
// Timing parameters for the animation.
const PACKET_DUR = 1.0; // seconds per packet in flight
const GAP = 0.25; // seconds of pause between each send/ack step
const PAUSE_END = 2.0; // hold time after the cycle completes before looping
const startWall = performance.now();
let lastScenario = -1;
// Precomputed rows (each row = one send or one ack) per scenario.
const scenarioRows = scenarios.map(scenario => {
const rows = [];
let nextExpected = 1;
scenario.forEach(pkt => {
rows.push({ type: 'send', pkt });
if (!pkt.lost) {
if (pkt.seq === nextExpected) nextExpected++;
rows.push({ type: 'ack', expect: nextExpected });
} else {
rows.push({ type: 'ack', expect: nextExpected, dup: true });
}
});
// attach t0 / t1 to each row sequentially
let t = 0;
rows.forEach(r => {
r.t0 = t;
r.t1 = t + PACKET_DUR;
t = r.t1 + GAP;
});
return { rows, total: t };
});
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const topY = 50, botY = h - 30;
const leftX = 90, rightX = w - 90;
drawTimelines(ctx, leftX, rightX, topY, botY);
const scenarioIdx = Math.floor(s.values.loss);
const { rows, total } = scenarioRows[scenarioIdx];
const cycleDur = total + PAUSE_END;
// restart the animation clock when scenario changes so users always
// see the full sequence when they flip the slider.
if (scenarioIdx !== lastScenario) lastScenario = scenarioIdx;
const now = ((performance.now() - startWall) / 1000) % cycleDur;
const slant = 22;
const rowH = Math.min(34, (botY - topY - slant - 10) / rows.length);
let sent = 0, retx = 0;
rows.forEach((r, i) => {
const y = topY + 10 + i * rowH;
const frac = Math.max(0, Math.min(1, (now - r.t0) / (r.t1 - r.t0)));
if (r.type === 'send') {
const color = r.pkt.kind === 'retx' ? C.orange : C.cyan;
const label = r.pkt.kind === 'retx' ? `retx seq=${r.pkt.seq}` : `DATA seq=${r.pkt.seq}`;
drawPartialPacket(ctx, leftX, rightX, y, y + slant, frac, label, color, { lost: r.pkt.lost, dropFrac: 0.5 });
if (frac > 0 && r.pkt.kind === 'retx') retx++;
if (frac > 0) sent++;
} else {
const label = r.dup ? `ACK ack=${r.expect} (dup)` : `ACK ack=${r.expect}`;
const color = r.dup ? C.red : C.green;
drawPartialPacket(ctx, rightX, leftX, y, y + slant, frac, label, color);
}
});
// Time progress bar at the top
const barY = 18, barX0 = 20, barX1 = w - 20;
ctx.strokeStyle = C.grid;
ctx.beginPath(); ctx.moveTo(barX0, barY); ctx.lineTo(barX1, barY); ctx.stroke();
ctx.fillStyle = C.cyan;
ctx.beginPath();
ctx.arc(barX0 + (barX1 - barX0) * (now / cycleDur), barY, 3, 0, Math.PI * 2);
ctx.fill();
s.setReadout('sent', sent);
s.setReadout('retx', retx);
}
function loop() { draw(); requestAnimationFrame(loop); }
loop();
}
// ---------- Scene 3: Sliding window ----------
function sceneWindow() {
const s = scene('scene-window', {
height: 520,
controls: [
{ key: 'win', label: 'Window size (packets)', min: 1, max: 8, value: 4, step: 1, format: v => `${v}` },
],
readout: [
{ key: 'rtts', label: 'RTTs to send 8 packets' },
{ key: 'throughput', label: 'Throughput vs stop-and-wait' },
],
caption: 'Sending one packet and then waiting for its ACK wastes nearly the entire round-trip time. TCP instead keeps a pile of packets in flight at once, up to the window size, and only waits when that pile is full. Doubling the window roughly doubles the throughput, up to the point where something else in the network becomes the bottleneck.',
});
if (!s) return;
// Animation timing: in a single RTT all `win` packets go out in quick
// succession, they arrive at the server after half an RTT, the acks come
// back during the second half, and then the next batch goes.
const RTT_DUR = 2.2; // seconds per round trip
const INTRA_STAGGER = 0.08;// stagger between packets within a batch
const PAUSE_END = 1.6;
const startWall = performance.now();
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const topY = 50, botY = h - 30;
const leftX = 90, rightX = w - 90;
drawTimelines(ctx, leftX, rightX, topY, botY);
const win = Math.floor(s.values.win);
const totalPackets = 8;
const rtts = Math.ceil(totalPackets / win);
const slant = 20;
const avail = botY - topY - 20;
const rowsCount = totalPackets * 2;
const gapCount = rtts - 1;
const unit = avail / (rowsCount + gapCount * 0.7);
const rowStep = Math.max(slant + 8, Math.min(32, unit));
const rttGap = Math.min(rowStep * 0.7, 24);
const cycleDur = rtts * RTT_DUR + PAUSE_END;
const now = ((performance.now() - startWall) / 1000) % cycleDur;
let yCursor = topY + 12;
let packetN = 1;
for (let batch = 0; batch < rtts; batch++) {
const batchSize = Math.min(win, totalPackets - batch * win);
const batchStart = batch * RTT_DUR;
// Send phase: 0 -> RTT/2 of this batch, staggered
for (let i = 0; i < batchSize; i++) {
const sendT0 = batchStart + i * INTRA_STAGGER;
const sendT1 = sendT0 + RTT_DUR / 2;
const frac = Math.max(0, Math.min(1, (now - sendT0) / (sendT1 - sendT0)));
drawPartialPacket(ctx, leftX, rightX, yCursor, yCursor + slant, frac, `seq=${packetN}`, C.cyan);
packetN++;
yCursor += rowStep;
}
// ACK phase: RTT/2 -> RTT of this batch, staggered
for (let i = 0; i < batchSize; i++) {
const ackT0 = batchStart + RTT_DUR / 2 + i * INTRA_STAGGER;
const ackT1 = ackT0 + RTT_DUR / 2;
const frac = Math.max(0, Math.min(1, (now - ackT0) / (ackT1 - ackT0)));
const ackNum = batch * win + i + 2;
drawPartialPacket(ctx, rightX, leftX, yCursor, yCursor + slant, frac, `ack=${ackNum}`, C.green);
yCursor += rowStep;
}
if (batch < rtts - 1) yCursor += rttGap;
}
// Time progress bar
const barY = 18, barX0 = 20, barX1 = w - 20;
ctx.strokeStyle = C.grid;
ctx.beginPath(); ctx.moveTo(barX0, barY); ctx.lineTo(barX1, barY); ctx.stroke();
ctx.fillStyle = C.cyan;
ctx.beginPath();
ctx.arc(barX0 + (barX1 - barX0) * (now / cycleDur), barY, 3, 0, Math.PI * 2);
ctx.fill();
s.setReadout('rtts', `${rtts}`);
const speedup = Math.ceil(totalPackets / rtts);
s.setReadout('throughput', `${speedup}x faster`);
}
function loop() { draw(); requestAnimationFrame(loop); }
loop();
}
// ---------- Scene 4: Congestion control ----------
function sceneCongestion() {
const s = scene('scene-congestion', {
height: 380,
controls: [
{ key: 'lossAt', label: 'First loss at time', min: 3, max: 20, value: 8, step: 0.5, format: v => `RTT ${v.toFixed(1)}` },
{ key: 'ssthresh0', label: 'Initial ssthresh', min: 8, max: 64, value: 32, step: 1, format: v => `${v}` },
],
readout: [
{ key: 'peak', label: 'Peak cwnd' },
{ key: 'final', label: 'Final cwnd' },
],
caption: 'TCP has no idea how much bandwidth is available. It finds out by sending more and more until something breaks. Slow start ramps the congestion window up exponentially until it hits the slow-start threshold, then tiptoes up by one packet per RTT. A packet loss halves the window and the whole thing starts over. This is a protocol that lives its entire life anxious about whether it should be going faster.',
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const margin = { l: 50, r: 20, t: 25, b: 40 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
const tMax = 40; // RTTs
const cwndMax = 80;
const xOf = t => margin.l + (t / tMax) * pW;
const yOf = c => margin.t + (1 - c / cwndMax) * pH;
ctx.strokeStyle = C.grid;
ctx.strokeRect(margin.l, margin.t, pW, pH);
for (let t = 0; t <= tMax; t += 5) {
ctx.beginPath(); ctx.moveTo(xOf(t), margin.t); ctx.lineTo(xOf(t), margin.t + pH); ctx.stroke();
text(ctx, `${t}`, xOf(t), margin.t + pH + 14, C.dim, 'center');
}
for (let c = 0; c <= cwndMax; c += 20) {
ctx.beginPath(); ctx.moveTo(margin.l, yOf(c)); ctx.lineTo(margin.l + pW, yOf(c)); ctx.stroke();
text(ctx, `${c}`, margin.l - 5, yOf(c), C.dim, 'right', 'middle');
}
text(ctx, 'time (RTTs)', margin.l + pW / 2, margin.t + pH + 28, C.dim, 'center');
ctx.save();
ctx.translate(margin.l - 40, margin.t + pH / 2);
ctx.rotate(-Math.PI / 2);
text(ctx, 'cwnd (segments)', 0, 0, C.dim, 'center');
ctx.restore();
// Run the classic Reno state machine
let cwnd = 1;
let ssthresh = s.values.ssthresh0;
const firstLoss = s.values.lossAt;
const lossInterval = 12; // RTTs between simulated losses
const points = [{ t: 0, cwnd, phase: 'slow' }];
let nextLoss = firstLoss;
let peak = cwnd;
for (let t = 1; t <= tMax; t += 1) {
if (t >= nextLoss) {
// Multiplicative decrease: ssthresh = cwnd / 2, cwnd resumes at ssthresh (fast recovery)
ssthresh = Math.max(2, Math.floor(cwnd / 2));
cwnd = ssthresh;
nextLoss += lossInterval;
points.push({ t, cwnd, phase: 'loss' });
continue;
}
if (cwnd < ssthresh) {
cwnd = cwnd * 2; // slow start: exponential
} else {
cwnd = cwnd + 1; // congestion avoidance: linear
}
if (cwnd > cwndMax) cwnd = cwndMax;
peak = Math.max(peak, cwnd);
points.push({ t, cwnd, phase: cwnd < ssthresh ? 'slow' : 'avoid' });
}
// Plot cwnd
ctx.strokeStyle = C.cyan;
ctx.lineWidth = 2;
ctx.beginPath();
points.forEach((p, i) => {
const x = xOf(p.t), y = yOf(p.cwnd);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
// Mark losses
points.filter(p => p.phase === 'loss').forEach(p => {
ctx.fillStyle = C.red;
ctx.beginPath();
ctx.arc(xOf(p.t), yOf(p.cwnd), 4, 0, Math.PI * 2);
ctx.fill();
text(ctx, 'loss', xOf(p.t) + 6, yOf(p.cwnd) - 4, C.red);
});
// Phase legend
text(ctx, 'slow start -> congestion avoidance -> loss -> repeat', margin.l + 6, margin.t + 14, C.dim);
s.setReadout('peak', `${peak.toFixed(0)} segments`);
s.setReadout('final', `${points[points.length - 1].cwnd.toFixed(0)} segments`);
requestAnimationFrame(() => {});
}
function loop() { draw(); requestAnimationFrame(loop); }
loop();
}
// ---------- Scene 5: UDP: send and forget ----------
function sceneUDP() {
const s = scene('scene-udp', {
height: 400,
controls: [
{ key: 'loss', label: 'Network loss rate', min: 0, max: 60, value: 15, step: 1, format: v => v.toFixed(0) + '%' },
],
readout: [
{ key: 'sent', label: 'Sent' },
{ key: 'rcvd', label: 'Received' },
{ key: 'recovery', label: 'Recovery' },
],
caption: 'UDP lives in the same IP packets that TCP does but deliberately refuses almost all of TCP\'s help. There is no handshake, no sequence tracking, no retransmission, no flow control, no congestion control. Packets either arrive or they don\'t, and if they don\'t, UDP would like you to deal with it. This sounds bad until you need to send voice, video, DNS queries, or game state, at which point it sounds wonderful.',
});
if (!s) return;
// Deterministic pseudo-random so the scene is reproducible.
function hash(seed, n) {
let x = Math.sin(seed * 9301 + n * 49297) * 233280;
return x - Math.floor(x);
}
const PACKET_DUR = 0.8;
const GAP = 0.15;
const PAUSE_END = 2.2;
const startWall = performance.now();
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const topY = 50, botY = h - 30;
const leftX = 90, rightX = w - 90;
drawTimelines(ctx, leftX, rightX, topY, botY, 'Client', 'Server');
const totalPackets = 10;
const lossPct = s.values.loss / 100;
const slant = 18;
const rowH = Math.min(28, (botY - topY - slant - 10) / totalPackets);
const cycleDur = totalPackets * (PACKET_DUR + GAP) + PAUSE_END;
const now = ((performance.now() - startWall) / 1000) % cycleDur;
let received = 0;
for (let i = 0; i < totalPackets; i++) {
const y = topY + 10 + i * rowH;
const lost = hash(1, i) < lossPct;
const dropFrac = 0.5 + (hash(2, i) - 0.5) * 0.3;
const t0 = i * (PACKET_DUR + GAP);
const t1 = t0 + PACKET_DUR;
const frac = Math.max(0, Math.min(1, (now - t0) / (t1 - t0)));
drawPartialPacket(ctx, leftX, rightX, y, y + slant, frac, `UDP seq=${i + 1}`, C.magenta, { lost, dropFrac });
// count received only once the packet has completed traveling
if (!lost && frac >= 1) received++;
}
// Time progress bar
const barY = 18, barX0 = 20, barX1 = w - 20;
ctx.strokeStyle = C.grid;
ctx.beginPath(); ctx.moveTo(barX0, barY); ctx.lineTo(barX1, barY); ctx.stroke();
ctx.fillStyle = C.magenta;
ctx.beginPath();
ctx.arc(barX0 + (barX1 - barX0) * (now / cycleDur), barY, 3, 0, Math.PI * 2);
ctx.fill();
// totals — show once the full cycle has played
const cycleFraction = now / cycleDur;
const totalCycled = cycleFraction > 0.95 ? totalPackets : Math.min(totalPackets, Math.ceil(now / (PACKET_DUR + GAP)));
let arrived = 0;
for (let i = 0; i < totalCycled; i++) {
const lost = hash(1, i) < lossPct;
if (!lost) arrived++;
}
s.setReadout('sent', `${totalCycled}`);
s.setReadout('rcvd', `${arrived}`);
s.setReadout('recovery', arrived === totalCycled ? 'none needed' : 'none (UDP does not care)');
}
function loop() { draw(); requestAnimationFrame(loop); }
loop();
}
function boot() {
const host = document.querySelector('.scene-host, [data-scene-host]');
if (host) host.classList.add('scene-host');
sceneHandshake();
sceneData();
sceneWindow();
sceneCongestion();
sceneUDP();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();