Module map and load order
One IIFE per file, all hanging off window.Subnetris. The script order in index.html is the dependency order, and scan-deps.json asserts it. A lower module never names a higher one.
| Module | Responsibility |
|---|---|
rng.js | FNV-1a into mulberry32, derived streams, the tier and flag mix. No dependencies. |
net.js | IPv4 arithmetic. DOM-free, Number-based, lecture usable counts. |
plan.js | The addressing plan inside one subnet. Conventions A, B and C made checkable. Pure. |
model.js | The block tree, the cut primitive, requirements, drill state, undo. No DOM. |
codes.js | The hash grammar, the calculator query form, and the single validator. |
exercises.js | The library and the picker's categories, an RFC 4180 reader and writer for both files, and the built-in fallbacks. |
gen.js | Seeded random exercises by tier from a code. |
check.js | The hierarchy checker and the reference solver. |
drill.js | Expected values, answer normalisation, grading. |
render.js | Pure. Model in, SVG or HTML out. No handlers, no state. |
ui.js | The router, the pointer state machine, the modals, the refresh pipeline. |
theme.js | Preferences, layout persistence, resizers, the Firefox reset. |
menu.js | The drawer, its flyouts and its state badges. |
app.js | Boot, the address bar, the first-run card. Carries the boot guard. |
The render.js and ui.js split is the one that matters: render is pure and returns elements, ui owns every handler and all state. That is what keeps ui from turning into the whole application.
The model
Paths and derived numbers
A node is { kids: null | [left, right], req: null | '<id>' } and nothing else. A node is addressed by a path of 0 and 1 characters from the root, so '' is the whole allocation and '01' is the right child of the left child.
Nothing derivable is stored. The prefix is the root prefix plus the path length; the size is two to the power of thirty-two minus the prefix; the network number is the root network plus each set bit of the path weighted by that level's child size. Address order and pre-order are the same walk, which is why the table and the diagram never have to sort anything.
Assignments live on the node rather than in a side table. That is what makes rejoin free: collapsing a subtree releases whatever was assigned inside it with no bookkeeping.
The mutation chokepoint
Every mutation runs through one internal commit(kind, fn, opts). It pushes an undo snapshot unless the change is marked silent, runs the change, prunes drill answers whose block no longer exists, clears a selection that has stopped being a leaf, bumps the revision and notifies the listeners. The hash writer and the whole re-render hang off that notification, so there is no path that changes the model without the screen and the address bar following.
Silent mutations are the ones a student would not expect to undo: typing in a drill cell, moving the selection, changing the focus.
The cut primitive
cutAt(addr, opts) is the single function behind the drag, the click and the keyboard. It finds the leaf containing the address, refuses when that leaf is already at the floor or when the point is within half a floor-step of an edge, then enumerates the legal cuts inside the leaf: the midpoint at depth one, the quarter points at depth two, and so on. The application always cuts at depth one, since every gesture is one halving; the deeper candidates are enumerated anyway so the readout can say what a nearer boundary would cost, and opts.maxDepth lets the harnesses exercise them.
The search is O(depth), not O(2^depth). At each depth only the odd multiples of the step are new, because the even ones were already scored one level up, and only the three candidates nearest the requested address are worth considering. Ties go to the shallower cut, which is why pointing exactly halfway between the midpoint and a quarter point gives you the midpoint.
The returned plan carries the requested and the snapped address, every halving as { path, from, to, at }, and a deeper list of the nearer boundaries that more halvings would reach. That list is computed against the floor regardless of the snap setting, and only keeps a boundary that is strictly nearer than the one before it, so the explanation never offers a deeper cut that does not actually get you closer.
What a need really needs
A requirement carries hosts, a growth percentage and a gwExtra flag, and the number the checker measures against is none of those on its own. model.hostsNeeded(req) is the single place that combines them: growth is applied to the host count and rounded up, then one is added if the gateway is not already part of the count. model.hostsSteps(req) returns the same working as { base, growth, grown, gwExtra, total }, which is what lets check.needSentence narrate the arithmetic without repeating it.
The total is never rendered beside the need. Computing it is the exercise, so render.reqList shows the terms and the stated count and nothing else; the check report is the only surface that prints the answer. A harness asserts that the panel text does not contain it.
Growth and the gateway flag are dropped whenever the host count is null, in the parser, in addReq and in updateReq. They are arithmetic on a number, and with no number they are state nothing would ever read.
Share links
The hash is human-readable with a fixed key order and default elision, so the default page has an empty address bar and a byte-stable link otherwise.
| Key | Meaning | Default |
|---|---|---|
n | The allocation in CIDR form | 10.200.5.0/24 |
d | The division string, pre-order, 1 split, 0 leaf | 0 |
r | name:hosts with the exercise file's own + modifiers, names URI-encoded | empty, and elided under an exercise |
a | index:path assignments | empty |
x | An exercise id or a random code | absent |
f | The floor prefix | 30 |
c | The conventions in force, key:value pairs joined by ;, only the keys that differ from the lecture's defaults | elided at the defaults |
p | Addressing plans, one record per need | elided while every plan is empty |
q | Drill fields; its presence turns the drill on | absent |
v | Grammar version | 1 |
The allocation stays in the link beside an exercise id on purpose. If an id is ever retired the link still lands on the right network and degrades to explore mode with a notice, rather than silently loading something else.
Drill answers never travel. A shared drill link always arrives blank. Addressing plans do travel, under p, as index~gateway~first-last~name@addr|name@addr records separated by commas. Every part may be blank, because a half-finished plan is the normal state of a student's work. Static names are percent-encoded with the tilde escaped by hand, since encodeURIComponent leaves it alone and it is this grammar's field separator.
Which statics an exercise requires is not read back from the link. The addresses come from the link and the required names come from the exercise, so a link cannot quietly drop a required row the student had not filled in yet.
The one validator
codes.validate is the only function that vets externally supplied state, and it is type-restricted: unknown keys are dropped, every value is bounded, and anything that fails falls back to a default with a notice rather than throwing. A misaligned network is corrected and reported using the ECT calculator's own sentence, so a student who has seen that message in one tool recognises it in the other.
The division string is bounded three ways: at most 2047 characters, a depth no greater than the address space allows, and at most 1024 leaves. It must also parse as a complete tree that consumes the whole string. The model has its own depth cap on top of that, so even a hand-edited string cannot build a node past /32.
Calculator interoperation
A query string of the ECT Visual Subnet Calculator's shape is accepted on boot and rewritten to the canonical hash. The division encoding is reproduced bit for bit: four bits per hexadecimal digit, least significant first, with the bit count in front because the last digit may be partial. Labels use the same pipe-separated form with percent and pipe escaped, and arrive as requirements assigned positionally to the leaves.
The outbound link is built the same way. Both directions are asserted by a harness, including a round trip through the tool's own parser.
The addressing plan
plan.js holds one plan per requirement: a gateway, a DHCP pool, and a list of named statics, all uint32 or null. It is pure, it never sees the DOM, and it is separate from check.js because its report is per field, not per rule.
validate(plan, leaf, conv) returns problems and notes as two different things, and that split is the module's whole reason for existing. A problem is a plan that cannot work, and it carries the hierarchy rule it breaks so the checker can file it without re-reading the wording: using the network number or the broadcast is n2, everything else is business. A note is a deviation from Convention A, B or C and never fails anything. The report also carries per-field error and note strings, so the panel can put a message on the row that caused it.
A missing gateway is a problem, filed under business, from the moment the block is assigned. The tool never places the gateway for the student, so its absence is a plan that does not work, and the message deliberately names no address. The checker validates every assigned need's plan, empty or not, for problems; it reports the notes only once the plan is non-empty.
The conventions are a small object, { gateway, statics, dhcp, dns }, vetted by plan.normalizeConventions, defaulting to plan.CONVENTIONS (the lecture's A to D) and carried on the model as model.conventions. suggest(leaf, plan, conv) lays a block out by them: the gateway at its end, the statics packed from theirs, the pool filling what is left. It stops short of suggesting a pool when the block has two usable addresses, because that is a point-to-point link and both ends are configured by hand. Only the reference solver and the harnesses call it; the Use conventions button was removed when the gateway became the student's to place. validate reads the expected layout against where the gateway actually is, so a gateway at the wrong end earns one note under A rather than a cascade under B and C, and Convention C's expected pool is the longest run of usable addresses nothing else has taken.
The model owns mutation, as always. setPlanField, addStatic, setStatic, removeStatic and applyConventions all go through the same commit chokepoint, so a plan edit is undoable like any other change. removeStatic on a required row clears its address instead of deleting the row, because the exercise still wants that device addressed.
Render geometry
The diagram is the handout's picture. Reading down: the allocation CIDR, a row of network numbers in blue, the band itself, a row of broadcasts in red, and a footer when anything is drawn out of scale. A left gutter carries the two row labels, and is dropped below 560 pixels where the band needs the space more.
Inside the band, each leaf gets a blue line on its left edge and a red line on its right, so neighbours meet at a bar with red on the left of it and blue on the right. That is the picture the lecture draws when it says a cut creates a broadcast and a network number at the same point.
A block carries up to three stacked lines: its prefix, its usable count, and its assigned name. Each line is dropped independently once the block is too narrow to hold it, so a shrinking block loses the name, then the word beside the count, then the count, then the prefix, in that order; the stack stays centred on the band whatever survives. A drill that asks for the usable count masks it here the same way it masks the addresses, or the answer would be printed above the question.
The greyed parent prefix sits at its bar, vertically centred in the band, with a knockout rectangle behind it so the bar never strikes through the label. Font size and bar width both step down with depth, which is how Table 4.1 of the handout stacks them.
Every string that reaches the SVG passes through an ASCII filter. Arrows are written ->. The picture has to survive a PNG export and a printed page, and a glyph that renders in a browser may not.
Colours reach the SVG as attributes, not classes, resolved from the CSS tokens once per refresh. That is what lets the PNG export serialise the diagram and get the right colours in both themes.
The minimum-width clamp
Widths start proportional to size. Any leaf narrower than the minimum is pinned to it and marked, and its shortfall is redistributed among the leaves that are still free; the pass repeats until nothing new falls under the minimum. Clamped leaves get a hatch fill and the footer says how many are out of scale, because a picture that silently lies about proportion is worse than one that admits it.
The address-to-x mapping is per leaf rather than global, which is exactly why it stays correct under the clamp and under a focus.
The pointer state machine
Listeners attach once to the diagram host, never to the SVG, because the SVG is replaced on every refresh.
- pointerover on a leaf, with no drag or bar press underway, selects it. The selection stays when the pointer leaves the diagram, because the card describing it sits in the pane the pointer crosses to reach it and carries buttons.
model.selectreturns early when the path is already selected, so a pointer re-entering the block it just left commits nothing. - pointerdown on a bar records a bar press. On a leaf it starts a drag, captures the pointer and selects the block. A press while a click is pending cancels that click and focuses the block instead, which is how a double click means zoom.
- pointermove past three pixels marks the drag as moved, runs the cut as a dry run, positions the ghost and writes the explanation.
- pointerup rejoins if it was a bar press, applies the cut if the drag moved, and otherwise starts the click timer.
- pointercancel discards everything and leaves the model untouched.
Pointer capture means ev.target stops being the element under the cursor once a drag is underway, so only pointerover and pointerdown read the target; everything after it resolves the block from the layout by x coordinate.
A selection change takes a light path through ui.refresh: render.markSelected moves the class and the stroke attributes on the leaf rects and the class on the table rows in place, and only the selection card is rebuilt. Rebuilding the diagram, the table and the needs list on every hover would reset their scroll positions and replace the rect under the pointer on each entry. The selection is not in the link and does not name the tab, so the hash writer and the title are skipped as well.
One gesture, two destinations. A drag that stays inside the diagram cuts. A drag that leaves it becomes an assignment: the ghost is dropped, the requirement row under the pointer is highlighted, and releasing there assigns the block to that network. Capture is what makes it possible at all, because the moves keep arriving after the pointer has left the diagram, and the row underneath is found with elementFromPoint, which capture does not affect. Releasing outside the diagram and not on a row cancels rather than cutting, because the pointer is nowhere near the address the clamp would otherwise read it as pointing at.
The same assignment from the other end. A press on a requirement row, carried past three pixels and released on a leaf, assigns that leaf to the network through the same model.assign the other direction uses. The listeners sit on the req-list host and the host takes the capture, not the row: the press blurs any text field that had focus so its change fires then rather than after the drop, and that refresh replaces every row, which would have lost a capture one of them held. The row is remembered by id. A press on a button, an input, a summary or anything inside the addressing panel is left to that control and starts nothing. As the pointer crosses the diagram, elementFromPoint finds the leaf under it, render.markDropTarget gives it the is-drop-target class (a dashed stroke set from CSS, so the attributes markSelected keeps for the PNG are untouched), and model.select takes the light refresh path so the card describes it. A release on nothing leaves the model alone and says so; a release on the block the network already has commits nothing; a press that never moved is a click on the row, which does nothing. The rows carry touch-action: pan-y, so a vertical touch drag still scrolls the panel and only a sideways one picks the network up; when the browser takes the touch for a scroll, pointercancel clears the marks.
The checker and the solver
check.run returns a report keyed by the five rules of the hierarchy, ordered unbreakable first, each with a status of ok, fail or note and a list of messages. Only the N-2 rule and business need can fail. Every message comes from a catalogue of templates, so the wording is in one place and a test can assert it exactly.
check.solve is the reference solver: sort the needs by the block size they require, largest block first, then walk the leaves in address order and take the first free block big enough, halving into the left child until it fits. Descending powers of two never fragment a power-of-two bin, so this succeeds whenever the sizes fit at all. It is what proves the generator's problems are solvable, and it is how the built-in exercises are verified.
The generator
A code is SN1-<tier><space><nonce>. The seed is FNV-1a of the nonce mixed with the tier index and the private flag, so the same nonce at a different tier is an unrelated draw and a code fully pins its problem. Each generation pass takes its own derived stream, and the rule is that a pass may consume only its own stream and never depend on a later pass's output. Break that and adding a tier reshuffles every problem at every existing code, which surfaces months later as a student whose posted code no longer matches.
Host counts are drawn tight: more than the next smaller block holds, no more than this block holds, so exactly one prefix is correct. Tier F biases towards the boundary. Block sizes are drawn until their sum fits the allocation, retrying by stepping the largest block one prefix smaller, which always terminates.
Theme and layout
Theming is token-only. One :root block declares every colour, font and dimension; one body.dark block re-declares the same properties and nothing else. High-Visibility and the responsive breakpoints also re-declare tokens rather than restating rules.
The preference is light, dark or auto, and auto follows the system media query live. The first-run default is light, not auto, so a new user lands on the light theme whatever their machine is set to; only the default is pinned, and a stored preference of any of the three is honoured verbatim. It lives in one constant, DEFAULT_THEME, which the unknown-value fallbacks all read rather than naming a mode, and the static badge in the shell has to match it because that is what the first paint shows.
Four localStorage keys are used, all prefixed subnetris., and on Firefox a reload clears exactly those four keys so the page returns to its defaults. It never clears anything else, because every tool on this docroot shares one origin.
The layout key carries the panel sizes, the collapse states, the hidden-column list and the Block Display state, which are all the same class of thing. Block Display stores the collapse flags it found under blockSaved when it turns on, and setLayout drops both the flag and the saved pair when a pane is opened or closed by hand, so the next selection collapses again rather than restoring a state the student has moved past. The columns are a validated list rather than a flag each, so a new toggle is data rather than another field and another migration; the module stays generic and never learns what a column is called. A layout written when this was a single hideBinary flag migrates in one place: the student's answer about binary is preserved and any column added since takes its default.
Tests and tooling
Fifteen harnesses live in testing/. Each is a self-contained page that loads the real shipped sources with window.__skipAppInit = true, writes its results as text and puts PASS n or FAIL n in the document title. The title is what the runner reads; the text is what a human reads when opening the file in a browser.
The Python tooling lives in the sibling w:\tools\sndev\, never in the served directory.
sweep.pyruns every harness headless and prints a pass table. A new harness goes into its list in the same commit that creates it.shot.pydrives the real page through a scripted gesture and writes a screenshot, so a layout change can be looked at rather than guessed at.verbs.pyreads everydata-actionout of the shell and compares it against the verbs the router actually registers, so a dead button cannot ship.toc.pyasserts that every heading in these pages has a table-of-contents entry and every entry has a heading.
python scan.py subnetris from w:\tools is the shipped-file gate. Run it with PYTHONIOENCODING=utf-8.
Traps that have already bitten
- Shifting for the mask.
1 << (32 - p)is a signed 32-bit shift and overflows at short prefixes. UseMath.pow, build addresses with multiplication rather than shifts, and keep the/0mask as its own case because shifting by 32 is a no-op. - The drawer's specificity. The shared stylesheet targets
.ect-nav-menu a, which outranks a bare class on a menu row whatever the load order. Every drawer rule here is written with the parent selector in front of it, and the rows are buttons. - Reading a selection after mutating it. Halving a block clears the selection, because a split block is not a leaf. Capture the path before the mutation if you need it afterwards.
- Hiding a table column. Setting display on a
<col>element is ignored by most browsers. Columns are dropped by rebuilding the row, not by styling. - A data file resolved against the page. A relative fetch resolves against the document, not against the script that asked for it, so a harness in a subdirectory looked for the exercise file under that subdirectory, got a 404 and fell back to the built-in rows without complaint. The URL is built from
document.currentScript.srcinstead. The failure mode to remember is that the fallback made it look like nothing was wrong. - Fetching under
file://. There is no origin, so the fetch is skipped entirely rather than attempted and caught; attempting it logs a network error the catch cannot suppress. - Typing must not rebuild the DOM. A keystroke that redraws the panel takes the focused input with it, so the caret lands on the body and the next character goes nowhere. Text inputs commit quietly: the model and the address bar update, and nothing on screen is replaced. The visible validation catches up when the field is left. A rebuild that does happen while a field has focus records a descriptor of it and restores the caret afterwards.
- A block's network address is its left edge, which is where the division bar is. Clicking there rejoins rather than selects. Aim at the middle of a block to work on the block.
IPv6, later
net.js is written as a family module: it exposes a family and a maxPrefix, and the model carries a family field that is elided while it is 4. A sibling built over BigInt could expose the same names, and the tree, the renderer and the checker would not need to know which one they were talking to. Nothing has been built for it yet, and nothing has been painted into a corner.