How to port a classic, un-bundled module served as-is from public/, leaning on
runtime globals, into a typed module inside Vite's graph. See also lazy-loading.md,
architecture.md, and data-model.md.
Pick the layer by responsibility, name the file kebab-case.ts:
| Layer | Holds |
|---|---|
src/generators/ |
domain generators / data logic |
src/renderers/ |
code that draws SVG layers |
src/renderers/overlays/ |
transient feedback that removes itself (highlight, fog) |
src/controllers/ |
map UI you open and close: editors, tools, overviews |
src/components/ |
web components + UI opened over the map that ignores it |
src/components/dialog/ |
the shared dialog toolkit |
src/services/ |
app-shell & platform infra, incl. preferences |
src/data/ |
static content / reference lists |
src/utils/ |
pure helpers — no ambient state, ≥2 consumers |
Not everything is Model/View/Controller. Before reaching for controllers/ or utils/, apply
these three tests — a big classic module usually splits across four or five folders:
- Does the user open and close it, and is it about the map? Both yes →
controllers/. Always on screen →components/. Opened but not about the map (an About dialog, a shell-loaded widget) →components/. Transient UI loaded only when first opened (such as the colour picker) →controllers/. - Does it read
pack/grid? Then it is not aservice, whatever else it is. - Does it read an ambient global, or have only one consumer? Then it is not a
util— put it in the module that uses it, or pass the state in as an argument.
If a file is static content (a constant list, a template table) it goes in data/. If it
manages browser/app lifecycle (PWA install, auto-update, io, preferences) it goes in
services/. See architecture.md for the full rationale.
- No
any. Use precise types; reach forunknown(then narrow) when a type is genuinely open.anysilently disables checking and spreads. - Prefer module getters over re-implementing lookups:
Markets.get(id),Goods.get(id)(both=> T | undefined) instead ofpack.markets.find(...). - Path alias:
@/*resolves tosrc/*(configured invite.config.tsandtsconfig.json). Prefer it to deep../../chains — e.g.@/utils,@/generators/markets-generator. Sibling imports stay relative (./box).
A classic module reaches dozens of runtime globals. Resolve each by origin,
and never use module-local declare const or as any to paper over one.
-
It lives in
src/(migrated) → import it — never call it through itswindow.*global; that bridge exists for classicpublic/code, not for bundled modules. The one exception is direction: imports may only point down the stack (components/controllers/services → renderers → generators → utils). -
It lives only in classic
public/code → declare it once insrc/types/global.tsasvar X: …, beside the existing ones. Do not redeclare a nameglobal.ts(or a generator/util module) already types. -
It's a DOM element (an
id'd node the browser exposes as a global) → don't declare it at all. UseensureEl<HTMLInputElement>("brushSize")(ordocument.getElementById). For an element used several times in a function — especially one built from a just-assignedinnerHTML— grab it once into a localconst el = ensureEl(...).
The project depends on d3 ^7.9.0 with @types/d3. Migrate to it.
- Import named symbols from
"d3"— never thewindow.d3global, and prefer named imports over a* as d3namespace (better tree-shaking, explicit deps):The page still loads a legacy global D3 (v5) viaimport { type Selection, select, scaleLinear, max } from "d3";
<script src="libs/d3.min.js">for the old classic code; bundled TS must not depend on it. - Two v5→v7 breaks to fix while porting:
- Selection
.on(type, listener)now passes(event, datum)— the datum is the second arg (v5 passed it first): rewrite.on("mouseover", d => …)→.on("mouseover", (_event, d) => …). Value accessors (.attr,.text,.style) still take the datum first. mean/max/min/extentreturnT | undefined— handle it (?? 0, or!only when truly guaranteed).
- Selection
d3.eventis gone in v7. Old drag/zoom handlers that readd3.eventmust move to the event-arg style: takeeventas the listener's first param, useevent.transform,event.x/y, andpointer(event).- Don't use the legacy global d3 selections. Always create selections from the imported v7
selectfunction and work with them explicitly, global selections use d3 v5 and can lead to bugs.- Never attach a v7 behaviour (
drag,zoom) through a v5 selection. When you.call(drag(...))on a selection that descends from a global v5 selection (e.g. adebug,viewboxorsvgselection made through the global d3), the v5 selection registers the v7 handlers but dispatches them with the v5 calling convention (datum-first:handler(d, i, nodes)). The v7 drag internals expect the event first (handler(event, d)), so they receive the bound datum instead of the DOM event and the gesture silently never starts — the elements look draggable but don't move. Fix: reselect the container with the bundled v7selectbefore binding data and calling the behaviour, e.g.select<SVGGElement, unknown>("#controlPoints").selectAll("circle").data(...).join("circle").call(drag()…)instead ofdebug.select("#controlPoints")…. This was the river/route control-point drag bug (river-editor.ts,route-editor.ts).
- Never attach a v7 behaviour (
- Named exports only — no
defaultexport. A controller exposes what it does:export const supporters = …,export function open() {}. - Function over class unless you genuinely need instances with shared
state. Most controllers are a few exported functions over module-scoped
letstate.
Each skeleton below is the shape to aim for — small, explicit, and testable — embodying the principles in architecture.md.
The recurring move is separate the logic from the legacy seam: write the real work as
plain exported functions that take their inputs as arguments (so a unit test can call them
without the app), then add a thin window bridge at the bottom that wires those functions
to the ambient globals classic callers expect. The bridge is a temporary interop
concession — keep it to a few lines and delete it once every caller is TypeScript. Globals
referenced bare (pack, grid, seed, TIME, customization, $, layerIsOn, …) come
from legacy public/ code and are typed in src/components/globals.ts,
src/types/global.ts or
by the owning module — import or declare, never as any.
// src/generators/module-generator.ts
import Alea from "alea";
export interface Module {
i: number;
name: string; /* serializable fields only */
}
// Clean core: explicit inputs → data out. Deterministic, no DOM, trivially unit-tested
function generate(seed: string): Module[] {
Math.random = Alea(seed); // seed once; same seed - same world
return [];
}
export const Module = { generate, get };
// Temporary Legacy seam — classic callers reach the generator as a global.
declare global {
var Module: { generate: typeof generate; get: typeof get };
}
window.Module = {
generate: () => void (pack[module] = generate(pack, seed)),
get: i => getWidget(pack.widgets, i)
};Reach for a class if the subsystem owns mutable runtime state.
// src/data/module-data.ts
// co-located: a const at the top of the generator that consumes it
const MODULE_DATA = [{ name: "Cog", value: 1 } /* … */] as const;
// split out once large
export const charges = {
types: {
/* … */
}
};No logic here — the data says what, the generator decides how.
// src/renderers/module-renderer.ts
// Clean core: a pure projection of state → markup. Same state ⇒ same output; reads only.
function draw(): string {
return (document.getElementById("moduleLayer").innerHTML = buildModule(pack));
}
export const ModuleRenderer = { draw };
// Legacy seam: apply to the layer + a toggle, registered for classic callers
declare global {
interface Window {
drawModule: typeof draw;
}
}
window.drawModule = draw;// src/controllers/module-editor.ts
// thin: intent → state change/redraw
import { ensureEl } from "../utils";
let controllerState: unknown; // optional, for a panel that preserves some UI state across opens
function open(id: number): void {
const dialog = render();
addListeners(dialog);
dialog.open({ title: "Module Editor", onClose: cleanup });
}
function render(): void {
/* build innerHTML, set values from pack */
}
function addListeners(dialog): void {
/* wire event handlers to update pack, redraw, etc. */
}
function cleanup(): void {
/* remove any DOM this module created, drop listeners, reset module-scoped state */
}
export const ModuleEditor = { open };
// Legacy seam: registered for classic callers
declare global {
interface Window {
ModuleEditor: { open: typeof open };
}
}
window.ModuleEditor = { open };All controllers must be lazy-loaded, unless they are needed immediately on app start.
A module is responsible for the full lifecycle of any DOM it introduces. Classic
public/ code often appended dialogs, panels, tooltips, and SVG groups once and
left them in the document forever, relying on display:none and re-use. When
porting, do not carry that pattern over. A migrated module must:
- Create its own markup. Don't rely on a node hand-authored in
index.htmlbeing present. If the module needs a dialog, panel, overlay, or SVG layer, build it (viainnerHTML,createElement, or aDialoghelper) whenopen()runs — and guard against duplicating it ifopen()is called twice. - Remove it on close. The
onClose/cleanuppath must delete every node the module added (el.remove(),select("#moduleLayer").remove()), detach event listeners it attached to shared/global targets (window,document,svg,body), and reset module-scopedletstate. Leave the DOM exactly as the module found it — no orphaned nodes, no leaked handlers. - Delete the old static markup. When the legacy node lived in
index.html(or another shared template), remove it there as part of the port so the two don't coexist — the same rule asgit rm-ing the oldpublic/**/x.js.
Wiring cleanup through the dialog's onClose (as above) is the seam that makes
this automatic: opening builds the UI, closing tears it down. A module that only
ever toggles visibility of a pre-existing node is a smell to fix, not preserve.
// src/services/something.ts — app/browser lifecycle only; never reads or writes pack/grid
function init(event: Event): void {
/* PWA install, fonts, tour, auto-update … */
}
export const Something = { init };src/main.ts is the single module entry: it imports
services/logging.ts and
components/globals.ts first, then every layer, then runs
the boot sequence on DOMContentLoaded. Two ordering rules follow from that:
- A bundled module must not read a late global at module top level.
pack,grid,optionsand friends exist fromglobals.ts(andoptions-store.ts) onwards, but they hold placeholder values until the boot sequence and the first generation fill them in. Read them lazily inside the function.
(An import { … } from "d3" at top level is always safe — it's part of the module graph, not a
runtime global.)
- Update each call site (one line):
await import("../dynamic/x.js?v=…")→ the eager global orwindow.lazy.x(). git rmthe oldpublic/**/x.js— don't leave a duplicate.- Verify:
npx tsc --noEmit(0 errors) →npm run lint→npm run build, then load the app and confirmwindow.Xis registered and the feature renders.