fix: render road/river chains in parallel + skip zoom-bake on bg-image maps

Path chains that share a route are stroked in path-type order, so a later
chain on the same hexes used to paint over the earlier one (hexmaker#30).
Add computeLaneOffsets() + offsetPolyline() (hexGeometry.ts): chains sharing
a segment are grouped via union-find and given symmetric perpendicular lane
offsets PER DISTINCT TYPE — same-type chains merge into one stroke, different
types fan out side by side. Chains with no shared segment keep offset 0, so
the common single-path case is pixel-identical. Wired into both the on-screen
overlay (renderPathOverlay) and the PNG exporter (mapPngRenderer) so they match.

Also fixes a macOS-only bg-image zoom drift: bakeZoom grows the em-based hex
grid via font-size while the px-based bg image is grown through a separate
multiply; device-pixel snapping on fractional/Retina DPR rounds the two paths
differently, so the grid slipped off the image on zoom settle. scheduleZoomBake
now skips baking whenever a bg image is present, keeping zoom as a single
composited viewport scale() that can't drift on any DPR.

Adds ISSUES.md (local mirror of open upstream issues for planning).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaprettycoolguy@protonmail.com 2026-06-30 06:56:32 -04:00
parent ee6ba76274
commit 4cd0550a9a
5 changed files with 461 additions and 43 deletions

133
ISSUES.md Normal file
View file

@ -0,0 +1,133 @@
# Upstream Issues Tracker
Local planning mirror of open issues on the GitHub repo
(`sbuffkin/hexmaker` — this repo's `origin`). Snapshot taken **2026-06-29**.
This file is a planning aid, not the source of truth — GitHub is. Update the
**Status** line as work lands. duckmage is ahead of the public issue tracker, so
several requests are already shipped here.
Status legend: 🔴 not started · 🟡 partial / infra exists · 🟢 done in duckmage · 🔬 research-only
---
## #30 — Draw road and river parallel 🟢 (2026-06-29)
> If a road and river follow the same path, they are drawn side by side rather
> than the latter covering the earlier one.
- **Done.** `computeLaneOffsets()` + `offsetPolyline()` in
`src/hex-map/hexGeometry.ts`; wired into `renderPathOverlay()`
(`src/hex-map/HexMapView.ts`) and the PNG exporter (`src/export/mapPngRenderer.ts`).
- Chains sharing a *segment* (adjacent-hex pair) are grouped via union-find and
given symmetric perpendicular lane offsets (gap = widest stroke + 2px). Chains
with no shared segment keep offset 0 → output pixel-identical to before.
Perpendicular is direction-canonicalised so reversed chains still split apart.
- Tests in `tests/hexGeometry.test.ts` (offset math, segment grouping,
single-hex-crossing exclusion, reversed-order routes).
- Verified: type-check + build + 361 tests + eslint all green.
## #29 — Hex flower generation 🔴
> Dynamically generate a map / "hex flower" so you can roll the "next hex".
- Procedural-generation feature. No infra yet. Larger design effort; the reporter
themselves notes a richer palette may serve the same need.
- **Plan (deferred):** scope as an optional generator command. Revisit after the
smaller items clear.
## #27 — Settlement/ruin icons + auto-icon on town create 🟡
> Where are the screenshot icons? + auto-place an icon (e.g. a black dot) when a
> town is created.
- Two parts: (a) **docs/help** — point users at the bundled icon set
(`src/bundledIcons.ts`, plugin `icons/`); (b) **feature** — auto-stamp an icon
override on a hex when a linked town/settlement is added. No auto-icon wiring
found in `src/`.
- **Plan:** (a) is a quick `src/hex-map/help.md` / README note. (b) optional
setting "auto-icon on settlement link" with a default dot icon.
## #24 — Tag-based structure (tags instead of hardcoded folders) 🔴
> Allow tags (e.g. `#Town`, `#Settlement`) as a content source instead of fixed
> folders, so existing vaults don't have to be reorganised.
- Today all link sources are folder-scoped (`townsFolder`, etc.). No tag-source
path in `src/`.
- **Plan (larger):** add an optional per-section "source = folder | tag" mode.
Touches every link picker + enumeration site. Significant; design note first.
## #23 — Image-based backgrounds 🟢 (zoom-drift fix 2026-06-29, needs Mac verify)
> An image layer under the hex map that zooms/pans with the grid.
- Implemented: bg image layer (`.duckmage-bg-image-layer`), `MapModal`,
`newMapFields.ts` background fields, renders in export via `mapPngRenderer.ts`.
- **Mac zoom-drift bug (user-reported, not locally reproducible):** hex grid
shifts off the bg image when zooming. Root cause in `bakeZoom()`
(`HexMapView.ts`): grid is `em`/font-size driven, bg is `px`/transform driven;
baking grows them through two separate arithmetic paths that only match under
exact arithmetic. Device-pixel snapping of the em layout on fractional/Retina
DPR (macOS) rounds differently than the smoothly-scaled bg → visible jump on
zoom settle. No `navigator.platform`/`devicePixelRatio`/CSS-`zoom` code is
involved — it's pure sub-pixel rounding divergence.
- **Fix:** `scheduleZoomBake()` now skips baking whenever a bg image is present
(mirrors calibration-mode behaviour) — zoom stays a single composited viewport
`scale()`, so grid + bg scale as one unit and can't drift on any DPR.
- **Action:** verify on a Mac/Retina display that the grid no longer slips on
zoom; then close upstream.
- **Perf — opening a bg-image map is sluggish (under investigation):**
Benchmarked the render path headlessly (`dev/bg-hex-recalc-bench.{html,mjs}`,
chult's 63×61 = 3843 hexes). Findings:
- Building the 3843 hex DOM nodes ≈ **27 ms/render**, present with **or
without** a bg image — this is the dominant main-thread cost and is not
bg-specific.
- The old `:not([style*="background-color"])` empty-hex selector added
**~46 ms/render**; switched to a `.duckmage-hex-painted` class selector
(`setHexColor` helper) → overhead gone. ✅ landed.
- `renderGrid()`'s `viewportEl.empty()` recreated the `<img>` every render,
forcing image re-decode/re-raster. Now caches & re-attaches the layer
(`bgLayerEl`/`bgLayerSrc`), skipped during calibration. ✅ landed.
- Earlier paint hypothesis was **wrong** — the user clarified it stalls on
*initial open*, not pan, and it's a regression.
- **ROOT CAUSE FOUND (regression) — `renderCoordLabelsLayer` layout thrash:**
introduced in commit 10475fd (coord label layer). It read each hex's
`offsetWidth/Left/Top` and *then created the label DOM inside the same loop*
every read after a write forces a full synchronous layout → **O(n²)**. At
chult's 3843 hexes that's a **~21s** main-thread stall on open (the bg only
amplifies ~1.1×; the real driver is hex count — chult is just the biggest map
AND has a bg). Benchmarked with `dev/coord-label-thrash-bench.{html,mjs}`:
thrash 20770ms → batched 105ms (**~198×**).
- **Fix (landed):** split read phase from write phase — read all hex geometry
into an array first, then create all labels. Matches the read-then-write
pattern already used by `renderPathOverlay`/`renderFactionOverlay`.
- **Guard (landed):** CLAUDE.md convention forbidding interleaved layout-read /
DOM-write in per-hex loops + the committed benchmark as the reproducer.
## #20 — Mobile support 🔬
> Research feasibility of mobile.
- Research tracking issue. No commitment.
- **Plan:** audit pointer/hover assumptions and modal sizing; record findings.
## #12 — Exports and imports 🟡
> Map PNG/JPG export; random-table PDF export; palette/icon export as zip.
- Export subsystem exists: `src/export/` (PDF, PNG, HTML, md serializer; exporters
for map+table, random table, single hex/note, workflow).
- **Gaps:** palette/icon **export-as-zip** and any **import** path not evident.
- **Plan:** confirm coverage vs the three asks; scope palette/icon bundle
export + import separately.
## #10 — Space palette (+ more default palettes) 🟡
> Add a Space default palette; world/country palette maybe better as map-image link.
- Palette infra supports multiple named presets (`Limited`, `Expanded` in
`src/constants.ts`); no `Space` preset yet.
- **Plan:** add a `SPACE_TERRAIN_PALETTE` preset + register it in
`DEFAULT_SETTINGS.terrainPalettes`. Small, additive.
---
### Suggested order (low-hanging first)
1. **#30** road/river parallel — small, self-contained. (in progress)
2. **#10** Space palette — additive preset.
3. **#27a** icon help docs — doc-only.
4. **#23 / #12** verify-and-close / gap-scope existing features.
5. **#27b, #24, #29, #20** — larger design work, deferred.

View file

@ -20,6 +20,8 @@ import {
buildMeanderPts,
smoothPath,
sharpPath,
offsetPolyline,
computeLaneOffsets,
} from "../hex-map/hexGeometry";
import { ensureExportFolder } from "./exportFolder";
import {
@ -356,30 +358,42 @@ export async function renderMapToPngBlob(
);
}
// Paths
// Paths — flatten in draw order, then offset chains that share a route into
// parallel lanes so a road and a river on the same hexes render side by side
// (issue #30), matching HexMapView's on-screen behaviour.
if (showPaths) {
for (const pt of plugin.settings.pathTypes) {
const renderables = plugin.settings.pathTypes.flatMap((pt) =>
map.pathChains
.filter((c) => c.typeName === pt.name)
.map((chain) => ({ chain, pt })),
);
const laneOffset = computeLaneOffsets(
renderables.map((r) => ({
hexes: r.chain.hexes,
width: r.pt.width,
typeName: r.pt.name,
})),
);
renderables.forEach(({ chain, pt }, idx) => {
const dash = dashFor(pt.lineStyle);
const chains = map.pathChains.filter((c) => c.typeName === pt.name);
for (const chain of chains) {
let pts;
let smooth: boolean;
if (pt.routing === "edge") {
pts = buildEdgePts(chain.hexes, shifted, isFlat, R);
smooth = false;
} else if (pt.routing === "meander") {
pts = buildMeanderPts(chain.hexes, shifted);
smooth = true;
} else {
pts = chain.hexes
.map((k) => shifted.get(k))
.filter((p): p is { cx: number; cy: number } => !!p);
smooth = true;
}
if (pts.length < 2) continue;
drawPath(ctx, pts, pt.color, pt.width, dash, smooth);
let pts;
let smooth: boolean;
if (pt.routing === "edge") {
pts = buildEdgePts(chain.hexes, shifted, isFlat, R);
smooth = false;
} else if (pt.routing === "meander") {
pts = buildMeanderPts(chain.hexes, shifted);
smooth = true;
} else {
pts = chain.hexes
.map((k) => shifted.get(k))
.filter((p): p is { cx: number; cy: number } => !!p);
smooth = true;
}
}
if (pts.length < 2) return;
pts = offsetPolyline(pts, laneOffset[idx]);
drawPath(ctx, pts, pt.color, pt.width, dash, smooth);
});
}
return canvas.convertToBlob({ type: "image/png" });

View file

@ -29,6 +29,8 @@ import {
hexNeighbors,
smoothPath,
sharpPath,
offsetPolyline,
computeLaneOffsets,
buildMeanderPts,
buildEdgePts,
} from "./hexGeometry";
@ -2568,6 +2570,17 @@ export class HexMapView extends ItemView {
// (instead of baking it into font-size) so the SVG outline overlay,
// the hex grid, and the bg image all scale together as a single unit.
if (this.bgCalibrating) return;
// Same reasoning whenever a background image is present, but permanently:
// baking grows the em-based hex grid via font-size while the px-based bg
// image is grown through a separate `* F` multiply (see bakeZoom). Those
// two paths only match under exact arithmetic — device-pixel snapping of
// the em layout on fractional/Retina DPR (e.g. macOS) rounds differently
// than the smoothly-scaled bg transform, so the grid visibly jumps off the
// image the instant zoom settles. Keeping zoom as a single composited
// viewport scale() makes grid + bg scale as one unit → no drift on any DPR.
// Trade-off: hexes upscale via the compositor (slightly soft) at high zoom
// over a bg, which is acceptable and matches calibration-mode behaviour.
if (this.getActiveMap().backgroundImage) return;
if (this.zoomSettleTimer !== null) window.clearTimeout(this.zoomSettleTimer);
// 80ms ≈ 5 frames of quiet — short enough that coords un-blur quickly
// after a scroll stops, long enough that a continuous wheel stream
@ -4261,30 +4274,46 @@ export class HexMapView extends ItemView {
const isFlat = this.plugin.settings.hexOrientation === "flat";
const hexRadius = isFlat ? hexW / 2 : hexH / 2;
// Draw all path types in definition order
for (const pt of this.plugin.settings.pathTypes) {
const typeChains = region.pathChains.filter(
(c) => c.typeName === pt.name,
);
// Flatten every chain into a render list in path-type definition order,
// pairing each chain with its resolved type. Stable order = draw order.
const renderables = this.plugin.settings.pathTypes.flatMap((pt) =>
region.pathChains
.filter((c) => c.typeName === pt.name)
.map((chain) => ({ chain, pt })),
);
// Assign each chain a perpendicular "lane" offset so chains that share a
// route (e.g. a road and a river on the same hexes) render side by side
// instead of one stroke covering the other (issue #30). Chains that don't
// share any segment with another keep offset 0 → pixel-identical output.
const laneOffset = computeLaneOffsets(
renderables.map((r) => ({
hexes: r.chain.hexes,
width: r.pt.width,
typeName: r.pt.name,
})),
);
renderables.forEach(({ chain, pt }, idx) => {
const dash = DASH_ARRAYS[pt.lineStyle] ?? "";
for (const chain of typeChains) {
let pts: { cx: number; cy: number }[];
let smooth: boolean;
if (pt.routing === "meander") {
pts = buildMeanderPts(chain.hexes, centerMap);
smooth = true;
} else if (pt.routing === "edge") {
pts = buildEdgePts(chain.hexes, centerMap, isFlat, hexRadius);
smooth = false;
} else {
pts = chain.hexes
.map((k) => centerMap.get(k))
.filter((p): p is { cx: number; cy: number } => !!p);
smooth = true;
}
if (pts.length >= 2) appendPath(pts, pt.color, pt.width, dash, smooth);
let pts: { cx: number; cy: number }[];
let smooth: boolean;
if (pt.routing === "meander") {
pts = buildMeanderPts(chain.hexes, centerMap);
smooth = true;
} else if (pt.routing === "edge") {
pts = buildEdgePts(chain.hexes, centerMap, isFlat, hexRadius);
smooth = false;
} else {
pts = chain.hexes
.map((k) => centerMap.get(k))
.filter((p): p is { cx: number; cy: number } => !!p);
smooth = true;
}
}
if (pts.length < 2) return;
pts = offsetPolyline(pts, laneOffset[idx]);
appendPath(pts, pt.color, pt.width, dash, smooth);
});
// Small circle to mark the active endpoint (only visible in path mode)
if (this.drawingMode === "path" && this.activePathEnd) {

View file

@ -183,6 +183,124 @@ export function sharpPath(pts: Pt[]): string {
return "M " + pts.map((p) => `${p.cx} ${p.cy}`).join(" L ");
}
/**
* Shift every point of a polyline perpendicular to its local tangent by
* `offset` pixels, producing a curve parallel to the original. Used to render
* path chains that share the same hexes (e.g. a road and a river on one route)
* side by side instead of one stroke covering the other.
*
* The perpendicular is canonicalised per geometric tangent (independent of the
* direction the chain's points were stored in), so two chains tracing the same
* route in opposite orders still separate onto opposite sides rather than
* landing on top of each other. `offset === 0` returns the input unchanged so
* non-overlapping paths render pixel-identically to before.
*/
export function offsetPolyline(pts: Pt[], offset: number): Pt[] {
if (offset === 0 || pts.length < 2) return pts;
return pts.map((p, i) => {
const prev = pts[i === 0 ? 0 : i - 1];
const next = pts[i === pts.length - 1 ? i : i + 1];
let tx = next.cx - prev.cx;
let ty = next.cy - prev.cy;
const len = Math.hypot(tx, ty);
if (len === 0) return p;
tx /= len;
ty /= len;
// Canonicalise tangent direction so the perpendicular at a given point on
// the route is the same no matter which way the chain runs.
if (tx < 0 || (tx === 0 && ty < 0)) {
tx = -tx;
ty = -ty;
}
// Perpendicular = tangent rotated 90°: (-ty, tx).
return { cx: p.cx + -ty * offset, cy: p.cy + tx * offset };
});
}
/**
* Assign each path chain a perpendicular "lane" offset (in pixels) so chains of
* *different* types that share a route render side by side rather than one
* stroke covering the other (issue #30).
*
* Two chains are considered to share a route when they share at least one
* *segment* an unordered pair of adjacent hexes not merely a single hex, so
* paths that only cross at one cell are left untouched. Chains linked through
* shared segments are grouped (transitively, via union-find).
*
* Within a group, lanes are assigned **per distinct path type**, not per chain:
* chains of the *same* type share a lane (offset) so overlaid same-type paths
* (e.g. two rivers tracing one route) merge into a single stroke, while
* different types fan out into separate lanes. A group containing only one type
* gets no offset. Lanes are centred on zero: `(lane - (T-1)/2) * gap` for T
* distinct types, where `gap` is the widest stroke in the group plus a small
* margin. A chain sharing no segment with any other keeps offset 0, so the
* common single-path case renders pixel-identically to before. `chains` order
* is the draw order and decides lane order (earlier type more negative).
*/
export function computeLaneOffsets(
chains: { hexes: string[]; width: number; typeName: string }[],
): number[] {
const n = chains.length;
const offsets = new Array<number>(n).fill(0);
if (n < 2) return offsets;
// Canonical (direction-independent) segment keys for each chain.
const segSets = chains.map((c) => {
const s = new Set<string>();
for (let i = 0; i < c.hexes.length - 1; i++) {
const a = c.hexes[i];
const b = c.hexes[i + 1];
s.add(a < b ? `${a}|${b}` : `${b}|${a}`);
}
return s;
});
// Union-find: group chains that share ≥1 segment.
const parent = Array.from({ length: n }, (_, i) => i);
const find = (i: number): number =>
parent[i] === i ? i : (parent[i] = find(parent[i]));
const union = (a: number, b: number) => {
parent[find(a)] = find(b);
};
const segOwner = new Map<string, number>();
for (let i = 0; i < n; i++) {
for (const seg of segSets[i]) {
const owner = segOwner.get(seg);
if (owner === undefined) segOwner.set(seg, i);
else union(owner, i);
}
}
// Bucket by group root, preserving draw order within each group.
const groups = new Map<number, number[]>();
for (let i = 0; i < n; i++) {
const root = find(i);
const g = groups.get(root);
if (g) g.push(i);
else groups.set(root, [i]);
}
for (const members of groups.values()) {
// Distinct types in this group, in first-seen (draw) order.
const laneOfType = new Map<string, number>();
for (const i of members) {
if (!laneOfType.has(chains[i].typeName)) {
laneOfType.set(chains[i].typeName, laneOfType.size);
}
}
if (laneOfType.size < 2) continue; // single type → merge, no offset
const maxWidth = Math.max(...members.map((i) => chains[i].width));
const gap = maxWidth + 2;
const mid = (laneOfType.size - 1) / 2;
for (const i of members) {
const lane = laneOfType.get(chains[i].typeName) ?? 0;
offsets[i] = (lane - mid) * gap;
}
}
return offsets;
}
// ── Chain routing helpers ─────────────────────────────────────────────────────
/**

View file

@ -5,6 +5,8 @@ import {
hexCenter,
hexPolygonPoints,
gridBoundingBox,
offsetPolyline,
computeLaneOffsets,
} from "../src/hex-map/hexGeometry";
const SQRT3 = Math.sqrt(3);
@ -136,3 +138,125 @@ describe("gridBoundingBox", () => {
expect(height).toBeLessThan(400);
});
});
describe("offsetPolyline", () => {
it("returns the input unchanged when offset is 0", () => {
const pts = [
{ cx: 0, cy: 0 },
{ cx: 10, cy: 0 },
];
expect(offsetPolyline(pts, 0)).toBe(pts);
});
it("returns the input unchanged for fewer than 2 points", () => {
const pts = [{ cx: 5, cy: 5 }];
expect(offsetPolyline(pts, 4)).toBe(pts);
});
it("shifts a horizontal line perpendicular (downward in screen coords)", () => {
// Tangent +x → perpendicular (-ty, tx) = (0, +1) → shifts +y.
const out = offsetPolyline(
[
{ cx: 0, cy: 0 },
{ cx: 10, cy: 0 },
{ cx: 20, cy: 0 },
],
3,
);
out.forEach((p) => expect(close(p.cy, 3)).toBe(true));
expect(close(out[0].cx, 0)).toBe(true);
expect(close(out[2].cx, 20)).toBe(true);
});
it("puts opposite-direction chains on opposite sides for matching offsets", () => {
const forward = [
{ cx: 0, cy: 0 },
{ cx: 10, cy: 0 },
];
const backward = [
{ cx: 10, cy: 0 },
{ cx: 0, cy: 0 },
];
// Same geometric route, stored reversed. With the SAME offset value the
// canonicalised perpendicular must land them on the same physical side.
const a = offsetPolyline(forward, 3);
const b = offsetPolyline(backward, 3);
a.forEach((p) => expect(close(p.cy, 3)).toBe(true));
b.forEach((p) => expect(close(p.cy, 3)).toBe(true));
});
});
describe("computeLaneOffsets", () => {
it("returns all-zero for fewer than 2 chains", () => {
expect(computeLaneOffsets([])).toEqual([]);
expect(
computeLaneOffsets([
{ hexes: ["0_0", "1_0"], width: 4, typeName: "river" },
]),
).toEqual([0]);
});
it("leaves non-overlapping chains at offset 0", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_0"], width: 4, typeName: "road" },
{ hexes: ["5_5", "6_5"], width: 4, typeName: "river" },
]);
expect(out).toEqual([0, 0]);
});
it("splits two DIFFERENT-type chains sharing a segment into symmetric lanes", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "road" },
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "river" },
]);
// 2 distinct types, gap = maxWidth(4) + 2 = 6, mid = 0.5 → offsets -3, +3.
expect(out).toEqual([-3, 3]);
});
it("MERGES same-type chains on the same route (no offset)", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "river" },
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "river" },
]);
// Single distinct type in the group → both stay centred so they overlap.
expect(out).toEqual([0, 0]);
});
it("merges same-type but still offsets a different type sharing the route", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "river" },
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "river" },
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "road" },
]);
// 2 distinct types (river lane 0, road lane 1), mid = 0.5, gap = 6.
// Both rivers share the river lane → -3; the road → +3.
expect(out).toEqual([-3, -3, 3]);
});
it("treats reversed hex order as the same route (shared segment)", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "road" },
{ hexes: ["2_0", "1_0", "0_0"], width: 4, typeName: "river" },
]);
expect(out).toEqual([-3, 3]);
});
it("does not split chains that merely cross at a single hex", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_1"], width: 4, typeName: "road" },
{ hexes: ["1_1", "2_2"], width: 4, typeName: "river" },
]);
// They share hex 1_1 but no common segment → no offset.
expect(out).toEqual([0, 0]);
});
it("fans three distinct types sharing a route into three centred lanes", () => {
const out = computeLaneOffsets([
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "road" },
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "river" },
{ hexes: ["0_0", "1_0", "2_0"], width: 4, typeName: "trail" },
]);
// 3 distinct types, gap = 6, mid = 1 → lanes -6, 0, +6.
expect(out).toEqual([-6, 0, 6]);
});
});