w5isp.com/static/js/subnetting.js
Graham McIntire 805560000f
Draft subnetting post + Atom/RSS feed autodiscovery
Adds a draft of subnetting-ipv4 (scheduled 2026-06-05) with three
interactive scenes: bit/mask visualization with prefix slider, greedy
VLSM carving of a parent block into variable-size children, and the
same-subnet-vs-gateway decision every host makes on every outbound
packet.

Also adds IDEAS entries for layer 2/3 routing and IPv4 subnetting.

Enables Zola feed generation (Atom + RSS) and adds autodiscovery link
tags in the base template. Sets site description and author so feed
entries have a real byline instead of "Unknown".
2026-04-22 16:35:46 -05:00

653 lines
26 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 = 24;
const gapY = 6;
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 + 4;
// A title strip above the bits
text(ctx, `Host A: ${ipToStr(ipA)}/${prefix}`, margin.l + 4, rowsY - 2, C.cyan, 'left', 'bottom', 12, 'ui-monospace, SFMono-Regular, Menlo, monospace');
rowBits(rowsY + 6, 'A', bitsA, bitsB);
text(ctx, `Host B: ${ipToStr(ipB)}/${prefix}`, margin.l + 4, rowsY + 6 + rowH + gapY - 2, C.amber, 'left', 'bottom', 12, 'ui-monospace, SFMono-Regular, Menlo, monospace');
rowBits(rowsY + 6 + rowH + gapY + 14, 'B', bitsB, bitsA);
rowBits(rowsY + 6 + 2 * (rowH + gapY) + 14, '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, rowsY); ctx.lineTo(divX, rowsY + 3 * (rowH + gapY) + 20);
ctx.stroke();
ctx.restore();
pillLabel(ctx, `/${prefix}`, divX, rowsY - 4, C.yellow, 'center', 'middle', 11);
// Decision diagram at the bottom
const diagY = rowsY + 3 * (rowH + gapY) + 40;
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();
}
// ---- init ----
function init() {
sceneBits();
sceneVLSM();
sceneSameSubnet();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();