Authoritative for EDIT/WEDIT, contData(), buildOverlay, applyContOverlay /
applyWorldOverlay, hub/connector/weld editing, and world-link anchoring.
Part of the eql-interactive-map reference set, pointed at from AGENTS.md's routing table.
AGENTS.md states the rules; this file carries the reasoning behind them — the measured
figures, the alternatives tried and rejected, the derivations. Where the two disagree, that is a
bug to fix in place, not a precedence question.
End users get bounded customization: move/scale/rotate zones, drag hubs and connector ends, toggle anchor/free, lock/unlock/snap welds, add manual links, drag continents/realms/world-connector ends, add hubs and connectors, and hide published hubs and connectors (and restore them). They cannot add/delete/hide zones, edit published hub text, delete welds, or export repo files.
Two deliberate asymmetries: published hub text is content-immutable (hide it and add your own — this avoids a third overlay collection and keeps the invariant that published text is either present as shipped or hidden, never silently rewritten); and published items hide while user-added items delete, following the shipped deleteLink precedent.
State lives in the existing EDIT[cont] / WEDIT structures, so contData() serves it and no draw or pick call site branches on edit-vs-view. It is serialized to eql-map-customization.json (buildOverlay) and re-applied by applyContOverlay / applyWorldOverlay.
The overlay is sparse — that is the whole point. Anything the user didn't touch is read from published data at load, which is what lets new zones, hubs, connectors and world links from a later release appear inside an existing customization. The sparseness test differs by collection:
zoneXf,links,contPos,realmPos— compare against the published value, never against identity/default. With 46 non-identityzoneXfon Antonica, an identity test would re-emit all of them as user overrides on the first save and pin the map to that release forever. Comparing against published also makes "Reset this zone" stick when the published transform is non-identity.hubs,conns,worldConns— atouchedflag set at drag-end (and by anchor/free toggles), because these are index-keyed with no name to compare. Gated ondrag.moved, so a bare click-to-select doesn't mark an item customized.
Index-keyed items carry a content hint (ref) captured from published data at buildEditState time — not recomputed later, because dragging a connector overwrites its own a/b. Hints: hub kind|label, connector rounded endpoints ax,ay|bx,by, world link the same in globe coords (verified collision-free on current data). resolveRef tries the saved index first (the append-only case), then a unique hint match anywhere (self-heals deletions and reordering), then drops and counts — ambiguity never misapplies to the wrong glyph. All drops surface in one aggregated toast.
"Not recomputed later" includes the snapshot restore path, which is where it was being violated. The author worldApplyState rebuilt each ref with wlRefOf from the restored a/b, so a link whose end had been dragged came back with a hint matching nothing published, and a customization exported afterwards silently dropped that entry on import. Anchoring adds a second way in — freeing an end writes the resolved point into the raw fallback — so the hint is now taken from the published entry via pidx, and only a user-added link (no pidx, and nothing reads its hint) falls back to recomputing.
zoneKeys records the authored zone roster at save time, including pack-skipped zones. In a sparse overlay "no zoneXf entry" means "untouched", which covers both brand-new zones and pre-existing ones the user left alone — indistinguishable without the roster. A zone absent only because this build's pack lacks it is not new release content either, so contOverlay appends ALL[cont].skipped to the surviving edit-state keys. Guessing wrong drags every unmoved or temporarily absent zone along with its neighbour. New zones are seeded by the delta their nearest neighbour moved by (translation only) and flagged seeded, which contOverlay skips so a later update re-seeds cleanly; unseed() clears the flag the moment the user moves the zone themselves.
A partial build must not publish links whose endpoint zone is absent. contOverlay interprets
every published weld missing from live zoneLinks as a user deletion. Since weld detection runs
only over surviving zones, a dangling published link could never become live and would be saved as
deleted:true; loading that overlay after installing a complete pack would then destroy a link the
user never touched. The build therefore filters those links before injection. It deliberately does
not filter connectors (coordinate-only cosmetic lines) or the unread placed/unplaced lists.
Measured on the root-only cache, 14 of Kunark's 19 links are filtered and none of Antonica's 11 are.
Weld detection is deferred. buildEditState no longer calls detectLinks() — it leaves linksReady:false and ensureLinks() finishes the job when Edit opens. Measured under jsdom on Antonica (49 zones), timing the two halves in isolation on a warmed-up page: applying the overlay ~280 ms vs the deferred weld detection ~3.0 s, about 11×. (Don't time enterCont on a fresh page against a later setEdit — the first measurement absorbs script warmup and first-draw cost, which on a slow runtime made the cheap path look slower than the expensive one.) This matters because the user edition applies a stored customization on plain continent view (enterCont → applyStoredCont), which must stay cheap; nothing on the view draw path reads zoneLinks. Overlay links entries arrive before welds exist, so they park in pendingLinks and ensureLinks drains them. Anything that rebuilds edit-state while editMode is true must call ensureLinks (applyState, revertLast, the Reset handler, setEdit).
Weld pair keys are normalized lexicographically (weldKey) at every write and read: detectLinks emits z1/z2 in zoneOrder while addManualLink writes whichever zone was selected, so the same weld arrives as A|B or B|A.
ANCHOR_THRESH=1200 and LINK_THRESH=120 come from a measurement, not a guess. On Odus, real connector endpoints sit within 10 world-units of a zone outline and hubs within 56, so 1200 binds everything genuinely attached with a wide margin while detectLinks stays tight at 120 to avoid welding merely-adjacent zones. Only Odus was measured — if anchoring misbehaves on a continent with a very different scale, re-measure before changing the constant.
enterCont must call bindES(name) whenever EDIT[name] exists. drawCont calls ep() and hubPos() without a zone map, so they read the global zones; a stale binding renders anchored hubs at their published spot and snaps anchored connectors back to canonical. (This was a latent bug before the overlay: edit A, edit B, return to A with Edit off.)
localStorage keys are suffixed per edition (_v1 author, _u1 user) because the author build stores full snapshots and the user build stores sparse overlays — a shared key would have each misparse the other's buffer.
The world globe deliberately shows published continent shapes. buildWorldCache reads ALL, not contData, so a user's zone rearrangement does not change a continent's globe outline or hit-box. Routing it through contData would need EDIT built eagerly for every continent; done lazily the globe would mutate as you visited continents, which is worse than consistently showing the published shape. Continent positions, realm positions and world connectors do reflect the overlay, since those read through metaPos/worldConns.
A world-connector endpoint anchors to a continent, stored as a globe-unit offset from its pos and resolved at render time through metaPos — the world-level twin of ep()/hubPos(). wlPt is a plain translation with no tPoint analogue, because pos is the only editable field in META and a continent neither rotates nor scales; if world-level scale ever becomes editable, that is the function that has to grow. The raw a/b stay in the structure as the free fallback, exactly as c.a/c.b do one level down.
WL_ANCHOR_THRESH = 1.0 globe units, and the number is measured. Across all 18 authored endpoints, 17 sit at distance 0.000 from their continent's globe box, the worst genuine attachment misses by 0.033 (the same near-miss the expansion rules record), and the nearest second candidate is 1.921 — so 1.0 leaves ~30× margin over the worst true attachment and ~2× under the closest false positive. It looks tiny beside ANCHOR_THRESH=1200 because that one is continent-frame units and this is globe units.
Anchors are derived at buildWorldEditState, never authored. data/world.json keeps its plain two-point form, build.py is untouched, and both export paths resolve through wlPt on the way out — so no injected structure changes and datacmp sees nothing. Same argument as detailOffset: a stored anchor goes stale silently when the data is re-authored, a derived one self-heals every load. The derive must run after WEDIT.meta is populated, since it resolves against metaPos, and it is unconditional rather than gated on autoAnchor — that toggle governs re-anchoring after a drag and new-item creation, not the baseline.
Dragging a continent must leave the overlay's worldConns empty, and that is the functional argument for anchoring over the cheap alternative. Only WEDIT.meta[c].pos changes: no link field is written and nothing is marked touched, so a later release that moves one of those endpoints still takes effect inside an existing customization. Translating attached endpoints during the cmove drag instead would mark every attached link touched and freeze its coordinates at whatever release the user happened to be on — the same failure as testing zoneXf against identity instead of against published. A test for this has to drive worldMouseDown/Move/Up on a real continent hit; assigning pos directly bypasses the cmove branch and so can never fail.
wlBoxDist takes the continent's position as an argument precisely so expansion and anchoring cannot share a frame. wlOwner passes META[c].pos, nearestContForPoint passes metaPos(c). A default would let the live frame leak onto the expansion path, breaking the rule that nothing expansion-related reads EDIT/WEDIT/contData() — and the failure is silent, because a dragged continent simply hands ownership to a different landmass and the link stops hiding. The candidate list is also deliberately expansion-independent: WEDIT is built once and the expansion can change afterwards without a rebuild, so an expansion-scoped list would leave stale anchors behind.
The raw a/b must be refreshed at the moment an end becomes free, at both levels. toggleAnchor/toggleWlAnchor capture the resolved point into the fallback before nulling the anchor; without that, freeing an end after its zone or continent has moved teleports it back to the published coordinate. anchorHub already did the equivalent on the anchoring side, which is what made the gap on the freeing side easy to miss.
Re-anchoring on mouse-up is gated on drag.moved, at both levels. A bare click must not silently re-bind an end the user deliberately freed. The hub branch always had this through anchorCleared; cend/cbody did not, so it was an omission rather than a design choice — and gating the whole block makes the anchorCleared test redundant but harmless, since it implies moved.
In a snapshot, an absent anchor field means legacy data and must re-derive; null means the user deliberately freed that end. Author snapshots predate the four fields, and copying the missing keys straight through restores every endpoint free — a failure that looks exactly like the feature not working. Anchor state persists at all for one reason only: a manual free or bind would otherwise be undone by the next auto-anchor pass. The derived baseline needs no help.
OVERLAY_V is 2 and the importer accepts 1, and both halves are required. The world-link endpoint form changed from a bare [x,y] array to an {xy,anchor,lx,ly} object, and the version check rejects rather than migrates: keeping 1 lets an older viewer accept a new file and then throw inside applyWorldOverlay on an object — the half-applied map that code's own comment says must never happen — while bumping without also accepting 1 rejects every file already exported. wlEndIn's Array.isArray branch is the migration: a v1 endpoint loads as free coordinates, the correct reading of a file that recorded absolute positions and no anchor intent. Keep wlEndIn's else — writing only if(p.anchor){…} leaves the derived anchor in place and silently discards the user's manual free. The continent half of the file has always used endOut's objects, so the change is confined to world.worldConns[].a/.b.
Accepted consequences, documented so they aren't surprises: duplicate glyphs when a release adds something a user already added by hand (mitigated by the dashed magenta ring on user-added items — USER_ADDED_COL, magenta because it is the one hue no glyph already uses); hidden-item resurrection when you edit a hub someone hid (its ref stops matching, the entry drops, it reappears — reported in the drop toast); added-connector re-anchoring to the nearest surviving zone if its anchor zone is removed.