ZEEZANDisometric harbour district · generated inline SVG
15:15
drag to pan · wheel / + − to zoom · R rotate · G grid · arrows pan · 0 recenter · hover & click a building

How it works

A stranger's guide: three short answers — how a world point becomes a pixel, how the painter's sort stays correct under camera rotation, and how the seed drives everything. Node count is measured live by the page and shown in the HUD.

1 · The projection (2:1 isometric)

World cells are integer lattice points (i, j) with height z. The city centre (9, 9) is re-centred to the origin, then the camera azimuth rotates the plan in 90° steps about the origin (four rigid orientations, so occlusion can never disagree with the geometry):

   x = i − 9,   y = j − 9
   az 0:  u =  x,  v =  y        az 1:  u = −y,  v =  x
   az 2:  u = −x,  v = −y        az 3:  u =  y,  v = −x

   screen  X = u − v
           Y = (u + v) / 2 − z

Derivation: rotate the plan 45° (mixing the axes gives X = x − y and view-depth = (x + y)/2), then apply the classic vertical squash that turns the 45° plan's √2 depth into a ½ factor — the standard 2:1 dimetric "isometric" grid. +z maps to (0, −1). A unit cube therefore projects to the classic 2-unit-wide, 3-unit-tall hexagon; its three visible faces share edges exactly because every corner is emitted from the same world vertex list, so there are no seams. Pan/zoom is a single translate·scale on the one <g id="world"> group — no per-node layout work.

2 · The depth sort (painter's algorithm)

Every raised object (building, quay strip, breakwater, tree, crane, boat, container) is a grid-aligned solid. It is drawn exactly once, in ascending order of its front-corner depth d = u + v of its deepest footprint corner (ties broken by i then j, so order is deterministic). Because depth is an affine function of the rotated plan, the same key re-computed per azimuth is correct at all four orientations: any two disjoint grid boxes have non-overlapping depth ranges in the direction the viewer sees, so the closer box always paints over the farther one, and a tall box behind a short one is covered, never covered-in-reverse. Flat ground (water, beach, roads, square) sits at z = 0 and is provably never able to occlude anything raised (a raised point and a ground point at the same screen pixel always have the raised point at greater depth), so it is painted once, first, unsorted.

3 · The seeded pipeline

One string hash (xmur3) seeds mulberry32; no Math.random() anywhere. The stream drives, in fixed order: 1-D value-noise coastline and bay, beach selection, road jitter, a constraint pass (quay = any land cell touching water; road grid; the square; the crane yard), 2-D value noise for block occupancy, then per-building parametric parts — roof type, pitch, floors, window counts, dormers, chimneys, awnings, the lit-window subset, and the dossier text (name, year, use, occupant). Given a seed, the model is a pure function: the page builds it twice on load and asserts the two serialisations are byte-identical (badge in the HUD), and the URL hash (#seed=…) reopens any view from disk. One light direction: every face colour is material × (ambient + diffuse·max(0, n̂·L)) with the same L for the whole scene; the time slider moves L (sun arc + golden tint, moonlight at night), retints the sky and water ramps, and switches window lights / beacon / lamps via a class — lit windows are the seeded subset, so nothing flickers.

4 · Measured node count

Live count of every node inside the scene SVG (geometry + defs, excluding the separate minimap SVG): 2,789, measured by scene.querySelectorAll('*').length after the first build (headless Chromium, seed zeezand-01; varies ±2% with the seed). The minimap adds 328 nodes. Total ≈ 3,117 — under the ~4,000 budget, with headroom left by merging multi-cell geometry (quay, roads, windows, zebras) into single multi-subpath paths.

Requirement checklist

Verified: patternUnits="userSpaceOnUse" tiles in the referencing element's user space, so textures scale with the single world transform (MDN, SVG 2 §Paint servers); transform lists apply right-to-left, hence the translate(tx,ty) scale(s) form; <use> instances render in document order, which is why they live inside the painter-sorted group. Served from disk by double-click — no server, no build, no network.