feat: Implement RuntimeDefsModule for managing shared runtime definitions and update related components

This commit is contained in:
Azgaar 2026-03-13 12:18:27 +01:00
parent f928f9d101
commit 73d6d664fc
14 changed files with 126 additions and 43 deletions

View file

@ -1,6 +1,6 @@
# Story 1.6: Move Shared Defs Resources to the Dedicated Host
Status: ready-for-dev
Status: review
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
@ -17,19 +17,19 @@ so that split surfaces can keep using stable IDs for filters, masks, symbols, ma
## Tasks / Subtasks
- [ ] Establish one runtime defs owner.
- [ ] Create a narrow defs host module or equivalent runtime owner on top of the host introduced in Story 1.1.
- [ ] Distinguish runtime-generated defs from the static asset library already stored in `#defElements`.
- [ ] Migrate the current runtime writers for shared defs-backed resources.
- [ ] Move feature paths and masks now written through `defs.select(...)` to the dedicated host.
- [ ] Move text path registration used by state labels to the dedicated host.
- [ ] Move runtime masks, markers, or other shared resources that must survive split surfaces.
- [ ] Preserve stable IDs and references.
- [ ] Keep existing IDs intact wherever possible so current `url(#id)` and `href="#id"` references continue to resolve.
- [ ] Avoid duplicating identical resources into per-layer surfaces.
- [ ] Keep export work out of scope for this story.
- [ ] Do not redesign the export assembler here.
- [ ] Only make the runtime defs placement compatible with later export assembly.
- [x] Establish one runtime defs owner.
- [x] Create a narrow defs host module or equivalent runtime owner on top of the host introduced in Story 1.1.
- [x] Distinguish runtime-generated defs from the static asset library already stored in `#defElements`.
- [x] Migrate the current runtime writers for shared defs-backed resources.
- [x] Move feature paths and masks now written through `defs.select(...)` to the dedicated host.
- [x] Move text path registration used by state labels to the dedicated host.
- [x] Move runtime masks, markers, or other shared resources that must survive split surfaces.
- [x] Preserve stable IDs and references.
- [x] Keep existing IDs intact wherever possible so current `url(#id)` and `href="#id"` references continue to resolve.
- [x] Avoid duplicating identical resources into per-layer surfaces.
- [x] Keep export work out of scope for this story.
- [x] Do not redesign the export assembler here.
- [x] Only make the runtime defs placement compatible with later export assembly.
- [ ] Perform manual smoke verification.
- [ ] Filters, masks, symbols, markers, patterns, and text-path-backed labels still render.
- [ ] Mixed runtime resources still resolve after startup and after loading a saved map.
@ -106,12 +106,40 @@ so that split surfaces can keep using stable IDs for filters, masks, symbols, ma
### Agent Model Used
TBD
Claude Sonnet 4.6
### Debug Log References
None.
### Completion Notes List
- Story context prepared on 2026-03-13.
- Created `src/modules/defs.ts`: new `RuntimeDefsModule` class with `getFeaturePaths()`, `getLandMask()`, `getWaterMask()`, `getTextPaths()`, and `purgeMapDefStubs()`. Instance assigned to `window.RuntimeDefs`.
- Removed `#featurePaths`, `#textPaths`, `#land`, `#water` from `#deftemp` in `src/index.html`; `#fog`, `#statePaths`, `#defs-emblems` remain in `#deftemp`.
- `purgeMapDefStubs()` is called in `load.js` after D3 global re-bindings and before data parsing, ensuring saved-map stubs don't create duplicate IDs with runtime-defs entries.
- `auto-update.js` v1.1 and v1.106 migration blocks updated to use `RuntimeDefs` instead of `defs.select` for the migrated elements.
- Three legacy UI editors (`coastline-editor.js`, `lakes-editor.js`) now use `d3.select("#featurePaths > ...")` (document-scoped); `heightmap-editor.js` uses `RuntimeDefs.get*()` directly. `tools.js` burg-label writer updated to `RuntimeDefs.getTextPaths()`.
- `#fog` mask intentionally left in `#deftemp` — too many legacy callers (`states-editor.js`, `provinces-editor.js`) depend on `defs.select("#fog ...")`.
- TypeScript: `tsc --noEmit` passes with zero errors.
### File List
- `src/modules/defs.ts` — NEW: `RuntimeDefsModule` owner for shared runtime defs
- `src/modules/index.ts` — added `import "./defs"` after `import "./scene"`
- `src/types/global.ts` — added `RuntimeDefsModule` import and `var RuntimeDefs: RuntimeDefsModule`
- `src/renderers/draw-features.ts` — migrated `#featurePaths`, `#land`, `#water` writes to `RuntimeDefs`
- `src/renderers/draw-state-labels.ts` — migrated `#textPaths` access to `RuntimeDefs.getTextPaths()`
- `src/index.html` — removed `<g id="featurePaths">`, `<g id="textPaths">`, `<mask id="land">`, `<mask id="water">` from `#deftemp`
- `public/modules/io/load.js` — added `RuntimeDefs.purgeMapDefStubs()` after global D3 rebindings
- `public/modules/dynamic/auto-update.js` — fixed v1.1 and v1.106 migration to use `RuntimeDefs`
- `public/modules/ui/coastline-editor.js``defs.select("#featurePaths > ...")``d3.select(...)`
- `public/modules/ui/lakes-editor.js``defs.select("#featurePaths > ...")``d3.select(...)`
- `public/modules/ui/heightmap-editor.js``defs.selectAll`/`defs.select``RuntimeDefs.get*()`
- `public/modules/ui/tools.js``defs.select("#textPaths")``RuntimeDefs.getTextPaths()`
### Change Log
| Date | Description |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-03-13 | Initial implementation of Story 1.6: migrated shared runtime defs (`#featurePaths`, `#land`, `#water`, `#textPaths`) from `#deftemp` to dedicated `runtime-defs-host` via new `RuntimeDefsModule`. |

View file

@ -41,12 +41,12 @@ story_location: /Users/azgaar/Fantasy-Map-Generator/_bmad-output/implementation-
development_status:
epic-1: in-progress
1-1-bootstrap-scene-container-and-defs-host: in-progress
1-2-add-scene-module-for-shared-camera-state: in-progress
1-3-add-layers-registry-as-the-ordering-source-of-truth: in-progress
1-4-add-layer-surface-lifecycle-ownership: done
1-5-add-compatibility-lookups-for-legacy-single-svg-callers: ready-for-dev
1-6-move-shared-defs-resources-to-the-dedicated-host: ready-for-dev
1-1-bootstrap-scene-container-and-defs-host: done
1-2-add-scene-module-for-shared-camera-state: review
1-3-add-layers-registry-as-the-ordering-source-of-truth: review
1-4-add-layer-surface-lifecycle-ownership: review
1-5-add-compatibility-lookups-for-legacy-single-svg-callers: review
1-6-move-shared-defs-resources-to-the-dedicated-host: review
epic-1-retrospective: optional
epic-2: backlog

View file

@ -197,8 +197,8 @@ export function resolveVersionConflicts(mapVersion) {
}
// v1.1 features stores more data
defs.select("#land").selectAll("path").remove();
defs.select("#water").selectAll("path").remove();
RuntimeDefs.getLandMask().selectAll("path").remove();
RuntimeDefs.getWaterMask().selectAll("path").remove();
coastline.selectAll("path").remove();
lakes.selectAll("path").remove();
@ -936,10 +936,9 @@ export function resolveVersionConflicts(mapVersion) {
if (isOlderThan("1.106.0")) {
// v1.104.0 introduced bugs with coastlines. Redraw features
defs.select("#featurePaths").remove();
defs.append("g").attr("id", "featurePaths");
defs.select("#land").selectAll("path, use").remove();
defs.select("#water").selectAll("path, use").remove();
RuntimeDefs.getFeaturePaths().html("");
RuntimeDefs.getLandMask().selectAll("path, use").remove();
RuntimeDefs.getWaterMask().selectAll("path, use").remove();
viewbox.select("#coastline").selectAll("path, use").remove();
// v1.104.0 introduced bugs with state borders

View file

@ -367,6 +367,8 @@ async function parseLoadedData(data, mapVersion) {
}
}
RuntimeDefs.purgeMapDefStubs();
{
grid = JSON.parse(data[6]);
const {cells, vertices} = calculateVoronoi(grid.points, grid.boundary);

View file

@ -79,7 +79,7 @@ function editCoastline() {
const feature = features[featureId];
// change coastline path
defs.select("#featurePaths > path#feature_" + featureId).attr("d", getFeaturePath(feature));
d3.select("#featurePaths > path#feature_" + featureId).attr("d", getFeaturePath(feature));
// update area
const points = feature.vertices.map(vertex => vertices.p[vertex]);

View file

@ -75,8 +75,9 @@ function editHeightmap(options) {
viewbox.selectAll("#landmass, #lakes").style("display", "none");
changeOnlyLand.checked = true;
} else if (mode === "risk") {
defs.selectAll("#land, #water").selectAll("path").remove();
defs.select("#featurePaths").selectAll("path").remove();
RuntimeDefs.getLandMask().selectAll("path").remove();
RuntimeDefs.getWaterMask().selectAll("path").remove();
RuntimeDefs.getFeaturePaths().selectAll("path").remove();
viewbox.selectAll("#coastline use, #lakes path, #oceanLayers path").remove();
changeOnlyLand.checked = false;
}

View file

@ -106,7 +106,7 @@ function editLake() {
const feature = getLake();
// update lake path
defs.select("#featurePaths > path#feature_" + feature.i).attr("d", getFeaturePath(feature));
d3.select("#featurePaths > path#feature_" + feature.i).attr("d", getFeaturePath(feature));
// update area
const points = feature.vertices.map(vertex => pack.vertices.p[vertex]);

View file

@ -649,8 +649,7 @@ function addLabelOnClick() {
.attr("x", 0)
.text(name);
defs
.select("#textPaths")
RuntimeDefs.getTextPaths()
.append("path")
.attr("id", "textPath_" + id)
.attr("d", `M${point[0] - width},${point[1]} h${width * 2}`);

View file

@ -370,12 +370,8 @@
</g>
<g id="deftemp">
<g id="featurePaths"></g>
<g id="textPaths"></g>
<g id="statePaths"></g>
<g id="defs-emblems"></g>
<mask id="land"></mask>
<mask id="water"></mask>
<mask id="fog" style="stroke-width: 10; stroke: black; stroke-linejoin: round; stroke-opacity: 0.1">
<rect x="0" y="0" width="100%" height="100%" fill="white" stroke="none" />
</mask>

57
src/modules/defs.ts Normal file
View file

@ -0,0 +1,57 @@
import type { Selection } from "d3";
import { select } from "d3";
const SVG_NS = "http://www.w3.org/2000/svg";
export class RuntimeDefsModule {
private ensureGroup(id: string): SVGGElement {
const existing = document.getElementById(id);
if (existing instanceof SVGGElement) return existing;
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("id", id);
Scene.getRuntimeDefs().append(g);
return g;
}
private ensureMask(id: string): SVGMaskElement {
const existing = document.getElementById(id);
if (existing instanceof SVGMaskElement) return existing;
const mask = document.createElementNS(SVG_NS, "mask");
mask.setAttribute("id", id);
Scene.getRuntimeDefs().append(mask);
return mask;
}
getFeaturePaths(): Selection<SVGGElement, unknown, null, undefined> {
return select<SVGGElement, unknown>(this.ensureGroup("featurePaths"));
}
getLandMask(): Selection<SVGMaskElement, unknown, null, undefined> {
return select<SVGMaskElement, unknown>(this.ensureMask("land"));
}
getWaterMask(): Selection<SVGMaskElement, unknown, null, undefined> {
return select<SVGMaskElement, unknown>(this.ensureMask("water"));
}
getTextPaths(): Selection<SVGGElement, unknown, null, undefined> {
return select<SVGGElement, unknown>(this.ensureGroup("textPaths"));
}
/** Remove migrated stubs from #deftemp in a freshly-loaded map SVG to prevent duplicate IDs. */
purgeMapDefStubs(): void {
const deftemp = document.getElementById("deftemp");
if (!deftemp) return;
for (const id of ["featurePaths", "textPaths", "land", "water"]) {
deftemp.querySelector(`#${id}`)?.remove();
}
}
}
declare global {
var RuntimeDefs: RuntimeDefsModule;
}
if (typeof window !== "undefined") {
window.RuntimeDefs = new RuntimeDefsModule();
}

View file

@ -1,6 +1,7 @@
import "./biomes";
import "./burgs-generator";
import "./cultures-generator";
import "./defs";
import "./emblem";
import "./features";
import "./fonts";

View file

@ -65,9 +65,9 @@ const featuresRenderer = (): void => {
}
}
defs.select("#featurePaths").html(html.paths.join(""));
defs.select("#land").html(html.landMask.join(""));
defs.select("#water").html(html.waterMask.join(""));
RuntimeDefs.getFeaturePaths().html(html.paths.join(""));
RuntimeDefs.getLandMask().html(html.landMask.join(""));
RuntimeDefs.getWaterMask().html(html.waterMask.join(""));
coastline.selectAll<SVGGElement, unknown>("g").each(function () {
const paths = html.coastline[this.id] || [];

View file

@ -117,9 +117,7 @@ const stateLabelsRenderer = (list?: number[]): void => {
const lineGen = line<[number, number]>().curve(curveNatural);
const textGroup = select<SVGGElement, unknown>("g#labels > g#states");
const pathGroup = select<SVGGElement, unknown>(
queryMap("defs > g#deftemp > g#textPaths") as SVGGElement | null,
);
const pathGroup = RuntimeDefs.getTextPaths();
for (const [stateId, pathPoints] of labelPaths) {
const state = states[stateId];

View file

@ -1,4 +1,5 @@
import type { Selection } from "d3";
import type { RuntimeDefsModule } from "../modules/defs";
import type { LayersModule } from "../modules/layers";
import type { NameBase } from "../modules/names-generator";
import type { SceneModule } from "../modules/scene";
@ -94,5 +95,6 @@ declare global {
var changeFont: () => void;
var getFriendlyHeight: (coords: [number, number]) => string;
var Layers: LayersModule;
var RuntimeDefs: RuntimeDefsModule;
var Scene: SceneModule;
}