w5isp.com/static/js/subnetting.js
Graham McIntire 87368d28d7
Expand subnetting post: supernet scene + reserved ranges + layout fixes
Adds a fourth scene for route summarization: two /24s in a /16 parent,
slider to position each, draws the smallest supernet that covers both.
Demonstrates cleanly why only consecutive and power-of-two aligned
subnets aggregate without collateral.

Adds a prose section on reserved IPv4 ranges (RFC 1918 private,
loopback, link-local, multicast, limited broadcast, 0.0.0.0, RFC 6598
CGN space).

Fixes scene-samesubnet layout where the host title strips were
overlapping the bit rows; increases row gap so titles sit cleanly above
their rows. Clamps pill labels in scene-supernet so they stay on
canvas when subnets sit at the track edges, and fixes an off-by-one
tick label that read '10.20.256.0' at the right edge.

Also retitles the post.
2026-04-22 16:45:24 -05:00

814 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// IPv4 subnetting explainer. Vanilla JS + 2D canvas. Scoped to one post.
(() => {
'use strict';
const C = {
bg: '#1a1d24',
panel: '#242932',
fg: '#d8dce4',
dim: '#8189a0',
grid: '#2d323d',
gridStrong: '#3a414e',
blue: '#78b5f3',
cyan: '#6dc5d3',
green: '#9dca83',
amber: '#d6a86a',
red: '#e58089',
yellow: '#e2c37d',
magenta: '#c78de0',
};
// ---- framework ----
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, family = 'system-ui, sans-serif') {
ctx.save();
ctx.fillStyle = color;
ctx.font = `${size}px ${family}`;
ctx.textAlign = align;
ctx.textBaseline = baseline;
ctx.fillText(str, x, y);
ctx.restore();
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x, y, w, h, r);
else ctx.rect(x, y, w, h);
}
function pillLabel(ctx, str, x, y, color = C.fg, align = 'left', baseline = 'middle', size = 12) {
ctx.save();
ctx.font = `${size}px system-ui, sans-serif`;
ctx.textAlign = align;
ctx.textBaseline = baseline;
const tw = ctx.measureText(str).width;
const padX = 5, padY = 3;
const h = size + 2 + padY * 2;
let bx;
if (align === 'right') bx = x - tw - padX;
else if (align === 'center') bx = x - tw / 2 - padX;
else bx = x - padX;
let by;
if (baseline === 'middle') by = y - h / 2;
else if (baseline === 'top') by = y - padY;
else by = y - size - padY;
ctx.fillStyle = 'rgba(26, 29, 36, 0.82)';
roundRect(ctx, bx, by, tw + padX * 2, h, 3); ctx.fill();
ctx.fillStyle = color;
ctx.fillText(str, x, y);
ctx.restore();
}
// ---- subnet math ----
// addresses stored as unsigned 32-bit ints (0..0xFFFFFFFF)
function ipToStr(n) {
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.');
}
function ipBits(n) {
let s = '';
for (let i = 31; i >= 0; i--) s += ((n >>> i) & 1);
return s;
}
function maskFor(prefix) {
// >>> 0 keeps it unsigned; prefix 0 is the special case.
if (prefix === 0) return 0;
return (0xFFFFFFFF << (32 - prefix)) >>> 0;
}
function networkOf(ip, prefix) {
return (ip & maskFor(prefix)) >>> 0;
}
function broadcastOf(ip, prefix) {
return (networkOf(ip, prefix) | (~maskFor(prefix) >>> 0)) >>> 0;
}
// ============================================================
// Scene 1: Bits and mask
// ============================================================
function sceneBits() {
const s = scene('scene-bits', {
height: 360,
controls: [
{ key: 'o1', label: 'Address: octet 1', min: 0, max: 255, value: 192, step: 1, format: v => v.toFixed(0) },
{ key: 'o2', label: 'Address: octet 2', min: 0, max: 255, value: 168, step: 1, format: v => v.toFixed(0) },
{ key: 'o3', label: 'Address: octet 3', min: 0, max: 255, value: 10, step: 1, format: v => v.toFixed(0) },
{ key: 'o4', label: 'Address: octet 4', min: 0, max: 255, value: 37, step: 1, format: v => v.toFixed(0) },
{ key: 'prefix', label: 'Prefix length', min: 0, max: 32, value: 24, step: 1, format: v => '/' + v.toFixed(0) },
],
readout: [
{ key: 'cidr', label: 'CIDR' },
{ key: 'mask', label: 'Mask' },
{ key: 'net', label: 'Network' },
{ key: 'bcast', label: 'Broadcast' },
{ key: 'range', label: 'Usable' },
{ key: 'hosts', label: 'Usable hosts' },
],
caption: 'Every IPv4 address is 32 bits. The prefix length picks where the network portion ends and the host portion begins. The subnet mask is just a second 32-bit number with ones on the network side and zeros on the host side.',
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const prefix = Math.round(s.values.prefix);
const ip = ((s.values.o1 << 24) | (s.values.o2 << 16) | (s.values.o3 << 8) | s.values.o4) >>> 0;
const mask = maskFor(prefix);
const net = networkOf(ip, prefix);
const bcast = broadcastOf(ip, prefix);
// Layout: one row for the address bits, one for the mask bits, one for the network address.
const margin = { l: 20, r: 20, t: 20, b: 20 };
const colW = (w - margin.l - margin.r - 30) / 32; // 30px for the label column
const rowH = 28;
const gapY = 8;
const labelW = 110;
const firstBitX = margin.l + labelW;
function drawRow(y, bitsStr, rowLabel, networkColor, hostColor, textColor) {
text(ctx, rowLabel, margin.l + 4, y + rowH / 2, C.dim, 'left', 'middle', 12);
for (let i = 0; i < 32; i++) {
const bit = bitsStr[i];
const isNet = i < prefix;
const x = firstBitX + i * colW;
ctx.fillStyle = isNet ? networkColor : hostColor;
roundRect(ctx, x + 1, y + 1, colW - 2, rowH - 2, 3); ctx.fill();
text(ctx, bit, x + colW / 2, y + rowH / 2 + 0.5, textColor, 'center', 'middle', Math.max(10, Math.min(14, colW - 4)));
// octet separators
if (i % 8 === 7 && i !== 31) {
ctx.strokeStyle = C.grid;
ctx.beginPath();
ctx.moveTo(x + colW, y - 2);
ctx.lineTo(x + colW, y + rowH + 2);
ctx.stroke();
}
}
}
// Title strip with dotted-decimal + CIDR
text(ctx, `${ipToStr(ip)}/${prefix}`, margin.l + 4, margin.t + 4, C.fg, 'left', 'top', 16, 'ui-monospace, SFMono-Regular, Menlo, monospace');
text(ctx, `mask ${ipToStr(mask)}`, w - margin.r - 4, margin.t + 4, C.dim, 'right', 'top', 13, 'ui-monospace, SFMono-Regular, Menlo, monospace');
// Prefix divider bar
const rowsY = margin.t + 32;
// Address row
drawRow(rowsY, ipBits(ip), 'address', 'rgba(120,181,243,0.22)', 'rgba(157,202,131,0.18)', C.fg);
// Mask row
drawRow(rowsY + rowH + gapY, ipBits(mask), 'mask', 'rgba(120,181,243,0.22)', 'rgba(129,137,160,0.12)', C.fg);
// Network row
drawRow(rowsY + 2 * (rowH + gapY), ipBits(net), 'network', 'rgba(120,181,243,0.22)', 'rgba(129,137,160,0.08)', C.fg);
// Broadcast row
drawRow(rowsY + 3 * (rowH + gapY), ipBits(bcast), 'broadcast', 'rgba(120,181,243,0.22)', 'rgba(214,168,106,0.22)', C.fg);
// Draw the prefix divider line
const divX = firstBitX + prefix * colW;
ctx.save();
ctx.strokeStyle = C.yellow;
ctx.lineWidth = 2;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(divX, rowsY - 6);
ctx.lineTo(divX, rowsY + 4 * (rowH + gapY) - gapY + 4);
ctx.stroke();
ctx.restore();
// Divider label
pillLabel(ctx, `prefix /${prefix}`, divX, rowsY - 10, C.yellow, 'center', 'middle', 11);
// Legend beneath bit rows
const legY = rowsY + 4 * (rowH + gapY) + 6;
const sq = (x, color, label) => {
ctx.fillStyle = color;
roundRect(ctx, x, legY, 12, 12, 2); ctx.fill();
text(ctx, label, x + 18, legY + 6, C.dim, 'left', 'middle', 11);
};
sq(firstBitX, 'rgba(120,181,243,0.55)', 'network portion');
sq(firstBitX + 150, 'rgba(157,202,131,0.55)', 'host portion');
// Totals on the right below the legend
const total = prefix >= 32 ? 1 : Math.pow(2, 32 - prefix);
const usable = prefix >= 31 ? (prefix === 32 ? 1 : 2) : total - 2;
const usableNote = prefix === 31 ? '(/31: both usable per RFC 3021)' : prefix === 32 ? '(/32: host route)' : '';
text(ctx, `2^${32 - prefix} = ${total.toLocaleString()} total / ${usable.toLocaleString()} usable ${usableNote}`,
w - margin.r - 4, legY + 6, C.dim, 'right', 'middle', 12);
// readouts
s.setReadout('cidr', `${ipToStr(ip)}/${prefix}`);
s.setReadout('mask', ipToStr(mask));
s.setReadout('net', ipToStr(net));
s.setReadout('bcast', ipToStr(bcast));
if (prefix >= 31) {
s.setReadout('range', prefix === 32 ? `${ipToStr(net)} only` : `${ipToStr(net)} ${ipToStr(bcast)}`);
} else {
s.setReadout('range', `${ipToStr((net + 1) >>> 0)} ${ipToStr((bcast - 1) >>> 0)}`);
}
s.setReadout('hosts', usable.toLocaleString());
requestAnimationFrame(draw);
}
draw();
}
// ============================================================
// Scene 2: VLSM carving of a /22
// ============================================================
function sceneVLSM() {
// Predefined request lists. The scene greedy-allocates in order from the parent /22.
const strategies = [
{ label: 'four /24s', requests: [24, 24, 24, 24] },
{ label: '/23 + two /24s', requests: [23, 24, 24] },
{ label: '/24 + two /25s + /26 + /27', requests: [24, 25, 25, 26, 27] },
{ label: 'five /26s + four /28s', requests: [26, 26, 26, 26, 26, 28, 28, 28, 28] },
{ label: 'sixteen /26s', requests: Array.from({ length: 16 }, () => 26) },
{ label: 'many /30 point-to-points', requests: Array.from({ length: 20 }, () => 30) },
];
const s = scene('scene-vlsm', {
height: 360,
controls: [
{ key: 'strat', label: 'Allocation strategy', min: 0, max: strategies.length - 1, value: 2, step: 1,
format: v => strategies[Math.round(v)].label },
{ key: 'basePrefix', label: 'Parent block prefix', min: 20, max: 26, value: 22, step: 1,
format: v => '/' + v.toFixed(0) },
],
readout: [
{ key: 'parent', label: 'Parent' },
{ key: 'allocated', label: 'Allocated' },
{ key: 'wasted', label: 'Unused' },
{ key: 'fit', label: 'Requests honored' },
],
caption: 'Greedy VLSM allocation: largest subnet first, each aligned to its own size boundary. Hover or read the labels to see each subnet\'s prefix, network address, and usable host count. The slider on the right lets you change the parent block size so you can see when the same strategy runs out of room.',
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const strat = strategies[Math.round(s.values.strat)];
const basePrefix = Math.round(s.values.basePrefix);
const parentBase = 0xC0A80000; // 192.168.0.0
const parentSize = Math.pow(2, 32 - basePrefix);
const parentEnd = parentBase + parentSize;
// Greedy allocation
const allocated = [];
let cursor = parentBase;
let honored = 0;
for (const reqPrefix of strat.requests) {
if (reqPrefix < basePrefix) break; // bigger than parent
const size = Math.pow(2, 32 - reqPrefix);
// Align cursor up to multiple of `size`
const aligned = Math.ceil(cursor / size) * size;
if (aligned + size > parentEnd) break;
allocated.push({ start: aligned, size, prefix: reqPrefix });
cursor = aligned + size;
honored++;
}
const used = allocated.reduce((a, b) => a + b.size, 0);
const wasted = parentSize - used;
const margin = { l: 20, r: 20, t: 28, b: 20 };
const pW = w - margin.l - margin.r;
const pY = margin.t + 44;
const pH = 70;
// Parent block frame
ctx.save();
ctx.fillStyle = 'rgba(120,181,243,0.05)';
roundRect(ctx, margin.l, pY - 18, pW, pH + 34, 6); ctx.fill();
ctx.strokeStyle = C.grid;
ctx.stroke();
ctx.restore();
// Top label
text(ctx, `parent block: ${ipToStr(parentBase)}/${basePrefix} (${parentSize.toLocaleString()} addresses)`,
margin.l + 4, margin.t, C.fg, 'left', 'top', 13, 'ui-monospace, SFMono-Regular, Menlo, monospace');
// Ruler ticks at boundaries (every /24 boundary if visible, else every /N-2)
const rulerY = pY - 4;
const tickBits = Math.min(basePrefix + 2, 30);
const tickSize = Math.pow(2, 32 - tickBits);
ctx.strokeStyle = C.grid;
ctx.lineWidth = 1;
for (let a = parentBase; a <= parentEnd; a += tickSize) {
const x = margin.l + ((a - parentBase) / parentSize) * pW;
ctx.beginPath();
ctx.moveTo(x, rulerY - 3);
ctx.lineTo(x, rulerY);
ctx.stroke();
}
// Colors for subnets (cycle)
const palette = [C.blue, C.cyan, C.green, C.amber, C.magenta, C.yellow];
// Draw allocated blocks
allocated.forEach((a, i) => {
const x = margin.l + ((a.start - parentBase) / parentSize) * pW;
const bw = (a.size / parentSize) * pW;
const col = palette[i % palette.length];
ctx.fillStyle = col + '66'; // translucent
roundRect(ctx, x + 1, pY + 1, bw - 2, pH - 2, 4); ctx.fill();
ctx.strokeStyle = col;
ctx.lineWidth = 1;
roundRect(ctx, x + 1, pY + 1, bw - 2, pH - 2, 4); ctx.stroke();
// Label if wide enough
const label = `/${a.prefix}`;
const sub = ipToStr(a.start);
const hosts = a.prefix >= 31 ? (a.prefix === 32 ? 1 : 2) : a.size - 2;
ctx.save();
ctx.fillStyle = C.fg;
ctx.font = '12px ui-monospace, SFMono-Regular, Menlo, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (bw > 70) {
ctx.fillText(label, x + bw / 2, pY + 18);
ctx.fillStyle = C.dim;
ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace';
ctx.fillText(sub, x + bw / 2, pY + 36);
ctx.fillText(`${hosts.toLocaleString()} hosts`, x + bw / 2, pY + 52);
} else if (bw > 30) {
ctx.fillText(label, x + bw / 2, pY + pH / 2);
} else {
// too narrow to label
}
ctx.restore();
});
// Draw unused area (diagonal stripe)
if (used < parentSize) {
const startX = margin.l + (used / parentSize) * pW;
const endX = margin.l + pW;
ctx.save();
ctx.fillStyle = 'rgba(129,137,160,0.10)';
roundRect(ctx, startX + 1, pY + 1, endX - startX - 2, pH - 2, 4); ctx.fill();
// Diagonal hatch
ctx.strokeStyle = 'rgba(129,137,160,0.25)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let xx = startX - pH; xx < endX; xx += 8) {
ctx.moveTo(xx, pY + pH);
ctx.lineTo(xx + pH, pY);
}
ctx.stroke();
if (endX - startX > 60) {
text(ctx, 'unused', (startX + endX) / 2, pY + pH / 2, C.dim, 'center', 'middle', 12);
}
ctx.restore();
}
// Request list (bottom)
const listY = pY + pH + 28;
text(ctx, 'Requests (in order):', margin.l + 4, listY, C.dim, 'left', 'middle', 12);
let rx = margin.l + 140;
strat.requests.forEach((reqP, i) => {
const honored_i = i < honored;
const col = honored_i ? C.fg : C.red;
const label = '/' + reqP;
ctx.save();
ctx.font = '12px ui-monospace, SFMono-Regular, Menlo, monospace';
const tw = ctx.measureText(label).width + 10;
if (rx + tw > w - margin.r - 4) { rx = margin.l + 140; /* wrap line not needed often */ }
ctx.fillStyle = honored_i ? 'rgba(157,202,131,0.18)' : 'rgba(229,128,137,0.18)';
roundRect(ctx, rx - 3, listY - 10, tw, 20, 4); ctx.fill();
ctx.fillStyle = col;
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(label, rx, listY);
ctx.restore();
rx += tw + 4;
});
if (honored < strat.requests.length) {
text(ctx, `(${strat.requests.length - honored} didn't fit)`, margin.l + 4, listY + 22, C.red, 'left', 'middle', 11);
}
// Readouts
s.setReadout('parent', `${ipToStr(parentBase)}/${basePrefix}`);
s.setReadout('allocated', `${used.toLocaleString()} addrs`);
s.setReadout('wasted', `${wasted.toLocaleString()} addrs`);
s.setReadout('fit', `${honored} / ${strat.requests.length}`);
requestAnimationFrame(draw);
}
draw();
}
// ============================================================
// Scene 3: Same-subnet decision
// ============================================================
function sceneSameSubnet() {
const s = scene('scene-samesubnet', {
height: 380,
controls: [
{ key: 'aOct3', label: 'Host A: octet 3', min: 0, max: 255, value: 5, step: 1, format: v => v.toFixed(0) },
{ key: 'aOct4', label: 'Host A: octet 4', min: 1, max: 254, value: 17, step: 1, format: v => v.toFixed(0) },
{ key: 'bOct3', label: 'Host B: octet 3', min: 0, max: 255, value: 5, step: 1, format: v => v.toFixed(0) },
{ key: 'bOct4', label: 'Host B: octet 4', min: 1, max: 254, value: 200, step: 1, format: v => v.toFixed(0) },
{ key: 'prefix', label: 'Shared prefix', min: 16, max: 30, value: 24, step: 1, format: v => '/' + v.toFixed(0) },
],
readout: [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B' },
{ key: 'netA', label: 'A network' },
{ key: 'netB', label: 'B network' },
{ key: 'decision', label: 'Delivery' },
],
caption: "The two hosts share the base 10.0.x.x/16 space. The shared prefix determines where the network/host line falls. If both addresses' network bits match, the hosts deliver directly (one ARP, one frame). If they don't, every packet crosses the default gateway first.",
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const base = 0x0A000000; // 10.0.0.0
const prefix = Math.round(s.values.prefix);
const ipA = (base | (s.values.aOct3 << 8) | s.values.aOct4) >>> 0;
const ipB = (base | (s.values.bOct3 << 8) | s.values.bOct4) >>> 0;
const mask = maskFor(prefix);
const netA = (ipA & mask) >>> 0;
const netB = (ipB & mask) >>> 0;
const same = netA === netB;
// Layout: bits at top (3 rows: A, B, mask), decision diagram at bottom
const margin = { l: 20, r: 20, t: 14, b: 14 };
const colW = (w - margin.l - margin.r - 100) / 32;
const firstBitX = margin.l + 100;
const rowH = 22;
const gapY = 22; // room for the title strip above A and B rows
const bitsA = ipBits(ipA);
const bitsB = ipBits(ipB);
const bitsMask = ipBits(mask);
function rowBits(y, label, bits, compareBits) {
text(ctx, label, margin.l + 4, y + rowH / 2, C.dim, 'left', 'middle', 12);
for (let i = 0; i < 32; i++) {
const x = firstBitX + i * colW;
const isNet = i < prefix;
const b = bits[i];
let bg;
if (!isNet) {
bg = 'rgba(129,137,160,0.10)';
} else if (compareBits && compareBits[i] === b) {
bg = 'rgba(157,202,131,0.28)';
} else if (compareBits) {
bg = 'rgba(229,128,137,0.28)';
} else {
bg = 'rgba(120,181,243,0.22)';
}
ctx.fillStyle = bg;
roundRect(ctx, x + 1, y + 1, colW - 2, rowH - 2, 3); ctx.fill();
text(ctx, b, x + colW / 2, y + rowH / 2 + 0.5, C.fg, 'center', 'middle', Math.max(10, Math.min(13, colW - 4)));
if (i % 8 === 7 && i !== 31) {
ctx.strokeStyle = C.grid;
ctx.beginPath();
ctx.moveTo(x + colW, y - 2); ctx.lineTo(x + colW, y + rowH + 2); ctx.stroke();
}
}
}
const rowsY = margin.t + 12;
const rowAY = rowsY;
const rowBY = rowAY + rowH + gapY;
const rowMY = rowBY + rowH + gapY;
// Title strips above A and B rows (no title for mask; its own label in the row)
text(ctx, `Host A: ${ipToStr(ipA)}/${prefix}`, margin.l + 4, rowAY - 3, C.cyan, 'left', 'bottom', 11, 'ui-monospace, SFMono-Regular, Menlo, monospace');
text(ctx, `Host B: ${ipToStr(ipB)}/${prefix}`, margin.l + 4, rowBY - 3, C.amber, 'left', 'bottom', 11, 'ui-monospace, SFMono-Regular, Menlo, monospace');
text(ctx, `mask /${prefix}`, margin.l + 4, rowMY - 3, C.dim, 'left', 'bottom', 11, 'ui-monospace, SFMono-Regular, Menlo, monospace');
rowBits(rowAY, 'A', bitsA, bitsB);
rowBits(rowBY, 'B', bitsB, bitsA);
rowBits(rowMY, 'mask', bitsMask, null);
// Prefix divider line
const divX = firstBitX + prefix * colW;
ctx.save();
ctx.strokeStyle = C.yellow;
ctx.lineWidth = 2;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(divX, rowAY - 14); ctx.lineTo(divX, rowMY + rowH + 2);
ctx.stroke();
ctx.restore();
pillLabel(ctx, `/${prefix}`, divX, rowAY - 20, C.yellow, 'center', 'middle', 11);
// Decision diagram at the bottom
const diagY = rowMY + rowH + 24;
const diagH = h - diagY - margin.b;
if (diagH > 60) {
const cx = w / 2;
const yMid = diagY + diagH / 2;
// Draw two hosts and a switch/gateway
function box(x, y, bw, bh, col, label, sub) {
ctx.save();
ctx.fillStyle = col + '22';
roundRect(ctx, x - bw / 2, y - bh / 2, bw, bh, 5); ctx.fill();
ctx.strokeStyle = col; ctx.lineWidth = 1.5; ctx.stroke();
ctx.fillStyle = col;
ctx.font = '13px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(label, x, y - 8);
ctx.fillStyle = C.dim;
ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace';
ctx.fillText(sub, x, y + 10);
ctx.restore();
}
const hostW = 140, hostH = 56;
const aX = margin.l + hostW / 2 + 10, bX = w - margin.r - hostW / 2 - 10;
box(aX, yMid, hostW, hostH, C.cyan, 'Host A', ipToStr(ipA));
box(bX, yMid, hostW, hostH, C.amber, 'Host B', ipToStr(ipB));
if (same) {
// direct line
ctx.strokeStyle = C.green;
ctx.lineWidth = 2.5;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(aX + hostW / 2, yMid);
ctx.lineTo(bX - hostW / 2, yMid);
ctx.stroke();
pillLabel(ctx, 'same subnet -> direct (ARP, send)', cx, yMid - 14, C.green, 'center', 'middle', 12);
pillLabel(ctx, `both in ${ipToStr(netA)}/${prefix}`, cx, yMid + 16, C.dim, 'center', 'middle', 11);
} else {
// via gateway
const gwW = 130, gwH = 56;
box(cx, yMid, gwW, gwH, C.red, 'Gateway', 'default route');
ctx.strokeStyle = C.red;
ctx.lineWidth = 2.5;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(aX + hostW / 2, yMid); ctx.lineTo(cx - gwW / 2, yMid);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(cx + gwW / 2, yMid); ctx.lineTo(bX - hostW / 2, yMid);
ctx.stroke();
ctx.setLineDash([]);
pillLabel(ctx, 'different subnets -> via gateway', cx, yMid - 42, C.red, 'center', 'middle', 12);
pillLabel(ctx, `A net ${ipToStr(netA)}/${prefix} | B net ${ipToStr(netB)}/${prefix}`, cx, yMid + 42, C.dim, 'center', 'middle', 11);
}
}
s.setReadout('a', `${ipToStr(ipA)}/${prefix}`);
s.setReadout('b', `${ipToStr(ipB)}/${prefix}`);
s.setReadout('netA', ipToStr(netA));
s.setReadout('netB', ipToStr(netB));
s.setReadout('decision', same ? 'DIRECT (same subnet)' : 'GATEWAY (different subnets)');
requestAnimationFrame(draw);
}
draw();
}
// ============================================================
// Scene 4: Supernetting / route summarization
// ============================================================
function sceneSupernet() {
const s = scene('scene-supernet', {
height: 340,
controls: [
{ key: 'aIdx', label: 'Subnet A (10.20.x.0/24)', min: 0, max: 255, value: 4, step: 1, format: v => '10.20.' + v.toFixed(0) + '.0/24' },
{ key: 'bIdx', label: 'Subnet B (10.20.x.0/24)', min: 0, max: 255, value: 5, step: 1, format: v => '10.20.' + v.toFixed(0) + '.0/24' },
],
readout: [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B' },
{ key: 'super', label: 'Smallest supernet' },
{ key: 'covered', label: 'Covered' },
{ key: 'wasted', label: 'Collateral' },
],
caption: "Supernetting is the inverse of VLSM: take several contiguous subnets and find the smallest single prefix that covers them all. The only time this works cleanly is when the subnets are adjacent AND the group starts on a boundary aligned to its own combined size. When they aren't, the supernet balloons up to include every address in between, which is why you can't just 'summarize' any two subnets you happen to own.",
});
if (!s) return;
// Find the shortest prefix whose block contains both addresses.
function commonPrefixLen(a, b) {
let x = (a ^ b) >>> 0;
if (x === 0) return 32;
let p = 0;
while (x !== 0) { x = x >>> 1; p++; }
return 32 - p;
}
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const aIdx = Math.round(s.values.aIdx);
const bIdx = Math.round(s.values.bIdx);
const parent = 0x0A140000; // 10.20.0.0/16
const ipA = (parent | (aIdx << 8)) >>> 0;
const ipB = (parent | (bIdx << 8)) >>> 0;
const subnetSize = 256;
// Smallest supernet containing both. A /24 is 256 addrs so the lowest
// prefix worth considering is /24 (if A === B) or shorter.
const lo = Math.min(ipA, ipB);
const hi = (Math.max(ipA, ipB) + subnetSize - 1) >>> 0;
// Need a prefix P such that (lo & mask) == (hi & mask).
let superPrefix = commonPrefixLen(lo, hi);
if (superPrefix > 24) superPrefix = 24;
const superMask = maskFor(superPrefix);
const superStart = (lo & superMask) >>> 0;
const superSize = Math.pow(2, 32 - superPrefix);
// Layout: a number line spanning 10.20.0.0/16 with 256 tick positions
const margin = { l: 20, r: 20, t: 36, b: 14 };
const pW = w - margin.l - margin.r;
const trackY = margin.t + 50;
const trackH = 56;
const slotW = pW / 256;
// Parent frame
ctx.save();
ctx.fillStyle = 'rgba(120,181,243,0.04)';
roundRect(ctx, margin.l, trackY, pW, trackH, 5); ctx.fill();
ctx.strokeStyle = C.grid;
ctx.stroke();
ctx.restore();
text(ctx, `parent space: 10.20.0.0/16 (each tick = one /24, 256 slots)`,
margin.l + 4, margin.t - 4, C.dim, 'left', 'bottom', 12, 'ui-monospace, SFMono-Regular, Menlo, monospace');
// Supernet overlay
const superStartIdx = (superStart - parent) >>> 8;
const superSlots = superSize / 256;
if (superPrefix < 16) {
// wider than parent - rare edge case, clamp to parent
ctx.fillStyle = 'rgba(229,128,137,0.15)';
roundRect(ctx, margin.l, trackY - 10, pW, trackH + 20, 5); ctx.fill();
} else {
const sx = margin.l + superStartIdx * slotW;
const sw = superSlots * slotW;
ctx.fillStyle = 'rgba(229,128,137,0.14)';
roundRect(ctx, sx, trackY - 10, sw, trackH + 20, 5); ctx.fill();
ctx.strokeStyle = C.red;
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
roundRect(ctx, sx, trackY - 10, sw, trackH + 20, 5); ctx.stroke();
ctx.setLineDash([]);
// Supernet label - clamp horizontally so it stays within the canvas
const labelText = `supernet: ${ipToStr(superStart)}/${superPrefix} (${superSize.toLocaleString()} addresses)`;
ctx.save();
ctx.font = '11px system-ui, sans-serif';
const labelW = ctx.measureText(labelText).width + 14;
ctx.restore();
let labelX = sx + sw / 2;
const minX = margin.l + labelW / 2 + 2;
const maxX = w - margin.r - labelW / 2 - 2;
if (labelX < minX) labelX = minX;
if (labelX > maxX) labelX = maxX;
pillLabel(ctx, labelText, labelX, trackY - 22, C.red, 'center', 'middle', 11);
}
// Tick marks every 16 slots
ctx.strokeStyle = C.grid;
ctx.lineWidth = 1;
for (let i = 0; i <= 256; i += 16) {
const x = margin.l + i * slotW;
ctx.beginPath();
ctx.moveTo(x, trackY + trackH);
ctx.lineTo(x, trackY + trackH + 4);
ctx.stroke();
if (i % 64 === 0 && i < 256) {
text(ctx, `10.20.${i}.0`, x, trackY + trackH + 6, C.dim, i === 0 ? 'left' : 'center', 'top', 10, 'ui-monospace, SFMono-Regular, Menlo, monospace');
}
if (i === 256) {
text(ctx, `10.21.0.0`, x, trackY + trackH + 6, C.dim, 'right', 'top', 10, 'ui-monospace, SFMono-Regular, Menlo, monospace');
}
}
function clampLabelX(x, estWidth) {
const minX = margin.l + estWidth / 2 + 2;
const maxX = w - margin.r - estWidth / 2 - 2;
return Math.max(minX, Math.min(maxX, x));
}
// Draw subnet A
const axStart = margin.l + aIdx * slotW;
ctx.fillStyle = C.cyan + 'BB';
roundRect(ctx, axStart + 0.5, trackY + 4, Math.max(2, slotW - 1), trackH - 8, 2); ctx.fill();
const aLabelW = 140;
pillLabel(ctx, `A: 10.20.${aIdx}.0/24`, clampLabelX(axStart + slotW / 2, aLabelW), trackY + trackH + 22, C.cyan, 'center', 'middle', 11);
// Draw subnet B
const bxStart = margin.l + bIdx * slotW;
ctx.fillStyle = C.amber + 'BB';
roundRect(ctx, bxStart + 0.5, trackY + 4, Math.max(2, slotW - 1), trackH - 8, 2); ctx.fill();
// Offset B's label vertically if close to A horizontally (so pills don't overlap)
const labelYB = Math.abs(aIdx - bIdx) < 30 ? trackY + trackH + 40 : trackY + trackH + 22;
pillLabel(ctx, `B: 10.20.${bIdx}.0/24`, clampLabelX(bxStart + slotW / 2, aLabelW), labelYB, C.amber, 'center', 'middle', 11);
// Covered / collateral numbers
const covered = 2 * subnetSize;
const collateral = superSize - covered;
s.setReadout('a', `10.20.${aIdx}.0/24`);
s.setReadout('b', `10.20.${bIdx}.0/24`);
s.setReadout('super', `${ipToStr(superStart)}/${superPrefix}`);
s.setReadout('covered', `${covered.toLocaleString()} addrs`);
s.setReadout('wasted', `${collateral.toLocaleString()} addrs swept in`);
requestAnimationFrame(draw);
}
draw();
}
// ---- init ----
function init() {
sceneBits();
sceneVLSM();
sceneSameSubnet();
sceneSupernet();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();