diff --git a/ISSUES.md b/ISSUES.md
new file mode 100644
index 0000000..e4e2154
--- /dev/null
+++ b/ISSUES.md
@@ -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
+ **~4โ6 ms/render**; switched to a `.duckmage-hex-painted` class selector
+ (`setHexColor` helper) โ overhead gone. โ
landed.
+ - `renderGrid()`'s `viewportEl.empty()` recreated the `
` 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.
diff --git a/src/export/mapPngRenderer.ts b/src/export/mapPngRenderer.ts
index 388a3b0..a58dc44 100644
--- a/src/export/mapPngRenderer.ts
+++ b/src/export/mapPngRenderer.ts
@@ -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" });
diff --git a/src/hex-map/HexMapView.ts b/src/hex-map/HexMapView.ts
index 14341d2..7cf5292 100644
--- a/src/hex-map/HexMapView.ts
+++ b/src/hex-map/HexMapView.ts
@@ -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) {
diff --git a/src/hex-map/hexGeometry.ts b/src/hex-map/hexGeometry.ts
index 54dd457..4f5650b 100644
--- a/src/hex-map/hexGeometry.ts
+++ b/src/hex-map/hexGeometry.ts
@@ -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(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();
+ 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();
+ 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();
+ 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();
+ 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 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/**
diff --git a/tests/hexGeometry.test.ts b/tests/hexGeometry.test.ts
index f835273..736ee91 100644
--- a/tests/hexGeometry.test.ts
+++ b/tests/hexGeometry.test.ts
@@ -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]);
+ });
+});