Skip to content

Latest commit

 

History

History
291 lines (232 loc) · 13.1 KB

File metadata and controls

291 lines (232 loc) · 13.1 KB

Migration Guide: legacy public/**/*.js → bundled src/**/*.ts

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.

Where the file goes

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 a service, 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.

TypeScript — avoid any

  • No any. Use precise types; reach for unknown (then narrow) when a type is genuinely open. any silently disables checking and spreads.
  • Prefer module getters over re-implementing lookups: Markets.get(id), Goods.get(id) (both => T | undefined) instead of pack.markets.find(...).
  • Path alias: @/* resolves to src/* (configured in vite.config.ts and tsconfig.json). Prefer it to deep ../../ chains — e.g. @/utils, @/generators/markets-generator. Sibling imports stay relative (./box).

Globals: import what's migrated, declare the rest in global.ts

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.

  1. It lives in src/ (migrated) → import it — never call it through its window.* global; that bridge exists for classic public/ code, not for bundled modules. The one exception is direction: imports may only point down the stack (components/controllers/services → renderers → generators → utils).

  2. It lives only in classic public/ code → declare it once in src/types/global.ts as var X: …, beside the existing ones. Do not redeclare a name global.ts (or a generator/util module) already types.

  3. It's a DOM element (an id'd node the browser exposes as a global) → don't declare it at all. Use ensureEl<HTMLInputElement>("brushSize") (or document.getElementById). For an element used several times in a function — especially one built from a just-assigned innerHTML — grab it once into a local const el = ensureEl(...).

D3: v7 named imports only

The project depends on d3 ^7.9.0 with @types/d3. Migrate to it.

  • Import named symbols from "d3" — never the window.d3 global, and prefer named imports over a * as d3 namespace (better tree-shaking, explicit deps):
    import { type Selection, select, scaleLinear, max } from "d3";
    The page still loads a legacy global D3 (v5) via <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:
    1. 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.
    2. mean / max / min / extent return T | undefined — handle it (?? 0, or ! only when truly guaranteed).
  • d3.event is gone in v7. Old drag/zoom handlers that read d3.event must move to the event-arg style: take event as the listener's first param, use event.transform, event.x/y, and pointer(event).
  • Don't use the legacy global d3 selections. Always create selections from the imported v7 select function 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. a debug, viewbox or svg selection 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 v7 select before binding data and calling the behaviour, e.g. select<SVGGElement, unknown>("#controlPoints").selectAll("circle").data(...).join("circle").call(drag()…) instead of debug.select("#controlPoints")…. This was the river/route control-point drag bug (river-editor.ts, route-editor.ts).

File structure & exports

  • Named exports only — no default export. 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 let state.

Canonical module skeletons

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.

Generator

// 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.

Data

// 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.

Renderer

// 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;

Controller

// 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.

Own your HTML — create it on open, remove it on close

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.html being present. If the module needs a dialog, panel, overlay, or SVG layer, build it (via innerHTML, createElement, or a Dialog helper) when open() runs — and guard against duplicating it if open() is called twice.
  • Remove it on close. The onClose/cleanup path 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-scoped let state. 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 as git rm-ing the old public/**/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.

Service (app-shell lifecycle)

// 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 };

The eval-order gotcha (read this)

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, options and friends exist from globals.ts (and options-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.)

Finish the port

  1. Update each call site (one line): await import("../dynamic/x.js?v=…") → the eager global or window.lazy.x().
  2. git rm the old public/**/x.js — don't leave a duplicate.
  3. Verify: npx tsc --noEmit (0 errors) → npm run lint → npm run build, then load the app and confirm window.X is registered and the feature renders.