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]); + }); +});