From b1151a0166a3fa8500936a2f538691cc8f672b9e Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Wed, 6 May 2026 14:49:31 +0200 Subject: [PATCH 01/48] add more testing in preparation of refactors --- package.json | 3 +- playwright.config.ts | 27 +++++++++++-- tests/e2e/burgs.spec.ts | 6 +-- tests/e2e/fixtures.ts | 60 +++++++++++++++++++++++++++++ tests/e2e/lakes-layer.spec.ts | 6 +-- tests/e2e/layers.spec.ts | 19 +++++++-- tests/e2e/load-map.spec.ts | 4 +- tests/e2e/states.spec.ts | 6 +-- tests/e2e/visual-regression.spec.ts | 58 ++++++++++++++++++++++++++++ tests/e2e/zones-export.spec.ts | 6 +-- 10 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/fixtures.ts create mode 100644 tests/e2e/visual-regression.spec.ts diff --git a/package.json b/package.json index 578ade425..2b49249ed 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test": "vitest", "test:browser": "vitest --config=vitest.browser.config.ts", "test:e2e": "playwright test", + "test:e2e:update": "playwright test --update-snapshots", "lint": "biome check --write", "format": "biome format --write" }, @@ -48,4 +49,4 @@ "engines": { "node": ">=24.0.0" } -} +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index 5b73f1b59..00e31c182 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,6 +3,17 @@ import { defineConfig, devices } from '@playwright/test' const isCI = !!process.env.CI const skipBuild = !!process.env.SKIP_BUILD +/** Dedicated port avoids reuseExistingServer attaching to another project on the default Vite port. */ +const devPort = process.env.PLAYWRIGHT_DEV_PORT ?? '5199' +const devOrigin = `http://127.0.0.1:${devPort}` +const previewOrigin = 'http://127.0.0.1:4173' +/** Matches vite.config.ts base (NETLIFY uses '/' for deploy previews). */ +const appPath = process.env.NETLIFY ? '' : '/Fantasy-Map-Generator' +/** Trailing slash required so relative navigations resolve under the app path. */ +const baseURL = appPath + ? `${isCI ? previewOrigin : devOrigin}${appPath}/` + : `${isCI ? previewOrigin : devOrigin}/` + export default defineConfig({ testDir: './tests/e2e', fullyParallel: true, @@ -10,10 +21,16 @@ export default defineConfig({ retries: 0, workers: isCI ? 2 : undefined, reporter: 'html', + expect: { + toHaveScreenshot: { + maxDiffPixels: 15000, + threshold: 0.25, + }, + }, // Use OS-independent snapshot names (HTML content is the same across platforms) snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}', use: { - baseURL: isCI ? 'http://localhost:4173' : 'http://localhost:5173', + baseURL, trace: 'on-first-retry', // Fixed viewport to ensure consistent map rendering viewport: { width: 1280, height: 720 }, @@ -27,8 +44,12 @@ export default defineConfig({ webServer: { // In CI: build (done as a separate cached step) and preview for production-like testing // In dev: use vite dev server (faster, no rebuild needed) - command: isCI ? (skipBuild ? 'npm run preview' : 'npm run build && npm run preview') : 'npm run dev', - url: isCI ? 'http://localhost:4173' : 'http://localhost:5173', + command: isCI + ? skipBuild + ? 'npm run preview -- --host 127.0.0.1' + : 'npm run build && npm run preview -- --host 127.0.0.1' + : `npm run dev -- --host 127.0.0.1 --port ${devPort}`, + url: baseURL, reuseExistingServer: !isCI, timeout: 120000, }, diff --git a/tests/e2e/burgs.spec.ts b/tests/e2e/burgs.spec.ts index f78bc38f7..94b7f6132 100644 --- a/tests/e2e/burgs.spec.ts +++ b/tests/e2e/burgs.spec.ts @@ -1,17 +1,17 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "./fixtures"; test.describe("Burgs.add", () => { test.beforeEach(async ({ context, page }) => { await context.clearCookies(); - await page.goto("/"); + await page.goto(""); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); // Navigate with seed parameter and wait for full load - await page.goto("/?seed=test-burgs&width=1280&height=720"); + await page.goto("?seed=test-burgs&width=1280&height=720"); // Wait for map generation to complete await page.waitForFunction( diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 000000000..418e529ac --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,60 @@ +/** + * Shared Playwright test instance with an end-frame #map screenshot after each passed test. + * Skipped on CI (pixel baselines are local-only). See playwright.config.ts expect.toHaveScreenshot. + * layers.spec.ts uses a shared page and registers its own end-frame hook (see that file). + */ +import type { Page, TestInfo } from "@playwright/test"; +import { expect, test as base } from "@playwright/test"; + +export async function waitForLoadingOverlayGone(page: Page) { + await page.waitForFunction(() => { + const loading = document.getElementById("loading"); + if (!loading) return true; + const opacity = Number.parseFloat(getComputedStyle(loading).opacity || "1"); + return opacity < 0.01; + }, { timeout: 120000 }); +} + +export async function waitForMapSvgReady(page: Page) { + await page.waitForFunction(() => (window as unknown as { mapId?: unknown }).mapId !== undefined, { + timeout: 120000, + }); + await page.waitForFunction(() => { + const ocean = document.getElementById("ocean"); + return ocean != null && ocean.childNodes.length > 0; + }, { timeout: 120000 }); + await waitForLoadingOverlayGone(page); + await page.waitForTimeout(500); +} + +export function endFrameSnapshotName(testInfo: TestInfo): string { + const slug = testInfo.titlePath + .filter((s) => s.length > 0 && !/\.spec\.[tj]s$/i.test(s)) + .join("__") + .replace(/[^\w.-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .slice(0, 200); + return `end-frame__${slug || "unknown"}.png`; +} + +function skipGlobalEndFrameScreenshot(testInfo: TestInfo): boolean { + const name = testInfo.file.split(/[/\\]/).pop() ?? ""; + return name === "layers.spec.ts"; +} + +export const test = base; + +test.afterEach(async ({ page }, testInfo) => { + if (process.env.CI) return; + if (testInfo.status !== "passed") return; + if (skipGlobalEndFrameScreenshot(testInfo)) return; + + await waitForLoadingOverlayGone(page); + await page.waitForTimeout(100); + await expect(page.locator("#map")).toHaveScreenshot(endFrameSnapshotName(testInfo), { + timeout: 30_000, + }); +}); + +export { expect }; diff --git a/tests/e2e/lakes-layer.spec.ts b/tests/e2e/lakes-layer.spec.ts index 0d9a19102..63c0f8f42 100644 --- a/tests/e2e/lakes-layer.spec.ts +++ b/tests/e2e/lakes-layer.spec.ts @@ -1,16 +1,16 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "./fixtures"; test.describe("Lakes layer", () => { test.beforeEach(async ({ context, page }) => { await context.clearCookies(); - await page.goto("/"); + await page.goto(""); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); - await page.goto("/?seed=test-seed&width=1280&height=720"); + await page.goto("?seed=test-seed&width=1280&height=720"); // Wait for map generation to complete await page.waitForFunction(() => (window as any).mapId !== undefined, { diff --git a/tests/e2e/layers.spec.ts b/tests/e2e/layers.spec.ts index dd69cae1a..0ccfad8f9 100644 --- a/tests/e2e/layers.spec.ts +++ b/tests/e2e/layers.spec.ts @@ -1,4 +1,5 @@ -import { Browser, BrowserContext, expect, Page, test } from '@playwright/test' +import type { Browser, BrowserContext, Page } from '@playwright/test' +import { endFrameSnapshotName, expect, test, waitForLoadingOverlayGone } from './fixtures' // All tests in this describe block only READ the DOM — they never modify state. // Load the map once for the entire suite instead of before every test. @@ -6,12 +7,14 @@ let sharedContext: BrowserContext let sharedPage: Page test.describe('map layers', () => { + test.describe.configure({ mode: 'serial' }) + test.beforeAll(async ({ browser }: { browser: Browser }) => { sharedContext = await browser.newContext() sharedPage = await sharedContext.newPage() await sharedContext.clearCookies() - await sharedPage.goto('/') + await sharedPage.goto('') await sharedPage.evaluate(() => { localStorage.clear() sessionStorage.clear() @@ -21,7 +24,7 @@ test.describe('map layers', () => { // NOTE: // - We use a fixed seed ("test-seed") to make map generation deterministic for snapshot tests. // - Snapshots are OS-independent (configured in playwright.config.ts). - await sharedPage.goto('/?seed=test-seed&&width=1280&height=720') + await sharedPage.goto('?seed=test-seed&width=1280&height=720') // Wait for map generation to complete by checking window.mapId // mapId is exposed on window at the very end of showStatistics() @@ -31,6 +34,16 @@ test.describe('map layers', () => { await sharedPage.waitForTimeout(500) }) + test.afterEach(async ({}, testInfo) => { + if (process.env.CI) return; + if (testInfo.status !== 'passed') return; + await waitForLoadingOverlayGone(sharedPage); + await sharedPage.waitForTimeout(100); + await expect(sharedPage.locator('#map')).toHaveScreenshot(endFrameSnapshotName(testInfo), { + timeout: 30_000, + }); + }) + test.afterAll(async () => { await sharedPage.close() await sharedContext.close() diff --git a/tests/e2e/load-map.spec.ts b/tests/e2e/load-map.spec.ts index c135f188c..4e5a9dbea 100644 --- a/tests/e2e/load-map.spec.ts +++ b/tests/e2e/load-map.spec.ts @@ -1,11 +1,11 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "./fixtures"; import path from "path"; test.describe("Map loading", () => { test.beforeEach(async ({ context, page }) => { await context.clearCookies(); - await page.goto("/"); + await page.goto(""); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); diff --git a/tests/e2e/states.spec.ts b/tests/e2e/states.spec.ts index 2cba3dcf5..4f914e841 100644 --- a/tests/e2e/states.spec.ts +++ b/tests/e2e/states.spec.ts @@ -1,17 +1,17 @@ -import {test, expect} from "@playwright/test"; +import { expect, test } from "./fixtures"; test.describe("States", () => { test.beforeEach(async ({context, page}) => { await context.clearCookies(); - await page.goto("/"); + await page.goto(""); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); // Navigate with seed parameter and wait for full load - await page.goto("/?seed=test-states&width=1280&height=720"); + await page.goto("?seed=test-states&width=1280&height=720"); // Wait for map generation to complete await page.waitForFunction(() => (window as any).mapId !== undefined, {timeout: 60000}); diff --git a/tests/e2e/visual-regression.spec.ts b/tests/e2e/visual-regression.spec.ts new file mode 100644 index 000000000..775951d0e --- /dev/null +++ b/tests/e2e/visual-regression.spec.ts @@ -0,0 +1,58 @@ +/** + * Pixel regression on the root SVG (#map). Baselines are maintained on a single machine; + * this suite is skipped in CI (see describeVisual below). + * + * Run locally before large refactors: npm run test:e2e + * Refresh PNG baselines only when the visual change is intentional: + * npm run test:e2e:update-visual + * End-frame snapshots for all E2E tests live next to each spec; refresh everything: + * npm run test:e2e:update-snapshots + */ +import path from "path"; +import { expect, test, waitForMapSvgReady } from "./fixtures"; + +/** Pixel baselines are recorded against the dev server; skip on CI until snapshots are cross-platform. */ +const describeVisual = process.env.CI ? test.describe.skip : test.describe; + +describeVisual("Visual regression", () => { + test.describe.configure({ + mode: "serial", + timeout: 180000, + }); + + test.beforeEach(async ({ context }) => { + await context.clearCookies(); + }); + + test("seeded generation matches baseline", async ({ page }) => { + await page.goto(""); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + await page.goto("?seed=test-seed&width=1280&height=720"); + + await waitForMapSvgReady(page); + + await expect(page.locator("#map")).toHaveScreenshot("seeded-generation.png"); + }); + + test("loaded demo.map matches baseline", async ({ page }) => { + await page.goto(""); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + await page.waitForSelector("#mapToLoad", { state: "attached" }); + + const fileInput = page.locator("#mapToLoad"); + const mapFilePath = path.join(__dirname, "../fixtures/demo.map"); + await fileInput.setInputFiles(mapFilePath); + + await waitForMapSvgReady(page); + + await expect(page.locator("#map")).toHaveScreenshot("loaded-demo-map.png"); + }); +}); diff --git a/tests/e2e/zones-export.spec.ts b/tests/e2e/zones-export.spec.ts index b2a8356df..9a301f337 100644 --- a/tests/e2e/zones-export.spec.ts +++ b/tests/e2e/zones-export.spec.ts @@ -1,17 +1,17 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "./fixtures"; test.describe("Zone Export", () => { test.beforeEach(async ({ context, page }) => { await context.clearCookies(); - await page.goto("/"); + await page.goto(""); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); // Navigate with seed parameter and wait for full load - await page.goto("/?seed=test-zones-export&width=1280&height=720"); + await page.goto("?seed=test-zones-export&width=1280&height=720"); // Wait for map generation to complete await page.waitForFunction( From 80ad54980c463c13f942d43f675c4160f6d58559 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Wed, 6 May 2026 16:01:35 +0200 Subject: [PATCH 02/48] adding the journey feature v0 --- public/main.js | 2 + public/modules/io/load.js | 9 + public/modules/io/save.js | 4 +- public/modules/ui/hotkeys.js | 1 + public/modules/ui/journey-editor.js | 173 ++++++++++++++ public/modules/ui/layers.js | 23 ++ public/modules/ui/tools.js | 1 + public/versioning.js | 3 +- src/index.html | 40 +++- src/modules/index.ts | 1 + src/modules/journey-draw.test.ts | 92 ++++++++ src/modules/journey-draw.ts | 343 ++++++++++++++++++++++++++++ src/types/PackedGraph.ts | 2 + src/types/global.ts | 1 + tests/e2e/journey-layer.spec.ts | 73 ++++++ 15 files changed, 760 insertions(+), 8 deletions(-) create mode 100644 public/modules/ui/journey-editor.js create mode 100644 src/modules/journey-draw.test.ts create mode 100644 src/modules/journey-draw.ts create mode 100644 tests/e2e/journey-layer.spec.ts diff --git a/public/main.js b/public/main.js index 67c3c54f8..b31196b90 100644 --- a/public/main.js +++ b/public/main.js @@ -77,6 +77,7 @@ let burgIcons = icons.append("g").attr("id", "burgIcons"); let anchors = icons.append("g").attr("id", "anchors"); let armies = viewbox.append("g").attr("id", "armies"); let markers = viewbox.append("g").attr("id", "markers"); +let journeys = viewbox.append("g").attr("id", "journeys").style("display", "none"); let fogging = viewbox .append("g") .attr("id", "fogging-cont") @@ -634,6 +635,7 @@ async function generate(options) { else delete grid.cells.h; grid.cells.h = await HeightmapGenerator.generate(grid); pack = {}; // reset pack + pack.journey = {points: []}; Features.markupGrid(); addLakesInDeepDepressions(); diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 2bd8fa807..33b297264 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -357,6 +357,7 @@ async function parseLoadedData(data, mapVersion) { anchors = icons.select("#anchors"); armies = viewbox.select("#armies"); markers = viewbox.select("#markers"); + journeys = viewbox.select("#journeys"); ruler = viewbox.select("#ruler"); fogging = viewbox.select("#fogging"); debug = viewbox.select("#debug"); @@ -371,6 +372,9 @@ async function parseLoadedData(data, mapVersion) { if (!emblems.size()) { emblems = viewbox.insert("g", "#labels").attr("id", "emblems").style("display", "none"); } + if (!journeys.size()) { + journeys = viewbox.insert("g", "#ruler").attr("id", "journeys").style("display", "none"); + } } { @@ -413,6 +417,8 @@ async function parseLoadedData(data, mapVersion) { // data[28] had deprecated cells.crossroad pack.cells.routes = data[36] ? JSON.parse(data[36]) : {}; pack.ice = data[39] ? JSON.parse(data[39]) : []; + pack.journey = data[40] ? JSON.parse(data[40]) : {points: []}; + if (!pack.journey.points) pack.journey.points = []; if (data[31]) { const namesDL = data[31].split("/"); @@ -464,6 +470,8 @@ async function parseLoadedData(data, mapVersion) { if (isVisible(icons)) turnOn("toggleBurgIcons"); if (hasChildren(armies) && isVisible(armies)) turnOn("toggleMilitary"); if (hasChild(markers, "svg")) turnOn("toggleMarkers"); + if (isVisible(journeys) && (hasChild(journeys, "path") || hasChild(journeys, "circle"))) + turnOn("toggleJourney"); if (isVisible(ruler)) turnOn("toggleRulers"); if (isVisible(scaleBar)) turnOn("toggleScaleBar"); if (isVisibleNode(ensureEl("vignette"))) turnOn("toggleVignette"); @@ -735,6 +743,7 @@ async function parseLoadedData(data, mapVersion) { // draw data layers (not kept in svg) if (rulers && layerIsOn("toggleRulers")) rulers.draw(); if (layerIsOn("toggleGrid")) drawGrid(); + if (layerIsOn("toggleJourney")) drawJourney(); } { diff --git a/public/modules/io/save.js b/public/modules/io/save.js index 1d5642d62..049a9c107 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -103,6 +103,7 @@ function prepareMapData() { const routes = JSON.stringify(pack.routes); const zones = JSON.stringify(pack.zones); const ice = JSON.stringify(pack.ice); + const journey = JSON.stringify(pack.journey || {points: []}); // store name array only if not the same as default const defaultNB = Names.getNameBases(); @@ -157,7 +158,8 @@ function prepareMapData() { cellRoutes, routes, zones, - ice + ice, + journey ].join("\r\n"); return mapData; } diff --git a/public/modules/ui/hotkeys.js b/public/modules/ui/hotkeys.js index 2290b6c05..f2ddec442 100644 --- a/public/modules/ui/hotkeys.js +++ b/public/modules/ui/hotkeys.js @@ -55,6 +55,7 @@ function handleKeyup(event) { else if ((shift || altShift) && code === "KeyV") overviewRivers(); else if ((shift || altShift) && code === "KeyM") overviewMilitary(); else if ((shift || altShift) && code === "KeyK") overviewMarkers(); + else if ((shift || altShift) && code === "KeyJ") editJourney(); else if ((shift || altShift) && code === "KeyE") viewCellDetails(); else if (key === "!") toggleAddBurg(); else if (key === "@") toggleAddLabel(); diff --git a/public/modules/ui/journey-editor.js b/public/modules/ui/journey-editor.js new file mode 100644 index 000000000..15ec89f6a --- /dev/null +++ b/public/modules/ui/journey-editor.js @@ -0,0 +1,173 @@ +"use strict"; + +function ensurePackJourney() { + if (!pack.journey || !Array.isArray(pack.journey.points)) pack.journey = {points: []}; +} + +function journeyMergeSelectHTML(fromIndex) { + let opts = + ''; + const pts = pack.journey.points; + for (let j = 0; j < pts.length; j++) { + if (j === fromIndex) continue; + const pj = pts[j]; + opts += ``; + } + return opts; +} + +function journeyEditorRefreshBody() { + const body = ensureEl("journeyEditorBody"); + body.innerHTML = ""; + ensurePackJourney(); + pack.journey.points.forEach((pt, i) => { + body.insertAdjacentHTML( + "beforeend", + /* html */ `
+ #${i + 1} + + + + +
`, + ); + }); +} + +function journeyEditorBodyChange(ev) { + const t = ev.target; + const row = t.closest("[data-index]"); + if (!row) return; + const idx = +row.dataset.index; + if (!Number.isFinite(idx)) return; + + if (t.classList.contains("journey-merge-select")) { + const j = +t.value; + if (t.value === "" || !Number.isFinite(j)) return; + ensurePackJourney(); + const pts = pack.journey.points; + if (j < 0 || j >= pts.length || idx < 0 || idx >= pts.length || idx === j) { + t.value = ""; + return; + } + pts[idx][0] = rn(pts[j][0], 2); + pts[idx][1] = rn(pts[j][1], 2); + t.value = ""; + journeyEditorRefreshBody(); + drawJourney(); + return; + } + + if (t.classList.contains("journey-coord-input")) { + ensurePackJourney(); + const pts = pack.journey.points; + if (idx < 0 || idx >= pts.length) return; + const coord = t.dataset.coord; + const v = parseFloat(t.value); + if (!Number.isFinite(v)) return; + const rnVal = rn(v, 2); + if (coord === "x") pts[idx][0] = rnVal; + else if (coord === "y") pts[idx][1] = rnVal; + t.value = rnVal; + drawJourney(); + } +} + +function journeyAppendPoint(xy) { + ensurePackJourney(); + pack.journey.points.push(xy); + journeyEditorRefreshBody(); + drawJourney(); +} + +function journeyEditorOnClick() { + const evt = d3.event.sourceEvent || window.event; + const target = evt.target; + + let x; + let y; + if (target?.classList?.contains("journey-waypoint")) { + x = +target.getAttribute("data-jx"); + y = +target.getAttribute("data-jy"); + } else if (target?.closest?.(".journey-waypoint")) { + const el = target.closest(".journey-waypoint"); + x = +el.getAttribute("data-jx"); + y = +el.getAttribute("data-jy"); + } else { + const pt = d3.mouse(this); + x = rn(pt[0], 2); + y = rn(pt[1], 2); + } + + journeyAppendPoint([x, y]); + + if (!evt.shiftKey) { + // keep dialog open like route creator; Shift adds multiple quickly + } +} + +function closeJourneyEditor() { + ensureEl("journeyEditorBody").innerHTML = ""; + viewbox.on("click.journey", null).style("cursor", null); + clearMainTip(); + restoreDefaultEvents(); +} + +function editJourney() { + if (customization) return; + closeDialogs("#journeyEditor, .stable"); + ensurePackJourney(); + + if (!layerIsOn("toggleJourney")) toggleJourney(); + + tip( + "Click the map to add the next stop, or an existing circle to revisit it. Edit X/Y in the list to nudge a stop. Use “Match stop…” to snap one stop to another’s position. Shift: add several stops quickly.", + true, + ); + viewbox.style("cursor", "crosshair").on("click.journey", journeyEditorOnClick); + + $("#journeyEditor").dialog({ + title: "Journey editor", + resizable: false, + position: {my: "left top", at: "left+10 top+10", of: "#map"}, + close: closeJourneyEditor, + }); + + if (modules.editJourney) { + journeyEditorRefreshBody(); + return; + } + modules.editJourney = true; + + ensureEl("journeyEditorBody").on("change", journeyEditorBodyChange); + + ensureEl("journeyEditorBody").on("click", ev => { + if (!ev.target.classList.contains("icon-trash-empty")) return; + const row = ev.target.closest("[data-index]"); + if (!row) return; + const idx = +row.dataset.index; + if (!Number.isFinite(idx)) return; + ensurePackJourney(); + pack.journey.points.splice(idx, 1); + journeyEditorRefreshBody(); + drawJourney(); + }); + + ensureEl("journeyEditorUndo").on("click", () => { + ensurePackJourney(); + pack.journey.points.pop(); + journeyEditorRefreshBody(); + drawJourney(); + }); + + ensureEl("journeyEditorClear").on("click", () => { + ensurePackJourney(); + pack.journey.points = []; + journeyEditorRefreshBody(); + drawJourney(); + }); + + ensureEl("journeyEditorDone").on("click", () => $("#journeyEditor").dialog("close")); + + journeyEditorRefreshBody(); +} diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 3c601fe8d..8b5e6f4f4 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -59,6 +59,7 @@ function getDefaultPresets() { "toggleIce", "toggleLakes", "toggleMarkers", + "toggleJourney", "toggleRivers", "toggleRoutes", "toggleScaleBar", @@ -216,6 +217,7 @@ function drawLayers() { if (layerIsOn("toggleBurgIcons")) drawBurgIcons(); if (layerIsOn("toggleMilitary")) drawMilitary(); if (layerIsOn("toggleMarkers")) drawMarkers(); + if (layerIsOn("toggleJourney")) drawJourney(); if (layerIsOn("toggleRulers")) rulers.draw(); // scale bar // vignette @@ -853,6 +855,26 @@ function toggleMarkers(event) { } } +function drawJourney() { + TIME && console.time("drawJourney"); + if (!pack.journey) pack.journey = {points: []}; + JourneyDraw.redraw(defs, journeys); + TIME && console.timeEnd("drawJourney"); +} + +function toggleJourney(event) { + if (!layerIsOn("toggleJourney")) { + turnButtonOn("toggleJourney"); + drawJourney(); + $("#journeys").fadeIn(); + if (event && isCtrlClick(event)) editJourney(); + } else { + if (event && isCtrlClick(event)) return editJourney(); + $("#journeys").fadeOut(); + turnButtonOff("toggleJourney"); + } +} + function toggleLabels(event) { if (!layerIsOn("toggleLabels")) { turnButtonOn("toggleLabels"); @@ -1022,5 +1044,6 @@ function getLayer(id) { if (id === "toggleLabels") return $("#labels"); if (id === "toggleBurgIcons") return $("#icons"); if (id === "toggleMarkers") return $("#markers"); + if (id === "toggleJourney") return $("#journeys"); if (id === "toggleRulers") return $("#ruler"); } diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js index 3707263b1..748996b88 100644 --- a/public/modules/ui/tools.js +++ b/public/modules/ui/tools.js @@ -71,6 +71,7 @@ toolsContent.addEventListener("click", function (event) { else if (button === "addRiver") toggleAddRiver(); else if (button === "addRoute") createRoute(); else if (button === "addMarker") toggleAddMarker(); + else if (button === "addJourney") editJourney(); // click to create a new map buttons else if (button === "openSubmapTool") openSubmapTool(); else if (button === "openTransformTool") openTransformTool(); diff --git a/public/versioning.js b/public/versioning.js index 21bec91ac..823af693a 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -16,7 +16,7 @@ * For the changes that may be interesting to end users, update the `latestPublicChanges` array below (new changes on top). */ -const VERSION = "1.121.0"; +const VERSION = "1.122.0"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { @@ -30,6 +30,7 @@ if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format o } const latestPublicChanges = [ + "Journey layer: draw and edit a directional path on the map", "Jagged coastlines", "Heightmap Editor: Fill brush", "Editors: undo button", diff --git a/src/index.html b/src/index.html index 01d310e40..73ef2345d 100644 --- a/src/index.html +++ b/src/index.html @@ -633,6 +633,13 @@ > Routes +
  • + Journey +
  • +
    Show
    @@ -3091,6 +3105,19 @@ + + -
    +
    + - +
    @@ -8625,7 +8627,7 @@ - + diff --git a/src/modules/journey-draw.ts b/src/modules/journey-draw.ts index 8819a7f6d..1e851eb65 100644 --- a/src/modules/journey-draw.ts +++ b/src/modules/journey-draw.ts @@ -8,11 +8,12 @@ import type { import { burgJourneyStopRef, emptyPackJourney, + journeyLegToRefString, + journeyRefStringToLeg, journeyResolvedCoordinates, markerJourneyStopRef, normalizePackJourney, - newWaypointId, - nextDefaultWaypointName, + resolveJourneyLeg, resolveJourneyStopPosition, } from "./journey-model"; import { rn } from "../utils/numberUtils"; @@ -413,9 +414,10 @@ export class JourneyDrawModule { ); const idsAtCoord = new Map(); - for (const sid of journeyData.stopIds) { - const xy = resolveJourneyStopPosition(sid, journeyData, resCtx); + for (const leg of journeyData.stops) { + const xy = resolveJourneyLeg(leg, resCtx); if (!xy) continue; + const sid = journeyLegToRefString(leg); const ck = `${rn(xy[0], 2)},${rn(xy[1], 2)}`; const arr = idsAtCoord.get(ck) ?? []; if (!arr.includes(sid)) arr.push(sid); @@ -642,11 +644,12 @@ if (typeof window !== "undefined") { normalizePackJourney, journeyResolvedCoordinates, resolveJourneyStopPosition, + resolveJourneyLeg, + journeyRefStringToLeg, emptyPackJourney, - newWaypointId, - nextDefaultWaypointName, burgJourneyStopRef, markerJourneyStopRef, + journeyLegToRefString, }; } @@ -658,11 +661,12 @@ declare global { normalizePackJourney: typeof normalizePackJourney; journeyResolvedCoordinates: typeof journeyResolvedCoordinates; resolveJourneyStopPosition: typeof resolveJourneyStopPosition; + resolveJourneyLeg: typeof resolveJourneyLeg; + journeyRefStringToLeg: typeof journeyRefStringToLeg; emptyPackJourney: typeof emptyPackJourney; - newWaypointId: typeof newWaypointId; - nextDefaultWaypointName: typeof nextDefaultWaypointName; burgJourneyStopRef: typeof burgJourneyStopRef; markerJourneyStopRef: typeof markerJourneyStopRef; + journeyLegToRefString: typeof journeyLegToRefString; }; } } diff --git a/src/modules/journey-model.test.ts b/src/modules/journey-model.test.ts index 2cf411371..be552a879 100644 --- a/src/modules/journey-model.test.ts +++ b/src/modules/journey-model.test.ts @@ -1,134 +1,139 @@ import { describe, expect, it } from "vitest"; import { burgJourneyStopRef, + journeyLegToRefString, + journeyRefStringToLeg, journeyResolvedCoordinates, markerJourneyStopRef, normalizePackJourney, - nextDefaultWaypointName, parseJourneyStopRef, + resolveJourneyLeg, resolveJourneyStopPosition, type JourneyResolutionContext, + type JourneyStopLeg, type PackJourney, } from "./journey-model"; describe("normalizePackJourney", () => { - it("strips stray points key without inferring waypoints", () => { - const j: Record = { points: [[10, 20], [30, 40]] }; + it("strips stray points/stopIds/waypoints and yields empty stops", () => { + const j: Record = { + points: [[10, 20], [30, 40]], + stopIds: [], + waypoints: [], + }; normalizePackJourney(j); expect(j.points).toBeUndefined(); + expect(j.stopIds).toBeUndefined(); + expect(j.waypoints).toBeUndefined(); const normalized = j as unknown as PackJourney; - expect(normalized.stopIds).toEqual([]); - expect(normalized.waypoints).toEqual([]); + expect(normalized.stops).toEqual([]); expect(journeyResolvedCoordinates(normalized)).toEqual([]); }); - it("keeps new format and strips stray points key", () => { - const j = { - stopIds: ["a"], - waypoints: [{ id: "a", name: "Alpha", x: 1, y: 2 }], - points: [[99, 99]] as [number, number][], - }; - normalizePackJourney(j); - expect((j as { points?: unknown }).points).toBeUndefined(); - expect(j.stopIds).toEqual(["a"]); - expect(j.waypoints.length).toBe(1); - }); - - it("drops unknown waypoint stop ids", () => { + it("keeps stops array and strips legacy keys", () => { const j = { - stopIds: ["missing", "b"], - waypoints: [{ id: "b", name: "B", x: 0, y: 0 }], + stops: [{ kind: "burg" as const, id: 1 }], + stopIds: ["burg:99"], + waypoints: [{ id: "x" }], }; normalizePackJourney(j); - expect(j.stopIds).toEqual(["b"]); + expect(j.stopIds).toBeUndefined(); + expect(j.waypoints).toBeUndefined(); + expect(j.stops.length).toBe(1); + expect(j.stops[0]).toEqual({ kind: "burg", id: 1 }); }); - it("keeps well-formed burg/marker refs without pack", () => { - const j = { - stopIds: ["wp_1", burgJourneyStopRef(3), markerJourneyStopRef(7)], - waypoints: [{ id: "wp_1", name: "A", x: 1, y: 2 }], + it("migrates legacy stopIds burg/marker strings only", () => { + const j: Record = { + stopIds: ["wp_skip", burgJourneyStopRef(3), markerJourneyStopRef(7)], + waypoints: [{ id: "wp_skip", name: "A", x: 1, y: 2 }], }; normalizePackJourney(j); - expect(j.stopIds).toEqual(["wp_1", burgJourneyStopRef(3), markerJourneyStopRef(7)]); + expect((j as unknown as PackJourney).stops).toEqual([ + { kind: "burg", id: 3 }, + { kind: "marker", id: 7 }, + ]); }); - it("drops burg/marker refs when pack says missing", () => { - const j = { - stopIds: ["a", burgJourneyStopRef(5), markerJourneyStopRef(2)], - waypoints: [{ id: "a", name: "A", x: 0, y: 0 }], + it("drops legs when pack says missing burg/marker", () => { + const j: PackJourney = { + stops: [ + { kind: "burg", id: 5 }, + { kind: "marker", id: 2 }, + ], }; normalizePackJourney(j, { burgs: [{ i: 5, removed: false }], markers: [], }); - expect(j.stopIds).toEqual(["a", burgJourneyStopRef(5)]); + expect(j.stops).toEqual([{ kind: "burg", id: 5 }]); }); - it("strips points when catalog exists without stops", () => { - const j = { - stopIds: [], - waypoints: [{ id: "x", name: "Lonely", x: 5, y: 5 }], - points: [[1, 1]] as [number, number][], + it("prefers stops[] over legacy stopIds when both present", () => { + const j: Record = { + stops: [{ kind: "marker" as const, id: 1 }], + stopIds: [burgJourneyStopRef(9)], }; normalizePackJourney(j); - expect(j.waypoints.length).toBe(1); - expect(j.waypoints[0].id).toBe("x"); - expect(j.stopIds.length).toBe(0); - expect((j as { points?: unknown }).points).toBeUndefined(); + expect((j as unknown as PackJourney).stops).toEqual([{ kind: "marker", id: 1 }]); }); }); describe("parseJourneyStopRef", () => { it("parses burg and marker prefixes", () => { - expect(parseJourneyStopRef("burg:12")).toEqual({ kind: "burg", i: 12 }); - expect(parseJourneyStopRef("marker:3")).toEqual({ kind: "marker", i: 3 }); - expect(parseJourneyStopRef("wp_x")).toEqual({ kind: "waypoint", id: "wp_x" }); + expect(parseJourneyStopRef("burg:12")).toEqual({ kind: "burg", id: 12 }); + expect(parseJourneyStopRef("marker:3")).toEqual({ kind: "marker", id: 3 }); + expect(parseJourneyStopRef("wp_x")).toBeNull(); + }); +}); + +describe("journeyLegToRefString / journeyRefStringToLeg", () => { + it("roundtrips", () => { + const leg: JourneyStopLeg = { kind: "burg", id: 4 }; + expect(journeyRefStringToLeg(journeyLegToRefString(leg))).toEqual(leg); }); }); describe("journeyResolvedCoordinates", () => { const j: PackJourney = { - stopIds: ["w1", burgJourneyStopRef(10), markerJourneyStopRef(2)], - waypoints: [{ id: "w1", name: "W", x: 100, y: 200 }], + stops: [ + { kind: "burg", id: 10 }, + { kind: "marker", id: 2 }, + ], }; const ctx: JourneyResolutionContext = { burgs: [{ i: 10, x: 10, y: 20, removed: false }], markers: [{ i: 2, x: 30, y: 40 }], }; - it("resolves waypoint then burg then marker", () => { + it("resolves burg then marker", () => { expect(journeyResolvedCoordinates(j, ctx)).toEqual([ - [100, 200], [10, 20], [30, 40], ]); }); - it("skips missing burg/marker", () => { - const partial: PackJourney = { - stopIds: ["w1", burgJourneyStopRef(999)], - waypoints: j.waypoints, - }; - expect(journeyResolvedCoordinates(partial, ctx)).toEqual([[100, 200]]); + it("skips missing burg", () => { + expect(journeyResolvedCoordinates({ stops: [{ kind: "burg", id: 999 }] }, ctx)).toEqual([]); }); }); -describe("resolveJourneyStopPosition", () => { +describe("resolveJourneyLeg", () => { it("returns null for removed burg", () => { - const j: PackJourney = { stopIds: [], waypoints: [] }; const ctx: JourneyResolutionContext = { burgs: [{ i: 1, x: 1, y: 2, removed: true }], markers: [], }; - expect(resolveJourneyStopPosition(burgJourneyStopRef(1), j, ctx)).toBeNull(); + expect(resolveJourneyLeg({ kind: "burg", id: 1 }, ctx)).toBeNull(); }); }); -describe("nextDefaultWaypointName", () => { - it("increments Stop n from existing labels", () => { - expect(nextDefaultWaypointName([])).toBe("Stop 1"); - expect(nextDefaultWaypointName([{ id: "a", name: "Stop 3", x: 0, y: 0 }])).toBe( - "Stop 4", - ); +describe("resolveJourneyStopPosition", () => { + it("resolves ref string", () => { + const ctx: JourneyResolutionContext = { + burgs: [], + markers: [{ i: 3, x: 5, y: 6 }], + }; + expect(resolveJourneyStopPosition(markerJourneyStopRef(3), ctx)).toEqual([5, 6]); }); }); diff --git a/src/modules/journey-model.ts b/src/modules/journey-model.ts index a5211493f..f49c17c49 100644 --- a/src/modules/journey-model.ts +++ b/src/modules/journey-model.ts @@ -1,16 +1,23 @@ -import { rn } from "../utils/numberUtils"; +/** + * Journey path: ordered burg / marker references only (positions live on pack). + * Legacy `{ stopIds, waypoints }` is migrated on normalize (waypoint legs dropped). + */ + +/** One leg in the journey sequence (linked-list style via array order). */ +export interface JourneyBurgLeg { + kind: "burg"; + id: number; +} -export interface JourneyWaypoint { - id: string; - name: string; - x: number; - y: number; +export interface JourneyMarkerLeg { + kind: "marker"; + id: number; } -/** Catalog + ordered path (`stopIds`: waypoint ids and/or `burg:n` / `marker:n`). */ +export type JourneyStopLeg = JourneyBurgLeg | JourneyMarkerLeg; + export interface PackJourney { - stopIds: string[]; - waypoints: JourneyWaypoint[]; + stops: JourneyStopLeg[]; } /** Minimal pack slice for resolving burg/marker positions (avoids importing PackedGraph). */ @@ -37,19 +44,32 @@ export function markerJourneyStopRef(i: number): string { } export type ParsedJourneyStopRef = - | { kind: "waypoint"; id: string } - | { kind: "burg"; i: number } - | { kind: "marker"; i: number }; + | { kind: "burg"; id: number } + | { kind: "marker"; id: number }; + +export function journeyLegToRefString(leg: JourneyStopLeg): string { + return leg.kind === "burg" + ? burgJourneyStopRef(leg.id) + : markerJourneyStopRef(leg.id); +} export function parseJourneyStopRef(stopId: string): ParsedJourneyStopRef | null { const bm = BURG_REF_RE.exec(stopId); - if (bm) return { kind: "burg", i: +bm[1] }; + if (bm) return { kind: "burg", id: +bm[1] }; const mm = MARKER_REF_RE.exec(stopId); - if (mm) return { kind: "marker", i: +mm[1] }; - if (stopId.length > 0) return { kind: "waypoint", id: stopId }; + if (mm) return { kind: "marker", id: +mm[1] }; return null; } +/** UI / DOM string → stored leg (burg or marker only). */ +export function journeyRefStringToLeg(ref: string): JourneyStopLeg | null { + const p = parseJourneyStopRef(ref); + if (!p) return null; + return p.kind === "burg" + ? { kind: "burg", id: p.id } + : { kind: "marker", id: p.id }; +} + export function isWellFormedBurgStopRef(id: string): boolean { return BURG_REF_RE.test(id); } @@ -58,86 +78,68 @@ export function isWellFormedMarkerStopRef(id: string): boolean { return MARKER_REF_RE.test(id); } -export function newWaypointId(): string { - const uuid = - typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : ""; - if (uuid) return `wp_${uuid}`; - return `wp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; -} - export function emptyPackJourney(): PackJourney { - return { stopIds: [], waypoints: [] }; + return { stops: [] }; } -/** Next auto name `Stop n` based on existing waypoint names / count. */ -export function nextDefaultWaypointName(waypoints: JourneyWaypoint[]): string { - let maxN = 0; - const re = /^Stop\s+(\d+)$/i; - for (const w of waypoints) { - const m = re.exec(w.name.trim()); - if (m) maxN = Math.max(maxN, +m[1]); - } - return `Stop ${maxN + 1}`; -} - -function sanitizeWaypoints(raw: unknown[]): JourneyWaypoint[] { - const out: JourneyWaypoint[] = []; - const seen = new Set(); +function sanitizeStopsArray(raw: unknown[]): JourneyStopLeg[] { + const out: JourneyStopLeg[] = []; for (const item of raw) { if (!item || typeof item !== "object") continue; const o = item as Record; - const id = typeof o.id === "string" ? o.id : ""; - const name = - typeof o.name === "string" && o.name.trim() !== "" ? o.name : "Stop"; - const x = Number(o.x); - const y = Number(o.y); - if (!id || !Number.isFinite(x) || !Number.isFinite(y)) continue; - if (seen.has(id)) continue; - seen.add(id); - out.push({ id, name, x: rn(x, 6), y: rn(y, 6) }); + const kind = o.kind; + const id = Number(o.id); + if (!Number.isInteger(id) || id < 0) continue; + if (kind === "burg") out.push({ kind: "burg", id }); + else if (kind === "marker") out.push({ kind: "marker", id }); + } + return out; +} + +function legacyStopIdsToLegs(stopIds: string[]): JourneyStopLeg[] { + const out: JourneyStopLeg[] = []; + for (const sid of stopIds) { + const p = parseJourneyStopRef(sid); + if (!p) continue; + out.push( + p.kind === "burg" + ? { kind: "burg", id: p.id } + : { kind: "marker", id: p.id }, + ); } return out; } function burgExistsInPack( pack: JourneyNormalizePackContext, - i: number, + id: number, ): boolean { const burgs = pack.burgs; if (!Array.isArray(burgs)) return false; - return burgs.some((b) => b.i === i && !b.removed); + return burgs.some((b) => b.i === id && !b.removed); } -function markerExistsInPack(pack: JourneyNormalizePackContext, i: number): boolean { +function markerExistsInPack(pack: JourneyNormalizePackContext, id: number): boolean { const markers = pack.markers; if (!Array.isArray(markers)) return false; - return markers.some((m) => m.i === i); + return markers.some((m) => m.i === id); } -function stopIdIsAllowed( - id: string, - waypointIds: Set, +function legIsAllowed( + leg: JourneyStopLeg, pack?: JourneyNormalizePackContext, ): boolean { - if (waypointIds.has(id)) return true; - const parsed = parseJourneyStopRef(id); - if (!parsed || parsed.kind === "waypoint") return false; - if (parsed.kind === "burg") { - if (!isWellFormedBurgStopRef(id)) return false; + if (leg.kind === "burg") { if (!pack) return true; - return burgExistsInPack(pack, parsed.i); + return burgExistsInPack(pack, leg.id); } - if (!isWellFormedMarkerStopRef(id)) return false; if (!pack) return true; - return markerExistsInPack(pack, parsed.i); + return markerExistsInPack(pack, leg.id); } /** - * Mutates `j` into canonical `{ stopIds, waypoints }`; drops invalid `stopIds`; - * removes stray `points`. When `pack` is passed, drops burg/marker refs whose - * entity no longer exists. + * Mutates `j` into canonical `{ stops }`. Migrates legacy `stopIds` (burg/marker + * strings only; waypoint ids dropped). Removes `stopIds`, `waypoints`, `points`. */ export function normalizePackJourney( j: unknown, @@ -147,20 +149,23 @@ export function normalizePackJourney( const obj = j as Record; - const waypoints: JourneyWaypoint[] = Array.isArray(obj.waypoints) - ? sanitizeWaypoints(obj.waypoints as unknown[]) - : []; + let stops = sanitizeStopsArray( + Array.isArray(obj.stops) ? (obj.stops as unknown[]) : [], + ); - let stopIds: string[] = Array.isArray(obj.stopIds) - ? (obj.stopIds as unknown[]).filter((id): id is string => typeof id === "string") - : []; + if (!stops.length && Array.isArray(obj.stopIds)) { + const ids = (obj.stopIds as unknown[]).filter( + (id): id is string => typeof id === "string", + ); + stops = legacyStopIdsToLegs(ids); + } - const idSet = new Set(waypoints.map((w) => w.id)); - stopIds = stopIds.filter((id) => stopIdIsAllowed(id, idSet, pack)); + stops = stops.filter((leg) => legIsAllowed(leg, pack)); delete obj.points; - obj.stopIds = stopIds; - obj.waypoints = waypoints; + delete obj.stopIds; + delete obj.waypoints; + obj.stops = stops; } function finiteCoord(x: unknown, y: unknown): [number, number] | null { @@ -170,47 +175,49 @@ function finiteCoord(x: unknown, y: unknown): [number, number] | null { return [nx, ny]; } -/** Resolve one stop to map coordinates, or null if missing. */ -export function resolveJourneyStopPosition( - stopId: string, - j: PackJourney, +function tryWarnMissing(msg: string): void { + try { + const w = typeof globalThis !== "undefined" && (globalThis as { WARN?: boolean }).WARN; + if (w) console.warn(msg); + } catch { + /* noop */ + } +} + +/** Resolve one leg to map coordinates, or null if missing. */ +export function resolveJourneyLeg( + leg: JourneyStopLeg, ctx: JourneyResolutionContext, ): [number, number] | null { - const wpMap = new Map(j.waypoints.map((w) => [w.id, [w.x, w.y]] as const)); - const direct = wpMap.get(stopId); - if (direct) return [direct[0], direct[1]]; - - const parsed = parseJourneyStopRef(stopId); - if (!parsed || parsed.kind === "waypoint") return null; - - if (parsed.kind === "burg") { - const burg = ctx.burgs.find((b) => b.i === parsed.i && !b.removed); + if (leg.kind === "burg") { + const burg = ctx.burgs.find((b) => b.i === leg.id && !b.removed); if (!burg) { - tryWarnMissing(`journey: missing burg ref ${stopId}`); + tryWarnMissing(`journey: missing burg ${leg.id}`); return null; } const p = finiteCoord(burg.x, burg.y); - if (!p) tryWarnMissing(`journey: burg ${parsed.i} has invalid x/y`); + if (!p) tryWarnMissing(`journey: burg ${leg.id} has invalid x/y`); return p; } - const marker = ctx.markers.find((m) => m.i === parsed.i); + const marker = ctx.markers.find((m) => m.i === leg.id); if (!marker) { - tryWarnMissing(`journey: missing marker ref ${stopId}`); + tryWarnMissing(`journey: missing marker ${leg.id}`); return null; } const p = finiteCoord(marker.x, marker.y); - if (!p) tryWarnMissing(`journey: marker ${parsed.i} has invalid x/y`); + if (!p) tryWarnMissing(`journey: marker ${leg.id} has invalid x/y`); return p; } -function tryWarnMissing(msg: string): void { - try { - const w = typeof globalThis !== "undefined" && (globalThis as { WARN?: boolean }).WARN; - if (w) console.warn(msg); - } catch { - /* noop */ - } +/** Resolve `burg:n` / `marker:n` string (editor / DOM). */ +export function resolveJourneyStopPosition( + stopRef: string, + ctx: JourneyResolutionContext, +): [number, number] | null { + const leg = journeyRefStringToLeg(stopRef); + if (!leg) return null; + return resolveJourneyLeg(leg, ctx); } export function journeyResolvedCoordinates( @@ -218,16 +225,16 @@ export function journeyResolvedCoordinates( ctx: JourneyResolutionContext = { burgs: [], markers: [] }, ): [number, number][] { const out: [number, number][] = []; - for (const id of j.stopIds) { - const p = resolveJourneyStopPosition(id, j, ctx); + for (const leg of j.stops) { + const p = resolveJourneyLeg(leg, ctx); if (p) out.push([p[0], p[1]]); } return out; } -/** All stop ids in the journey sequence (waypoints, burgs, markers). */ +/** Ref strings for legs in the journey (for vertex hints). */ export function referencedStopIds(j: PackJourney): Set { - return new Set(j.stopIds); + return new Set(j.stops.map(journeyLegToRefString)); } /** @deprecated Use referencedStopIds */ diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 81a43e45e..efba92b1e 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -66,6 +66,6 @@ export interface PackedGraph { markers: any[]; ice: any[]; provinces: Province[]; - /** User journey: waypoint catalog + ordered `stopIds`. */ + /** User journey: ordered burg / marker legs (`stops`). */ journey?: PackJourney; } diff --git a/tests/e2e/journey-layer.spec.ts b/tests/e2e/journey-layer.spec.ts index e4bbf1067..d19327727 100644 --- a/tests/e2e/journey-layer.spec.ts +++ b/tests/e2e/journey-layer.spec.ts @@ -11,27 +11,26 @@ const BACKTRACK_JOURNEY_POINTS: [number, number][] = [ [560, 190], ]; -/** Catalog + `stopIds` from an ordered coord list (reused coords share one waypoint). */ -function packJourneyFromCoords(pts: [number, number][]) { - const waypoints: { id: string; name: string; x: number; y: number }[] = []; - const keyToId = new Map(); - let n = 0; - const stopIds: string[] = []; +/** Synthetic burgs at unique coords + `stops` legs (reused coords share one burg id). */ +function backtrackBurgJourneyFixture(pts: [number, number][]) { + const keyToBurgI = new Map(); + let nextI = 910001; + const burgs: { i: number; x: number; y: number; name: string; removed: boolean }[] = []; + const stops: { kind: "burg"; id: number }[] = []; for (const [x, y] of pts) { const key = `${x},${y}`; - let id = keyToId.get(key); - if (!id) { - n += 1; - id = `t_wp_${n}`; - keyToId.set(key, id); - waypoints.push({ id, name: `Stop ${n}`, x, y }); + let bi = keyToBurgI.get(key); + if (bi == null) { + bi = nextI++; + keyToBurgI.set(key, bi); + burgs.push({ i: bi, x, y, name: `BT ${bi}`, removed: false }); } - stopIds.push(id); + stops.push({ kind: "burg" as const, id: bi }); } - return { stopIds, waypoints }; + return { journey: { stops }, burgsToPush: burgs }; } -const BACKTRACK_PACK_JOURNEY = packJourneyFromCoords(BACKTRACK_JOURNEY_POINTS); +const BACKTRACK_FIXTURE = backtrackBurgJourneyFixture(BACKTRACK_JOURNEY_POINTS); test.describe("Journey layer", () => { test.beforeEach(async ({ context, page }) => { @@ -83,14 +82,14 @@ test.describe("Journey layer", () => { const j = w.pack.journey; return { pointsPresent: Object.prototype.hasOwnProperty.call(j, "points"), - stopCount: Array.isArray(j.stopIds) ? j.stopIds.length : -1, - waypointCount: Array.isArray(j.waypoints) ? j.waypoints.length : -1, + stopsLen: Array.isArray(j.stops) ? j.stops.length : -1, + legacyStopIds: Object.prototype.hasOwnProperty.call(j, "stopIds"), }; }, BACKTRACK_JOURNEY_POINTS); expect(snap.pointsPresent).toBe(false); - expect(snap.stopCount).toBe(0); - expect(snap.waypointCount).toBe(0); + expect(snap.stopsLen).toBe(0); + expect(snap.legacyStopIds).toBe(false); }); test("drawJourney resolves a burg stop ref using pack.burgs positions", async ({ page }) => { @@ -100,14 +99,12 @@ test.describe("Journey layer", () => { toggleJourney: () => void; pack: { burgs: Array<{ i: number; x: number; y: number; name?: string; removed?: boolean }>; - journey: { stopIds: string[]; waypoints: { id: string; name: string; x: number; y: number }[] }; + journey: { stops: { kind: string; id: number }[] }; }; - JourneyPack: { burgJourneyStopRef: (i: number) => string }; drawJourney: () => void; }; if (!w.layerIsOn("toggleJourney")) w.toggleJourney(); const testI = 999001; - const ref = w.JourneyPack.burgJourneyStopRef(testI); w.pack.burgs.push({ i: testI, x: 400, @@ -115,7 +112,7 @@ test.describe("Journey layer", () => { name: "E2E journey burg", removed: false, }); - w.pack.journey = { stopIds: [ref], waypoints: [] }; + w.pack.journey = { stops: [{ kind: "burg", id: testI }] }; w.drawJourney(); }); @@ -125,24 +122,28 @@ test.describe("Journey layer", () => { test("drawJourney renders paths and vertices for journey data", async ({ page }) => { await page.evaluate( - ({ journey }) => { + ({ journey, burgsToPush }) => { const w = window as unknown as { layerIsOn: (id: string) => boolean; toggleJourney: () => void; - pack: { journey: typeof journey }; + pack: { + journey: typeof journey; + burgs: Array<{ i: number; x: number; y: number; name: string; removed: boolean }>; + }; drawJourney: () => void; }; if (!w.layerIsOn("toggleJourney")) w.toggleJourney(); + for (const b of burgsToPush) w.pack.burgs.push(b); w.pack.journey = journey; w.drawJourney(); }, - { journey: BACKTRACK_PACK_JOURNEY }, + { journey: BACKTRACK_FIXTURE.journey, burgsToPush: BACKTRACK_FIXTURE.burgsToPush }, ); const segmentCount = BACKTRACK_JOURNEY_POINTS.length - 1; await expect(page.locator("#journeys .journey-segments .journey-segment")).toHaveCount(segmentCount); await expect(page.locator("#journeys .journey-segment-stroke")).toHaveCount(segmentCount); - // Deduped waypoints: only (400,300), (720,460), (560,190) + // Deduped vertices: three distinct burg positions await expect(page.locator("#journeys .journey-vertices circle")).toHaveCount(3); const arrowN = await page.locator("#journeys .journey-arrow").count(); expect(arrowN).toBeGreaterThan(segmentCount); @@ -151,16 +152,27 @@ test.describe("Journey layer", () => { test("journey stroke-width shrinks in map units when zoom scale increases (screen-constant sizing)", async ({ page, }) => { - const snap = await page.evaluate((journey: typeof BACKTRACK_PACK_JOURNEY) => { + const snap = await page.evaluate( + ({ + journey, + burgsToPush, + }: { + journey: typeof BACKTRACK_FIXTURE.journey; + burgsToPush: typeof BACKTRACK_FIXTURE.burgsToPush; + }) => { const w = window as unknown as { layerIsOn: (id: string) => boolean; toggleJourney: () => void; - pack: { journey: typeof journey }; + pack: { + journey: typeof journey; + burgs: Array<{ i: number; x: number; y: number; name: string; removed: boolean }>; + }; drawJourney: () => void; scale: number; syncJourneyZoom: (zoomScale: number) => void; }; if (!w.layerIsOn("toggleJourney")) w.toggleJourney(); + for (const b of burgsToPush) w.pack.burgs.push(b); w.pack.journey = journey; w.drawJourney(); const readStrokes = () => @@ -179,7 +191,9 @@ test.describe("Journey layer", () => { const strokeAfter = readStrokes(); const rAfter = readWaypointR(); return { strokeBefore, strokeAfter, rBefore, rAfter }; - }, BACKTRACK_PACK_JOURNEY); + }, + { journey: BACKTRACK_FIXTURE.journey, burgsToPush: BACKTRACK_FIXTURE.burgsToPush }, + ); const segN = BACKTRACK_JOURNEY_POINTS.length - 1; expect(snap.strokeBefore.length).toBe(segN); From 6bf720e43d1ba4fdd132e1ed73860d956abec5e1 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Thu, 7 May 2026 00:50:35 +0200 Subject: [PATCH 08/48] add new campaign layer preset --- public/main.js | 2 +- public/modules/ui/layers.js | 16 +++++++++++++++- src/index.html | 19 ++++++++++--------- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/public/main.js b/public/main.js index e479f082b..606d4cf8e 100644 --- a/public/main.js +++ b/public/main.js @@ -61,6 +61,7 @@ let zones = viewbox.append("g").attr("id", "zones"); let borders = viewbox.append("g").attr("id", "borders"); let stateBorders = borders.append("g").attr("id", "stateBorders"); let provinceBorders = borders.append("g").attr("id", "provinceBorders"); +let journeys = viewbox.append("g").attr("id", "journeys").style("display", "none"); let routes = viewbox.append("g").attr("id", "routes"); let roads = routes.append("g").attr("id", "roads"); let trails = routes.append("g").attr("id", "trails"); @@ -77,7 +78,6 @@ let burgIcons = icons.append("g").attr("id", "burgIcons"); let anchors = icons.append("g").attr("id", "anchors"); let armies = viewbox.append("g").attr("id", "armies"); let markers = viewbox.append("g").attr("id", "markers"); -let journeys = viewbox.append("g").attr("id", "journeys").style("display", "none"); let fogging = viewbox .append("g") .attr("id", "fogging-cont") diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index d4c167cfd..2af6e39ca 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -65,6 +65,20 @@ function getDefaultPresets() { "toggleScaleBar", "toggleVignette" ], + campaign: [ + "toggleHeight", + "toggleLakes", + "toggleCells", + "toggleGrid", + "toggleStates", + "toggleZones", + "toggleBorders", + "toggleJourney", + "toggleRoutes", + "toggleBurgIcons", + "toggleLabels", + "toggleMarkers" + ], military: [ "toggleBorders", "toggleBurgIcons", @@ -215,6 +229,7 @@ function drawLayers() { if (layerIsOn("toggleProvinces")) drawProvinces(); if (layerIsOn("toggleZones")) drawZones(); if (layerIsOn("toggleBorders")) drawBorders(); + if (layerIsOn("toggleJourney")) drawJourney(); if (layerIsOn("toggleRoutes")) drawRoutes(); if (layerIsOn("toggleTemperature")) drawTemperature(); if (layerIsOn("togglePopulation")) drawPopulation(); @@ -225,7 +240,6 @@ function drawLayers() { if (layerIsOn("toggleBurgIcons")) drawBurgIcons(); if (layerIsOn("toggleMilitary")) drawMilitary(); if (layerIsOn("toggleMarkers")) drawMarkers(); - if (layerIsOn("toggleJourney")) drawJourney(); if (layerIsOn("toggleRulers")) rulers.draw(); // scale bar // vignette diff --git a/src/index.html b/src/index.html index fd06100ee..c43801604 100644 --- a/src/index.html +++ b/src/index.html @@ -472,6 +472,7 @@ + @@ -625,6 +626,13 @@ > Borders
  • +
  • + Journey +
  • Routes
  • -
  • - Journey -
  • - + - + From 0151a4223db662db14b9e4e5a03f9ee9711e29a7 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Thu, 7 May 2026 01:14:01 +0200 Subject: [PATCH 09/48] allow styling of journey --- public/modules/ui/layers.js | 4 +- public/modules/ui/style-presets.js | 16 +++ public/modules/ui/style.js | 167 +++++++++++++++++++++++++- public/styles/ancient.json | 14 +++ public/styles/atlas.json | 14 +++ public/styles/clean.json | 14 +++ public/styles/cyberpunk.json | 14 +++ public/styles/darkSeas.json | 14 +++ public/styles/default.json | 14 +++ public/styles/gloom.json | 14 +++ public/styles/light.json | 14 +++ public/styles/monochrome.json | 14 +++ public/styles/night.json | 14 +++ public/styles/pale.json | 14 +++ public/styles/watercolor.json | 14 +++ src/index.html | 98 ++++++++++++++- src/modules/journey-draw.test.ts | 63 ++++++++++ src/modules/journey-draw.ts | 186 +++++++++++++++++++++++------ 18 files changed, 661 insertions(+), 41 deletions(-) diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 2af6e39ca..83e3e6a7a 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -904,9 +904,9 @@ function toggleJourney(event) { turnButtonOn("toggleJourney"); drawJourney(); $("#journeys").fadeIn(); - if (event && isCtrlClick(event)) editJourney(); + if (event && isCtrlClick(event)) editStyle("journeys"); } else { - if (event && isCtrlClick(event)) return editJourney(); + if (event && isCtrlClick(event)) return editStyle("journeys"); $("#journeys").fadeOut(); turnButtonOff("toggleJourney"); } diff --git a/public/modules/ui/style-presets.js b/public/modules/ui/style-presets.js index f1b6b167d..fc2a1464f 100644 --- a/public/modules/ui/style-presets.js +++ b/public/modules/ui/style-presets.js @@ -160,6 +160,8 @@ function applyStyleWithUiRefresh(style) { drawScaleBar(scaleBar, scale); fitScaleBar(scaleBar, svgWidth, svgHeight); + + if (layerIsOn("toggleJourney")) drawJourney(); } function addStylePreset() { @@ -253,6 +255,20 @@ function addStylePreset() { "filter" ], "#ice": ["opacity", "fill", "stroke", "stroke-width", "filter"], + "#journeys": [ + "opacity", + "filter", + "data-color-mode", + "data-solid-stroke", + "data-rainbow-stops", + "data-line-screen-px", + "data-waypoint-fill", + "data-waypoint-stroke", + "data-waypoint-r-screen-px", + "data-waypoint-ring-screen-px", + "data-outline-color", + "data-outline-screen-px" + ], "#emblems": ["opacity", "stroke-width", "filter"], "#emblems > #stateEmblems": ["data-size"], "#emblems > #provinceEmblems": ["data-size"], diff --git a/public/modules/ui/style.js b/public/modules/ui/style.js index 95fef70d4..eeaf84ee2 100644 --- a/public/modules/ui/style.js +++ b/public/modules/ui/style.js @@ -77,6 +77,73 @@ function getColor(value, scheme = getColorScheme("bright")) { // Toggle style sections on element select styleElementSelect.on("change", selectStyleElement); +function journeyStyleHexForPicker(raw, fallback) { + const fb = fallback || "#000000"; + const s = (raw != null && String(raw).trim() !== "" ? String(raw).trim() : fb); + if (/^#[\da-fA-F]{6}$/.test(s)) return s; + if (/^#[\da-fA-F]{3}$/.test(s)) { + const r = s[1], + g = s[2], + b = s[3]; + return `#${r}${r}${g}${g}${b}${b}`; + } + return fb; +} + +/** Match built-in rainbow ramp endpoints in journey-draw (when data-rainbow-stops unset). */ +const JOURNEY_UI_GRADIENT_DEFAULT_FROM = "#e81416"; +const JOURNEY_UI_GRADIENT_DEFAULT_TO = "#70389d"; + +function journeyParseStopsList(raw) { + if (raw == null || !String(raw).trim()) return null; + const parts = String(raw) + .split(",") + .map(s => s.trim()) + .filter(Boolean); + return parts.length >= 2 ? parts : null; +} + +function journeyPopulateRainbowUi(j) { + const raw = j.attr("data-rainbow-stops") || ""; + ensureEl("styleJourneyRainbowStops").value = raw.trim() === "" ? "" : raw; + + const parsed = journeyParseStopsList(raw); + let fromHex; + let toHex; + if (parsed && parsed.length >= 2) { + fromHex = journeyStyleHexForPicker(parsed[0], JOURNEY_UI_GRADIENT_DEFAULT_FROM); + toHex = journeyStyleHexForPicker(parsed[parsed.length - 1], JOURNEY_UI_GRADIENT_DEFAULT_TO); + } else { + fromHex = JOURNEY_UI_GRADIENT_DEFAULT_FROM; + toHex = JOURNEY_UI_GRADIENT_DEFAULT_TO; + } + ensureEl("styleJourneyGradientFrom").value = fromHex; + ensureEl("styleJourneyGradientFromOutput").value = fromHex; + ensureEl("styleJourneyGradientTo").value = toHex; + ensureEl("styleJourneyGradientToOutput").value = toHex; +} + +function journeyStyleSyncRowVisibility() { + const solid = ensureEl("styleJourneyColorMode").value === "solid"; + ensureEl("styleJourneySolidStroke").closest("tr").style.display = solid ? "" : "none"; + const showGrad = !solid; + ensureEl("styleJourneyGradientFrom").closest("tr").style.display = showGrad ? "" : "none"; + ensureEl("styleJourneyGradientTo").closest("tr").style.display = showGrad ? "" : "none"; + ensureEl("styleJourneyRainbowStops").closest("tr").style.display = showGrad ? "" : "none"; +} + +function journeyWriteRainbowFromPickers() { + ensureEl("styleJourneyRainbowStops").value = ""; + const from = ensureEl("styleJourneyGradientFrom").value; + const to = ensureEl("styleJourneyGradientTo").value; + svg.select("#journeys").attr("data-rainbow-stops", `${from},${to}`); + redrawJourneyIfVisible(); +} + +function redrawJourneyIfVisible() { + if (typeof drawJourney === "function" && layerIsOn("toggleJourney")) drawJourney(); +} + function selectStyleElement() { const styleElement = styleElementSelect.value; let el = d3.select("#" + styleElement); @@ -84,7 +151,10 @@ function selectStyleElement() { styleElements.querySelectorAll("tbody").forEach(e => (e.style.display = "none")); // hide all sections // show alert line if layer is not visible - const isLayerOff = styleElement !== "ocean" && (el.style("display") === "none" || !el.selectAll("*").size()); + const isLayerOff = + styleElement !== "ocean" && + styleElement !== "journeys" && + (el.style("display") === "none" || !el.selectAll("*").size()); styleIsOff.style.display = isLayerOff ? "block" : "none"; // active group element @@ -203,6 +273,31 @@ function selectStyleElement() { styleRescaleMarkers.checked = +markers.attr("rescale"); } + if (styleElement === "journeys") { + ensureEl("styleJourney").style.display = "table-row-group"; + const j = el; + const modeRaw = (j.attr("data-color-mode") || "rainbow").toLowerCase(); + ensureEl("styleJourneyColorMode").value = modeRaw === "solid" ? "solid" : "rainbow"; + const solidHex = journeyStyleHexForPicker(j.attr("data-solid-stroke"), "#5c5c70"); + ensureEl("styleJourneySolidStroke").value = solidHex; + ensureEl("styleJourneySolidStrokeOutput").value = solidHex; + journeyPopulateRainbowUi(j); + ensureEl("styleJourneyLineScreenPx").value = j.attr("data-line-screen-px") || 6; + const wpf = journeyStyleHexForPicker(j.attr("data-waypoint-fill"), "#ffffff"); + ensureEl("styleJourneyWaypointFill").value = wpf; + ensureEl("styleJourneyWaypointFillOutput").value = wpf; + const wps = journeyStyleHexForPicker(j.attr("data-waypoint-stroke"), "#000000"); + ensureEl("styleJourneyWaypointStroke").value = wps; + ensureEl("styleJourneyWaypointStrokeOutput").value = wps; + ensureEl("styleJourneyWaypointRScreenPx").value = j.attr("data-waypoint-r-screen-px") || 9; + ensureEl("styleJourneyWaypointRingScreenPx").value = j.attr("data-waypoint-ring-screen-px") || 4.5; + const oc = journeyStyleHexForPicker(j.attr("data-outline-color"), "#000000"); + ensureEl("styleJourneyOutlineColor").value = oc; + ensureEl("styleJourneyOutlineColorOutput").value = oc; + ensureEl("styleJourneyOutlineScreenPx").value = j.attr("data-outline-screen-px") || 2; + journeyStyleSyncRowVisibility(); + } + if (styleElement === "gridOverlay") { styleGrid.style.display = "block"; styleGridType.value = el.attr("type"); @@ -552,6 +647,76 @@ styleRescaleMarkers.on("change", function () { invokeActiveZooming(); }); +d3.select("#styleJourneyColorMode").on("change", function () { + svg.select("#journeys").attr("data-color-mode", this.value); + journeyStyleSyncRowVisibility(); + redrawJourneyIfVisible(); +}); + +d3.select("#styleJourneySolidStroke").on("input", function () { + ensureEl("styleJourneySolidStrokeOutput").value = this.value; + svg.select("#journeys").attr("data-solid-stroke", this.value); + redrawJourneyIfVisible(); +}); + +d3.select("#styleJourneyGradientFrom").on("input", function () { + ensureEl("styleJourneyGradientFromOutput").value = this.value; + journeyWriteRainbowFromPickers(); +}); + +d3.select("#styleJourneyGradientTo").on("input", function () { + ensureEl("styleJourneyGradientToOutput").value = this.value; + journeyWriteRainbowFromPickers(); +}); + +d3.select("#styleJourneyRainbowStops").on("input", function () { + const v = this.value.trim(); + const jn = svg.select("#journeys"); + if (v === "") jn.attr("data-rainbow-stops", null); + else jn.attr("data-rainbow-stops", v); + journeyPopulateRainbowUi(jn); + journeyStyleSyncRowVisibility(); + redrawJourneyIfVisible(); +}); + +d3.select("#styleJourneyLineScreenPx").on("input", function () { + svg.select("#journeys").attr("data-line-screen-px", this.value); + redrawJourneyIfVisible(); +}); + +d3.select("#styleJourneyWaypointFill").on("input", function () { + ensureEl("styleJourneyWaypointFillOutput").value = this.value; + svg.select("#journeys").attr("data-waypoint-fill", this.value); + redrawJourneyIfVisible(); +}); + +d3.select("#styleJourneyWaypointStroke").on("input", function () { + ensureEl("styleJourneyWaypointStrokeOutput").value = this.value; + svg.select("#journeys").attr("data-waypoint-stroke", this.value); + redrawJourneyIfVisible(); +}); + +d3.select("#styleJourneyWaypointRScreenPx").on("input", function () { + svg.select("#journeys").attr("data-waypoint-r-screen-px", this.value); + redrawJourneyIfVisible(); +}); + +d3.select("#styleJourneyWaypointRingScreenPx").on("input", function () { + svg.select("#journeys").attr("data-waypoint-ring-screen-px", this.value); + redrawJourneyIfVisible(); +}); + +d3.select("#styleJourneyOutlineColor").on("input", function () { + ensureEl("styleJourneyOutlineColorOutput").value = this.value; + svg.select("#journeys").attr("data-outline-color", this.value); + redrawJourneyIfVisible(); +}); + +d3.select("#styleJourneyOutlineScreenPx").on("input", function () { + svg.select("#journeys").attr("data-outline-screen-px", this.value); + redrawJourneyIfVisible(); +}); + styleCoastlineAuto.on("change", function () { coastline.select("#sea_island").attr("auto-filter", +this.checked); styleFilter.style.display = this.checked ? "none" : "block"; diff --git a/public/styles/ancient.json b/public/styles/ancient.json index d6ed402ca..9f36bde93 100644 --- a/public/styles/ancient.json +++ b/public/styles/ancient.json @@ -99,6 +99,20 @@ "rescale": 1, "filter": null }, + "#journeys": { + "opacity": null, + "filter": null, + "data-color-mode": "rainbow", + "data-solid-stroke": "#5c5c70", + "data-rainbow-stops": null, + "data-line-screen-px": 6, + "data-waypoint-fill": "#ffffff", + "data-waypoint-stroke": "#000000", + "data-waypoint-r-screen-px": 9, + "data-waypoint-ring-screen-px": 4.5, + "data-outline-color": "#000000", + "data-outline-screen-px": 2 + }, "#prec": { "opacity": null, "stroke": "#000000", diff --git a/public/styles/atlas.json b/public/styles/atlas.json index 144362be5..4488b45d6 100644 --- a/public/styles/atlas.json +++ b/public/styles/atlas.json @@ -99,6 +99,20 @@ "rescale": 1, "filter": null }, + "#journeys": { + "opacity": null, + "filter": null, + "data-color-mode": "rainbow", + "data-solid-stroke": "#5c5c70", + "data-rainbow-stops": null, + "data-line-screen-px": 6, + "data-waypoint-fill": "#ffffff", + "data-waypoint-stroke": "#000000", + "data-waypoint-r-screen-px": 9, + "data-waypoint-ring-screen-px": 4.5, + "data-outline-color": "#000000", + "data-outline-screen-px": 2 + }, "#prec": { "opacity": null, "stroke": "#000000", diff --git a/public/styles/clean.json b/public/styles/clean.json index 9698edc09..b450fe9e2 100644 --- a/public/styles/clean.json +++ b/public/styles/clean.json @@ -100,6 +100,20 @@ "rescale": null, "filter": null }, + "#journeys": { + "opacity": null, + "filter": null, + "data-color-mode": "rainbow", + "data-solid-stroke": "#5c5c70", + "data-rainbow-stops": null, + "data-line-screen-px": 6, + "data-waypoint-fill": "#ffffff", + "data-waypoint-stroke": "#000000", + "data-waypoint-r-screen-px": 9, + "data-waypoint-ring-screen-px": 4.5, + "data-outline-color": "#000000", + "data-outline-screen-px": 2 + }, "#prec": { "opacity": null, "stroke": "#000000", diff --git a/public/styles/cyberpunk.json b/public/styles/cyberpunk.json index c5796f9c4..bf4da2b48 100644 --- a/public/styles/cyberpunk.json +++ b/public/styles/cyberpunk.json @@ -99,6 +99,20 @@ "rescale": 1, "filter": null }, + "#journeys": { + "opacity": null, + "filter": null, + "data-color-mode": "rainbow", + "data-solid-stroke": "#5c5c70", + "data-rainbow-stops": null, + "data-line-screen-px": 6, + "data-waypoint-fill": "#ffffff", + "data-waypoint-stroke": "#000000", + "data-waypoint-r-screen-px": 9, + "data-waypoint-ring-screen-px": 4.5, + "data-outline-color": "#000000", + "data-outline-screen-px": 2 + }, "#prec": { "opacity": null, "stroke": "#000000", diff --git a/public/styles/darkSeas.json b/public/styles/darkSeas.json index 5c1840ca6..e73e887f4 100644 --- a/public/styles/darkSeas.json +++ b/public/styles/darkSeas.json @@ -96,6 +96,20 @@ "rescale": 1, "filter": null }, + "#journeys": { + "opacity": null, + "filter": null, + "data-color-mode": "rainbow", + "data-solid-stroke": "#5c5c70", + "data-rainbow-stops": null, + "data-line-screen-px": 6, + "data-waypoint-fill": "#ffffff", + "data-waypoint-stroke": "#000000", + "data-waypoint-r-screen-px": 9, + "data-waypoint-ring-screen-px": 4.5, + "data-outline-color": "#000000", + "data-outline-screen-px": 2 + }, "#prec": { "opacity": null, "stroke": "#000000", diff --git a/public/styles/default.json b/public/styles/default.json index cfca04804..e074397d7 100644 --- a/public/styles/default.json +++ b/public/styles/default.json @@ -99,6 +99,20 @@ "rescale": 1, "filter": null }, + "#journeys": { + "opacity": null, + "filter": null, + "data-color-mode": "rainbow", + "data-solid-stroke": "#5c5c70", + "data-rainbow-stops": null, + "data-line-screen-px": 6, + "data-waypoint-fill": "#ffffff", + "data-waypoint-stroke": "#000000", + "data-waypoint-r-screen-px": 9, + "data-waypoint-ring-screen-px": 4.5, + "data-outline-color": "#000000", + "data-outline-screen-px": 2 + }, "#prec": { "opacity": null, "stroke": "#000000", diff --git a/public/styles/gloom.json b/public/styles/gloom.json index f06a67db4..08da58d46 100644 --- a/public/styles/gloom.json +++ b/public/styles/gloom.json @@ -100,6 +100,20 @@ "rescale": 1, "filter": null }, + "#journeys": { + "opacity": null, + "filter": null, + "data-color-mode": "rainbow", + "data-solid-stroke": "#5c5c70", + "data-rainbow-stops": null, + "data-line-screen-px": 6, + "data-waypoint-fill": "#ffffff", + "data-waypoint-stroke": "#000000", + "data-waypoint-r-screen-px": 9, + "data-waypoint-ring-screen-px": 4.5, + "data-outline-color": "#000000", + "data-outline-screen-px": 2 + }, "#prec": { "opacity": null, "stroke": "#000000", diff --git a/public/styles/light.json b/public/styles/light.json index c28bbd974..d263a03cd 100644 --- a/public/styles/light.json +++ b/public/styles/light.json @@ -99,6 +99,20 @@ "rescale": 1, "filter": null }, + "#journeys": { + "opacity": null, + "filter": null, + "data-color-mode": "rainbow", + "data-solid-stroke": "#5c5c70", + "data-rainbow-stops": null, + "data-line-screen-px": 6, + "data-waypoint-fill": "#ffffff", + "data-waypoint-stroke": "#000000", + "data-waypoint-r-screen-px": 9, + "data-waypoint-ring-screen-px": 4.5, + "data-outline-color": "#000000", + "data-outline-screen-px": 2 + }, "#prec": { "opacity": null, "stroke": "#000000", diff --git a/public/styles/monochrome.json b/public/styles/monochrome.json index f05e2a37c..7f5a35660 100644 --- a/public/styles/monochrome.json +++ b/public/styles/monochrome.json @@ -100,6 +100,20 @@ "rescale": 1, "filter": null }, + "#journeys": { + "opacity": null, + "filter": null, + "data-color-mode": "rainbow", + "data-solid-stroke": "#5c5c70", + "data-rainbow-stops": null, + "data-line-screen-px": 6, + "data-waypoint-fill": "#ffffff", + "data-waypoint-stroke": "#000000", + "data-waypoint-r-screen-px": 9, + "data-waypoint-ring-screen-px": 4.5, + "data-outline-color": "#000000", + "data-outline-screen-px": 2 + }, "#prec": { "opacity": null, "stroke": "#000000", diff --git a/public/styles/night.json b/public/styles/night.json index d890db4e0..55abcb2fa 100644 --- a/public/styles/night.json +++ b/public/styles/night.json @@ -99,6 +99,20 @@ "rescale": null, "filter": "url(#dropShadow01)" }, + "#journeys": { + "opacity": null, + "filter": null, + "data-color-mode": "rainbow", + "data-solid-stroke": "#5c5c70", + "data-rainbow-stops": null, + "data-line-screen-px": 6, + "data-waypoint-fill": "#ffffff", + "data-waypoint-stroke": "#000000", + "data-waypoint-r-screen-px": 9, + "data-waypoint-ring-screen-px": 4.5, + "data-outline-color": "#000000", + "data-outline-screen-px": 2 + }, "#prec": { "opacity": null, "stroke": "#ffffff", diff --git a/public/styles/pale.json b/public/styles/pale.json index b9ce1a6b7..caf5a93e6 100644 --- a/public/styles/pale.json +++ b/public/styles/pale.json @@ -99,6 +99,20 @@ "rescale": 1, "filter": null }, + "#journeys": { + "opacity": null, + "filter": null, + "data-color-mode": "rainbow", + "data-solid-stroke": "#5c5c70", + "data-rainbow-stops": null, + "data-line-screen-px": 6, + "data-waypoint-fill": "#ffffff", + "data-waypoint-stroke": "#000000", + "data-waypoint-r-screen-px": 9, + "data-waypoint-ring-screen-px": 4.5, + "data-outline-color": "#000000", + "data-outline-screen-px": 2 + }, "#prec": { "opacity": null, "stroke": "#000000", diff --git a/public/styles/watercolor.json b/public/styles/watercolor.json index b1632825e..480fd4dd9 100644 --- a/public/styles/watercolor.json +++ b/public/styles/watercolor.json @@ -99,6 +99,20 @@ "rescale": 1, "filter": null }, + "#journeys": { + "opacity": null, + "filter": null, + "data-color-mode": "rainbow", + "data-solid-stroke": "#5c5c70", + "data-rainbow-stops": null, + "data-line-screen-px": 6, + "data-waypoint-fill": "#ffffff", + "data-waypoint-stroke": "#000000", + "data-waypoint-r-screen-px": 9, + "data-waypoint-ring-screen-px": 4.5, + "data-outline-color": "#000000", + "data-outline-screen-px": 2 + }, "#prec": { "opacity": null, "stroke": "#000000", diff --git a/src/index.html b/src/index.html index c43801604..1adebbb8a 100644 --- a/src/index.html +++ b/src/index.html @@ -628,7 +628,7 @@
  • Journey @@ -805,6 +805,7 @@ + @@ -1487,6 +1488,95 @@ + + + Color mode + + + + + + Solid color + + + + + + + Gradient from + + + + + + + Gradient to + + + + + + + Gradient stops + + + + + + Path outline color + + + + + + + Path outline (px) + + + + + + Line (screen px) + + + + + + Waypoint fill + + + + + + + Waypoint stroke + + + + + + + Waypoint radius (px) + + + + + + Waypoint ring (px) + + + + + + @@ -8609,14 +8699,14 @@ - + - + - + diff --git a/src/modules/journey-draw.test.ts b/src/modules/journey-draw.test.ts index 63ec12a1d..c49aae497 100644 --- a/src/modules/journey-draw.test.ts +++ b/src/modules/journey-draw.test.ts @@ -10,7 +10,11 @@ import { journeyLodTier, journeyPolylineSamplesForTier, journeyRampColor, + journeyRampSamplerForConfig, + JOURNEY_DEFAULT_SOLID_STROKE, laneMultipliersForSegments, + parseJourneyRainbowStops, + readJourneyStyleConfig, segmentUInterval, } from "./journey-draw"; @@ -143,6 +147,65 @@ describe("laneMultipliersForSegments", () => { }); }); +describe("parseJourneyRainbowStops", () => { + it("returns null for empty or single token", () => { + expect(parseJourneyRainbowStops(null)).toBeNull(); + expect(parseJourneyRainbowStops("")).toBeNull(); + expect(parseJourneyRainbowStops("#ff0000")).toBeNull(); + }); + + it("parses comma-separated colors", () => { + expect(parseJourneyRainbowStops("#ff0000, #00ff00")).toEqual([ + "#ff0000", + "#00ff00", + ]); + }); +}); + +describe("readJourneyStyleConfig", () => { + it("uses defaults when element is null", () => { + const c = readJourneyStyleConfig(null); + expect(c.colorMode).toBe("rainbow"); + expect(c.solidStroke).toBe(JOURNEY_DEFAULT_SOLID_STROKE); + expect(c.lineScreenPx).toBe(6); + expect(c.waypointFill).toBe("#ffffff"); + expect(c.outlineColor).toBe("#000000"); + }); + + it("reads data-color-mode solid and custom attrs", () => { + const attrs: Record = { + "data-color-mode": "solid", + "data-solid-stroke": "#abc", + "data-line-screen-px": "12", + }; + const el = { + getAttribute(name: string) { + return attrs[name] ?? null; + }, + } as unknown as Element; + const c = readJourneyStyleConfig(el); + expect(c.colorMode).toBe("solid"); + expect(c.solidStroke).toBe("#abc"); + expect(c.lineScreenPx).toBe(12); + }); +}); + +describe("journeyRampSamplerForConfig", () => { + it("returns constant color in solid mode", () => { + const cfg = readJourneyStyleConfig(null); + const solidCfg = { ...cfg, colorMode: "solid" as const, solidStroke: "#112233" }; + const f = journeyRampSamplerForConfig(solidCfg); + expect(f(0)).toBe("#112233"); + expect(f(1)).toBe("#112233"); + }); + + it("varies along u in rainbow mode", () => { + const cfg = readJourneyStyleConfig(null); + const f = journeyRampSamplerForConfig(cfg); + expect(f(0)).not.toBe(f(1)); + }); +}); + describe("arrowPositionsAlongPolyline", () => { it("spaces arrows along length", () => { const pts: [number, number][] = [ diff --git a/src/modules/journey-draw.ts b/src/modules/journey-draw.ts index 1e851eb65..3496d69f3 100644 --- a/src/modules/journey-draw.ts +++ b/src/modules/journey-draw.ts @@ -31,6 +31,114 @@ export const JOURNEY_RAINBOW_STOPS = [ const rampInterpolator = interpolateRgbBasis(JOURNEY_RAINBOW_STOPS); +/** Default path/arrows color when `data-color-mode` is solid and no `data-solid-stroke`. */ +export const JOURNEY_DEFAULT_SOLID_STROKE = "#5c5c70"; + +export type JourneyColorMode = "rainbow" | "solid"; + +/** Resolved presentation for `#journeys` (from `data-*` + defaults). */ +export interface JourneyStyleConfig { + colorMode: JourneyColorMode; + solidStroke: string; + rainbowStops: readonly string[]; + lineScreenPx: number; + waypointFill: string; + waypointStroke: string; + waypointRScreenPx: number; + waypointRingScreenPx: number; + outlineColor: string; + outlineScreenPx: number; +} + +function clamp(n: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, n)); +} + +/** Parse comma-separated hex/color tokens; returns null if fewer than two usable stops. */ +export function parseJourneyRainbowStops(raw: string | null | undefined): string[] | null { + if (raw == null || !String(raw).trim()) return null; + const parts = String(raw) + .split(",") + .map(s => s.trim()) + .filter(Boolean); + return parts.length >= 2 ? parts : null; +} + +/** + * Read journey style from `#journeys` SVG attributes (`data-*`). + * Safe with `null` / missing element (uses built-in defaults matching former hardcoded constants). + */ +export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { + const get = (name: string): string | null => + el && typeof el.getAttribute === "function" ? el.getAttribute(name) : null; + + const modeRaw = (get("data-color-mode") || "rainbow").toLowerCase().trim(); + const colorMode: JourneyColorMode = modeRaw === "solid" ? "solid" : "rainbow"; + + const parsedStops = parseJourneyRainbowStops(get("data-rainbow-stops")); + const rainbowStops = + parsedStops && parsedStops.length >= 2 ? parsedStops : [...JOURNEY_RAINBOW_STOPS]; + + const solidStroke = + get("data-solid-stroke")?.trim() || JOURNEY_DEFAULT_SOLID_STROKE; + + const lineScreenPx = clamp( + Number.parseFloat(get("data-line-screen-px") || "") || JOURNEY_STROKE_SCREEN_PX, + 0.5, + 96, + ); + + const waypointFill = get("data-waypoint-fill")?.trim() || "#ffffff"; + const waypointStroke = get("data-waypoint-stroke")?.trim() || "#000000"; + + const waypointRScreenPx = clamp( + Number.parseFloat(get("data-waypoint-r-screen-px") || "") || + JOURNEY_WAYPOINT_R_SCREEN_PX, + 2, + 120, + ); + + const waypointRingScreenPx = clamp( + Number.parseFloat(get("data-waypoint-ring-screen-px") || "") || + JOURNEY_WAYPOINT_STROKE_SCREEN_PX, + 0, + 48, + ); + + const outlineColor = get("data-outline-color")?.trim() || "#000000"; + + const outlineScreenPx = clamp( + Number.parseFloat(get("data-outline-screen-px") || "") || + JOURNEY_HALO_DILATE_SCREEN_PX, + 0, + 32, + ); + + return { + colorMode, + solidStroke, + rainbowStops, + lineScreenPx, + waypointFill, + waypointStroke, + waypointRScreenPx, + waypointRingScreenPx, + outlineColor, + outlineScreenPx, + }; +} + +/** Uniform ramp sampler along one logical journey (same contract as `journeyRampColor`). */ +export function journeyRampSamplerForConfig(cfg: JourneyStyleConfig): (u: number) => string { + if (cfg.colorMode === "solid") { + const c = cfg.solidStroke; + return (_u: number) => c; + } + const stops = cfg.rainbowStops.length >= 2 ? cfg.rainbowStops : JOURNEY_RAINBOW_STOPS; + const interp = interpolateRgbBasis([...stops]); + return (u: number) => interp(Math.max(0, Math.min(1, u))); +} + function journeyResolutionCtx(): JourneyResolutionContext { return { burgs: pack.burgs ?? [], @@ -163,6 +271,7 @@ const JOURNEY_OUTLINE_FILTER_ID = "journeyUnifiedOutline"; function ensureJourneyOutlineFilter( defs: Selection, morphologyRadiusMap: number, + floodColor: string, ): void { defs.select(`filter#${JOURNEY_OUTLINE_FILTER_ID}`).remove(); const f = defs @@ -182,7 +291,7 @@ function ensureJourneyOutlineFilter( .attr("result", "dilatedAlpha"); f.append("feFlood") - .attr("flood-color", "#000000") + .attr("flood-color", floodColor) .attr("result", "outlineFlood"); f.append("feComposite") @@ -398,16 +507,19 @@ export class JourneyDrawModule { const zs = Number.isFinite(zoomScale) ? zoomScale : 1; const zm = Number.isFinite(zoomMinForLod) ? zoomMinForLod : 0.05; + const styleCfg = readJourneyStyleConfig(journeys.node()); + const rampAt = journeyRampSamplerForConfig(styleCfg); + const verts = journeys.append("g").attr("class", "journey-vertices"); const waypointR = mapMetricScreenToWorld( - JOURNEY_WAYPOINT_R_SCREEN_PX, + styleCfg.waypointRScreenPx, zs, 0.15, 80, ); const waypointSw = mapMetricScreenToWorld( - JOURNEY_WAYPOINT_STROKE_SCREEN_PX, + styleCfg.waypointRingScreenPx, zs, 0.03, 24, @@ -438,8 +550,8 @@ export class JourneyDrawModule { .attr("cx", rn(x, 2)) .attr("cy", rn(y, 2)) .attr("r", rn(waypointR, 3)) - .attr("fill", "#ffffff") - .attr("stroke", "#000000") + .attr("fill", styleCfg.waypointFill) + .attr("stroke", styleCfg.waypointStroke) .attr("stroke-width", rn(waypointSw, 3)) .style("cursor", "pointer"); if (jidList?.length === 1) { @@ -457,20 +569,20 @@ export class JourneyDrawModule { const samples = journeyPolylineSamplesForTier(tier); const arrowSpacing = journeyArrowSpacingMapUnits(zs, tier); const morphR = mapMetricScreenToWorld( - JOURNEY_HALO_DILATE_SCREEN_PX, + styleCfg.outlineScreenPx, zs, 0.35, 40, ); - ensureJourneyOutlineFilter(defs, morphR); + ensureJourneyOutlineFilter(defs, morphR, styleCfg.outlineColor); const segmentsRoot = journeys.append("g").attr("class", "journey-segments"); const lanes = laneMultipliersForSegments(points); const repeats = directedChordOccurrenceIndex(points); const strokeW = mapMetricScreenToWorld( - JOURNEY_STROKE_SCREEN_PX, + styleCfg.lineScreenPx, zs, 0.06, 24, @@ -491,34 +603,40 @@ export class JourneyDrawModule { if (!d) continue; const [u0, u1] = segmentUInterval(S, i); - const c0 = journeyRampColor(u0); - const c1 = journeyRampColor(u1); - - const gid = `journeyGrad_${i}`; - const grad = defs - .append("linearGradient") - .attr("id", gid) - .attr("class", "journey-def") - .attr("gradientUnits", "userSpaceOnUse") - .attr("x1", a[0]) - .attr("y1", a[1]) - .attr("x2", b[0]) - .attr("y2", b[1]); - - grad.append("stop").attr("offset", "0%").attr("stop-color", c0); - grad.append("stop").attr("offset", "100%").attr("stop-color", c1); + const c0 = rampAt(u0); + const c1 = rampAt(u1); const seg = segmentsRoot .append("g") .attr("class", "journey-segment") .attr("filter", `url(#${JOURNEY_OUTLINE_FILTER_ID})`); + let strokeAttr: string; + if (styleCfg.colorMode === "solid") { + strokeAttr = styleCfg.solidStroke; + } else { + const gid = `journeyGrad_${i}`; + const grad = defs + .append("linearGradient") + .attr("id", gid) + .attr("class", "journey-def") + .attr("gradientUnits", "userSpaceOnUse") + .attr("x1", a[0]) + .attr("y1", a[1]) + .attr("x2", b[0]) + .attr("y2", b[1]); + + grad.append("stop").attr("offset", "0%").attr("stop-color", c0); + grad.append("stop").attr("offset", "100%").attr("stop-color", c1); + strokeAttr = `url(#${gid})`; + } + seg .append("path") .attr("class", "journey-segment-stroke") .attr("d", d) .attr("fill", "none") - .attr("stroke", `url(#${gid})`) + .attr("stroke", strokeAttr) .attr("stroke-width", rn(strokeW, 3)) .attr("stroke-linecap", "round") .attr("stroke-linejoin", "round"); @@ -538,7 +656,7 @@ export class JourneyDrawModule { } for (const ar of arrPts) { const gt = chordGradientT(a, b, ar.x, ar.y); - const arrowColor = journeyRampColor(u0 + gt * (u1 - u0)); + const arrowColor = rampAt(u0 + gt * (u1 - u0)); seg .append("path") .attr("class", "journey-arrow") @@ -588,9 +706,10 @@ export class JourneyDrawModule { zoomScale: number, ): void { const zs = Math.max(zoomScale, 1e-9); + const styleCfg = readJourneyStyleConfig(journeys.node()); const strokeW = mapMetricScreenToWorld( - JOURNEY_STROKE_SCREEN_PX, + styleCfg.lineScreenPx, zs, 0.06, 24, @@ -600,13 +719,13 @@ export class JourneyDrawModule { .attr("stroke-width", rn(strokeW, 3)); const waypointR = mapMetricScreenToWorld( - JOURNEY_WAYPOINT_R_SCREEN_PX, + styleCfg.waypointRScreenPx, zs, 0.15, 80, ); const waypointSw = mapMetricScreenToWorld( - JOURNEY_WAYPOINT_STROKE_SCREEN_PX, + styleCfg.waypointRingScreenPx, zs, 0.03, 24, @@ -626,15 +745,14 @@ export class JourneyDrawModule { }); const morphR = mapMetricScreenToWorld( - JOURNEY_HALO_DILATE_SCREEN_PX, + styleCfg.outlineScreenPx, zs, 0.35, 40, ); - defs - .select(`filter#${JOURNEY_OUTLINE_FILTER_ID}`) - .select("feMorphology") - .attr("radius", rn(morphR, 3)); + const filt = defs.select(`filter#${JOURNEY_OUTLINE_FILTER_ID}`); + filt.select("feMorphology").attr("radius", rn(morphR, 3)); + filt.select("feFlood").attr("flood-color", styleCfg.outlineColor); } } From 36de63a58ca18916f6ca8b724a36c2f5b5fdab45 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Thu, 7 May 2026 01:34:12 +0200 Subject: [PATCH 10/48] fix initial campaign layer preset not rendering journey --- public/modules/ui/layers.js | 2 ++ src/index.html | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 83e3e6a7a..1d800326a 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -887,6 +887,8 @@ function drawJourney() { if (window.JourneyPack) window.JourneyPack.normalizePackJourney(pack.journey, pack); const zs = typeof scale === "number" && Number.isFinite(scale) ? scale : 1; JourneyDraw.redraw(defs, journeys, zs, journeyZoomExtentMin()); + // Presets only flip layer buttons; they never call toggleJourney/fadeIn. Match visible state to layerIsOn. + if (layerIsOn("toggleJourney")) journeys.style("display", null); TIME && console.timeEnd("drawJourney"); } diff --git a/src/index.html b/src/index.html index 1adebbb8a..c5011f517 100644 --- a/src/index.html +++ b/src/index.html @@ -8699,7 +8699,7 @@ - + From 12e9b9bfeadecf5905b78e0522ec84e4b73a57fd Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Thu, 7 May 2026 02:26:08 +0200 Subject: [PATCH 11/48] cleanup --- package-lock.json | 6 ------ package.json | 1 - 2 files changed, 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index d3eb6917a..a00e2bf5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1369,7 +1369,6 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1410,7 +1409,6 @@ "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/browser": "4.1.5", "@vitest/mocker": "4.1.5", @@ -1901,7 +1899,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -2194,7 +2191,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2208,7 +2204,6 @@ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.59.1" }, @@ -2569,7 +2564,6 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", diff --git a/package.json b/package.json index 2b49249ed..e2135feef 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "test": "vitest", "test:browser": "vitest --config=vitest.browser.config.ts", "test:e2e": "playwright test", - "test:e2e:update": "playwright test --update-snapshots", "lint": "biome check --write", "format": "biome format --write" }, From 1263053a7d0dba40fdafd0fee3ce7a7717fb7802 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Thu, 7 May 2026 02:52:06 +0200 Subject: [PATCH 12/48] cleanup --- playwright.config.ts | 8 +--- public/modules/ui/journey-editor.js | 6 +-- public/modules/ui/layers.js | 1 - src/index.html | 4 +- src/modules/journey-model.ts | 5 --- tests/e2e/fixtures.ts | 54 +-------------------------- tests/e2e/layers.spec.ts | 17 +-------- tests/e2e/visual-regression.spec.ts | 58 ----------------------------- 8 files changed, 8 insertions(+), 145 deletions(-) delete mode 100644 tests/e2e/visual-regression.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts index 00e31c182..e576ee2b4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,13 +21,7 @@ export default defineConfig({ retries: 0, workers: isCI ? 2 : undefined, reporter: 'html', - expect: { - toHaveScreenshot: { - maxDiffPixels: 15000, - threshold: 0.25, - }, - }, - // Use OS-independent snapshot names (HTML content is the same across platforms) + // Keeps toMatchSnapshot('_layer_.html') paths stable (no browser/OS suffix in filename). snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}', use: { baseURL, diff --git a/public/modules/ui/journey-editor.js b/public/modules/ui/journey-editor.js index f63e5bc81..bdb50e4d1 100644 --- a/public/modules/ui/journey-editor.js +++ b/public/modules/ui/journey-editor.js @@ -173,10 +173,8 @@ function journeyEditorOnClick() { if (target?.classList?.contains("journey-waypoint")) circleEl = target; else if (target?.closest?.(".journey-waypoint")) circleEl = target.closest(".journey-waypoint"); - if (circleEl) { - const stopRef = - circleEl.getAttribute("data-journey-stop-ref") || - circleEl.getAttribute("data-journey-waypoint-id"); + if (circleEl) { + const stopRef = circleEl.getAttribute("data-journey-stop-ref"); if (stopRef) { journeyAppendStopRef(stopRef); return; diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 1d800326a..1b2d9bb59 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -905,7 +905,6 @@ function toggleJourney(event) { if (!layerIsOn("toggleJourney")) { turnButtonOn("toggleJourney"); drawJourney(); - $("#journeys").fadeIn(); if (event && isCtrlClick(event)) editStyle("journeys"); } else { if (event && isCtrlClick(event)) return editStyle("journeys"); diff --git a/src/index.html b/src/index.html index c5011f517..956c49dd2 100644 --- a/src/index.html +++ b/src/index.html @@ -8699,7 +8699,7 @@ - + @@ -8718,7 +8718,7 @@ - + diff --git a/src/modules/journey-model.ts b/src/modules/journey-model.ts index f49c17c49..9875ae396 100644 --- a/src/modules/journey-model.ts +++ b/src/modules/journey-model.ts @@ -236,8 +236,3 @@ export function journeyResolvedCoordinates( export function referencedStopIds(j: PackJourney): Set { return new Set(j.stops.map(journeyLegToRefString)); } - -/** @deprecated Use referencedStopIds */ -export function referencedWaypointIds(j: PackJourney): Set { - return referencedStopIds(j); -} diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 418e529ac..20d7c17cf 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -1,60 +1,8 @@ /** - * Shared Playwright test instance with an end-frame #map screenshot after each passed test. - * Skipped on CI (pixel baselines are local-only). See playwright.config.ts expect.toHaveScreenshot. - * layers.spec.ts uses a shared page and registers its own end-frame hook (see that file). + * Shared Playwright `test` / `expect` instance for e2e specs. */ -import type { Page, TestInfo } from "@playwright/test"; import { expect, test as base } from "@playwright/test"; -export async function waitForLoadingOverlayGone(page: Page) { - await page.waitForFunction(() => { - const loading = document.getElementById("loading"); - if (!loading) return true; - const opacity = Number.parseFloat(getComputedStyle(loading).opacity || "1"); - return opacity < 0.01; - }, { timeout: 120000 }); -} - -export async function waitForMapSvgReady(page: Page) { - await page.waitForFunction(() => (window as unknown as { mapId?: unknown }).mapId !== undefined, { - timeout: 120000, - }); - await page.waitForFunction(() => { - const ocean = document.getElementById("ocean"); - return ocean != null && ocean.childNodes.length > 0; - }, { timeout: 120000 }); - await waitForLoadingOverlayGone(page); - await page.waitForTimeout(500); -} - -export function endFrameSnapshotName(testInfo: TestInfo): string { - const slug = testInfo.titlePath - .filter((s) => s.length > 0 && !/\.spec\.[tj]s$/i.test(s)) - .join("__") - .replace(/[^\w.-]+/g, "_") - .replace(/_+/g, "_") - .replace(/^_|_$/g, "") - .slice(0, 200); - return `end-frame__${slug || "unknown"}.png`; -} - -function skipGlobalEndFrameScreenshot(testInfo: TestInfo): boolean { - const name = testInfo.file.split(/[/\\]/).pop() ?? ""; - return name === "layers.spec.ts"; -} - export const test = base; -test.afterEach(async ({ page }, testInfo) => { - if (process.env.CI) return; - if (testInfo.status !== "passed") return; - if (skipGlobalEndFrameScreenshot(testInfo)) return; - - await waitForLoadingOverlayGone(page); - await page.waitForTimeout(100); - await expect(page.locator("#map")).toHaveScreenshot(endFrameSnapshotName(testInfo), { - timeout: 30_000, - }); -}); - export { expect }; diff --git a/tests/e2e/layers.spec.ts b/tests/e2e/layers.spec.ts index 0ccfad8f9..12172105a 100644 --- a/tests/e2e/layers.spec.ts +++ b/tests/e2e/layers.spec.ts @@ -1,5 +1,5 @@ import type { Browser, BrowserContext, Page } from '@playwright/test' -import { endFrameSnapshotName, expect, test, waitForLoadingOverlayGone } from './fixtures' +import { expect, test } from './fixtures' // All tests in this describe block only READ the DOM — they never modify state. // Load the map once for the entire suite instead of before every test. @@ -20,10 +20,7 @@ test.describe('map layers', () => { sessionStorage.clear() }) - // Navigate with seed parameter and wait for full load - // NOTE: - // - We use a fixed seed ("test-seed") to make map generation deterministic for snapshot tests. - // - Snapshots are OS-independent (configured in playwright.config.ts). + // Fixed seed keeps SVG/HTML snapshots in layers tests stable across runs. await sharedPage.goto('?seed=test-seed&width=1280&height=720') // Wait for map generation to complete by checking window.mapId @@ -34,16 +31,6 @@ test.describe('map layers', () => { await sharedPage.waitForTimeout(500) }) - test.afterEach(async ({}, testInfo) => { - if (process.env.CI) return; - if (testInfo.status !== 'passed') return; - await waitForLoadingOverlayGone(sharedPage); - await sharedPage.waitForTimeout(100); - await expect(sharedPage.locator('#map')).toHaveScreenshot(endFrameSnapshotName(testInfo), { - timeout: 30_000, - }); - }) - test.afterAll(async () => { await sharedPage.close() await sharedContext.close() diff --git a/tests/e2e/visual-regression.spec.ts b/tests/e2e/visual-regression.spec.ts deleted file mode 100644 index 775951d0e..000000000 --- a/tests/e2e/visual-regression.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Pixel regression on the root SVG (#map). Baselines are maintained on a single machine; - * this suite is skipped in CI (see describeVisual below). - * - * Run locally before large refactors: npm run test:e2e - * Refresh PNG baselines only when the visual change is intentional: - * npm run test:e2e:update-visual - * End-frame snapshots for all E2E tests live next to each spec; refresh everything: - * npm run test:e2e:update-snapshots - */ -import path from "path"; -import { expect, test, waitForMapSvgReady } from "./fixtures"; - -/** Pixel baselines are recorded against the dev server; skip on CI until snapshots are cross-platform. */ -const describeVisual = process.env.CI ? test.describe.skip : test.describe; - -describeVisual("Visual regression", () => { - test.describe.configure({ - mode: "serial", - timeout: 180000, - }); - - test.beforeEach(async ({ context }) => { - await context.clearCookies(); - }); - - test("seeded generation matches baseline", async ({ page }) => { - await page.goto(""); - await page.evaluate(() => { - localStorage.clear(); - sessionStorage.clear(); - }); - - await page.goto("?seed=test-seed&width=1280&height=720"); - - await waitForMapSvgReady(page); - - await expect(page.locator("#map")).toHaveScreenshot("seeded-generation.png"); - }); - - test("loaded demo.map matches baseline", async ({ page }) => { - await page.goto(""); - await page.evaluate(() => { - localStorage.clear(); - sessionStorage.clear(); - }); - - await page.waitForSelector("#mapToLoad", { state: "attached" }); - - const fileInput = page.locator("#mapToLoad"); - const mapFilePath = path.join(__dirname, "../fixtures/demo.map"); - await fileInput.setInputFiles(mapFilePath); - - await waitForMapSvgReady(page); - - await expect(page.locator("#map")).toHaveScreenshot("loaded-demo-map.png"); - }); -}); From ec8a6b4be31042ed534c42ef6f0a6687f20b3d2f Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Thu, 7 May 2026 23:29:46 +0200 Subject: [PATCH 13/48] Enhance readJourneyStyleConfig to preserve zero values for data attributes and refactor numeric attribute parsing. Added a test case to verify that data-outline-screen-px retains a value of 0 without falling back to defaults. --- src/modules/journey-draw.test.ts | 10 ++++++++++ src/modules/journey-draw.ts | 17 ++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/modules/journey-draw.test.ts b/src/modules/journey-draw.test.ts index c49aae497..e340d7aa4 100644 --- a/src/modules/journey-draw.test.ts +++ b/src/modules/journey-draw.test.ts @@ -188,6 +188,16 @@ describe("readJourneyStyleConfig", () => { expect(c.solidStroke).toBe("#abc"); expect(c.lineScreenPx).toBe(12); }); + + it("keeps data-outline-screen-px 0 (does not fall back to default)", () => { + const el = { + getAttribute(name: string) { + return name === "data-outline-screen-px" ? "0" : null; + }, + } as unknown as Element; + const c = readJourneyStyleConfig(el); + expect(c.outlineScreenPx).toBe(0); + }); }); describe("journeyRampSamplerForConfig", () => { diff --git a/src/modules/journey-draw.ts b/src/modules/journey-draw.ts index 3496d69f3..6a5f36a51 100644 --- a/src/modules/journey-draw.ts +++ b/src/modules/journey-draw.ts @@ -72,6 +72,12 @@ export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { const get = (name: string): string | null => el && typeof el.getAttribute === "function" ? el.getAttribute(name) : null; + /** Parse numeric data-*; keeps `0` (unlike `parseFloat(x) || fallback`). */ + const attrPx = (name: string, fallback: number): number => { + const v = Number.parseFloat(get(name) ?? ""); + return Number.isFinite(v) ? v : fallback; + }; + const modeRaw = (get("data-color-mode") || "rainbow").toLowerCase().trim(); const colorMode: JourneyColorMode = modeRaw === "solid" ? "solid" : "rainbow"; @@ -83,7 +89,7 @@ export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { get("data-solid-stroke")?.trim() || JOURNEY_DEFAULT_SOLID_STROKE; const lineScreenPx = clamp( - Number.parseFloat(get("data-line-screen-px") || "") || JOURNEY_STROKE_SCREEN_PX, + attrPx("data-line-screen-px", JOURNEY_STROKE_SCREEN_PX), 0.5, 96, ); @@ -92,15 +98,13 @@ export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { const waypointStroke = get("data-waypoint-stroke")?.trim() || "#000000"; const waypointRScreenPx = clamp( - Number.parseFloat(get("data-waypoint-r-screen-px") || "") || - JOURNEY_WAYPOINT_R_SCREEN_PX, + attrPx("data-waypoint-r-screen-px", JOURNEY_WAYPOINT_R_SCREEN_PX), 2, 120, ); const waypointRingScreenPx = clamp( - Number.parseFloat(get("data-waypoint-ring-screen-px") || "") || - JOURNEY_WAYPOINT_STROKE_SCREEN_PX, + attrPx("data-waypoint-ring-screen-px", JOURNEY_WAYPOINT_STROKE_SCREEN_PX), 0, 48, ); @@ -108,8 +112,7 @@ export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { const outlineColor = get("data-outline-color")?.trim() || "#000000"; const outlineScreenPx = clamp( - Number.parseFloat(get("data-outline-screen-px") || "") || - JOURNEY_HALO_DILATE_SCREEN_PX, + attrPx("data-outline-screen-px", JOURNEY_HALO_DILATE_SCREEN_PX), 0, 32, ); From bedc404b830f2e8746a84c53ff0b8b851fad9e69 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Thu, 7 May 2026 23:32:01 +0200 Subject: [PATCH 14/48] Refactor journey resolution logic to introduce journeyResolvedStopEntries function, which returns resolved stops with their coordinates. Update JourneyDrawModule to utilize resolved stops instead of coordinates directly. Add tests for journeyResolvedStopEntries to ensure correct functionality. --- src/modules/journey-draw.ts | 13 +++++++------ src/modules/journey-model.test.ts | 20 ++++++++++++++++++++ src/modules/journey-model.ts | 29 +++++++++++++++++++++++++---- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/modules/journey-draw.ts b/src/modules/journey-draw.ts index 6a5f36a51..1148ec631 100644 --- a/src/modules/journey-draw.ts +++ b/src/modules/journey-draw.ts @@ -3,6 +3,7 @@ import { interpolateRgbBasis } from "d3"; import type { JourneyNormalizePackContext, JourneyResolutionContext, + JourneyResolvedStopEntry, PackJourney, } from "./journey-model"; import { @@ -11,6 +12,7 @@ import { journeyLegToRefString, journeyRefStringToLeg, journeyResolvedCoordinates, + journeyResolvedStopEntries, markerJourneyStopRef, normalizePackJourney, resolveJourneyLeg, @@ -501,11 +503,12 @@ export class JourneyDrawModule { normalizePackJourney(pack.journey, journeyNormalizeCtx()); const journeyData = pack.journey as PackJourney; const resCtx = journeyResolutionCtx(); - const points = journeyResolvedCoordinates(journeyData, resCtx); - if (!points.length) { + const resolvedStops = journeyResolvedStopEntries(journeyData, resCtx); + if (!resolvedStops.length) { this.lastLodTier = null; return; } + const points = resolvedStops.map((r: JourneyResolvedStopEntry) => r.coord); const zs = Number.isFinite(zoomScale) ? zoomScale : 1; const zm = Number.isFinite(zoomMinForLod) ? zoomMinForLod : 0.05; @@ -529,11 +532,9 @@ export class JourneyDrawModule { ); const idsAtCoord = new Map(); - for (const leg of journeyData.stops) { - const xy = resolveJourneyLeg(leg, resCtx); - if (!xy) continue; + for (const { leg, coord } of resolvedStops) { const sid = journeyLegToRefString(leg); - const ck = `${rn(xy[0], 2)},${rn(xy[1], 2)}`; + const ck = `${rn(coord[0], 2)},${rn(coord[1], 2)}`; const arr = idsAtCoord.get(ck) ?? []; if (!arr.includes(sid)) arr.push(sid); idsAtCoord.set(ck, arr); diff --git a/src/modules/journey-model.test.ts b/src/modules/journey-model.test.ts index be552a879..f77e0909c 100644 --- a/src/modules/journey-model.test.ts +++ b/src/modules/journey-model.test.ts @@ -4,6 +4,7 @@ import { journeyLegToRefString, journeyRefStringToLeg, journeyResolvedCoordinates, + journeyResolvedStopEntries, markerJourneyStopRef, normalizePackJourney, parseJourneyStopRef, @@ -118,6 +119,25 @@ describe("journeyResolvedCoordinates", () => { }); }); +describe("journeyResolvedStopEntries", () => { + const j: PackJourney = { + stops: [ + { kind: "burg", id: 10 }, + { kind: "marker", id: 2 }, + ], + }; + const ctx: JourneyResolutionContext = { + burgs: [{ i: 10, x: 10, y: 20, removed: false }], + markers: [{ i: 2, x: 30, y: 40 }], + }; + + it("matches journeyResolvedCoordinates coords and carries legs", () => { + const rows = journeyResolvedStopEntries(j, ctx); + expect(rows.map((r) => r.coord)).toEqual(journeyResolvedCoordinates(j, ctx)); + expect(rows.map((r) => journeyLegToRefString(r.leg))).toEqual(["burg:10", "marker:2"]); + }); +}); + describe("resolveJourneyLeg", () => { it("returns null for removed burg", () => { const ctx: JourneyResolutionContext = { diff --git a/src/modules/journey-model.ts b/src/modules/journey-model.ts index 9875ae396..bb1adbf90 100644 --- a/src/modules/journey-model.ts +++ b/src/modules/journey-model.ts @@ -220,18 +220,39 @@ export function resolveJourneyStopPosition( return resolveJourneyLeg(leg, ctx); } -export function journeyResolvedCoordinates( +/** One resolved leg and its map coordinate (same order as `journeyResolvedCoordinates` points). */ +export interface JourneyResolvedStopEntry { + leg: JourneyStopLeg; + coord: [number, number]; +} + +/** + * Resolve each leg once: coordinates for polyline + waypoint attribution. + * Omits legs that fail to resolve (same sequence as `journeyResolvedCoordinates`). + */ +export function journeyResolvedStopEntries( j: PackJourney, ctx: JourneyResolutionContext = { burgs: [], markers: [] }, -): [number, number][] { - const out: [number, number][] = []; +): JourneyResolvedStopEntry[] { + const out: JourneyResolvedStopEntry[] = []; for (const leg of j.stops) { const p = resolveJourneyLeg(leg, ctx); - if (p) out.push([p[0], p[1]]); + if (!p) continue; + out.push({ leg, coord: [p[0], p[1]] }); } return out; } +export function journeyResolvedCoordinates( + j: PackJourney, + ctx: JourneyResolutionContext = { burgs: [], markers: [] }, +): [number, number][] { + const rows = journeyResolvedStopEntries(j, ctx); + const out: [number, number][] = new Array(rows.length); + for (let i = 0; i < rows.length; i++) out[i] = rows[i].coord; + return out; +} + /** Ref strings for legs in the journey (for vertex hints). */ export function referencedStopIds(j: PackJourney): Set { return new Set(j.stops.map(journeyLegToRefString)); From 728c6a48571b167dc2a5132c54d05a0beee5d4be Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Thu, 7 May 2026 23:33:12 +0200 Subject: [PATCH 15/48] Update journey insertion logic to position journeys below routes instead of ruler in load.js. Increment version number for load.js in index.html to 1.122.2. --- public/modules/io/load.js | 3 ++- src/index.html | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/public/modules/io/load.js b/public/modules/io/load.js index a1d636ca1..ddc6ec8f7 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -372,7 +372,8 @@ async function parseLoadedData(data, mapVersion) { emblems = viewbox.insert("g", "#labels").attr("id", "emblems").style("display", "none"); } if (!journeys.size()) { - journeys = viewbox.insert("g", "#ruler").attr("id", "journeys").style("display", "none"); + // Match main.js: journeys sit below routes / fogging / ruler (insert before #routes, not #ruler). + journeys = viewbox.insert("g", "#routes").attr("id", "journeys").style("display", "none"); } } diff --git a/src/index.html b/src/index.html index 956c49dd2..91085f5bd 100644 --- a/src/index.html +++ b/src/index.html @@ -8751,7 +8751,7 @@ - + From a9ea0141b2a300fb7278fba6906c1eca22ea9f29 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Thu, 7 May 2026 23:34:55 +0200 Subject: [PATCH 16/48] Implement buildJourneyResolutionContext to enhance journey resolution with indexed lookups for burgs and markers. Update journeyResolutionCtx to utilize the new function. Add tests for buildJourneyResolutionContext to verify correct functionality. --- src/modules/journey-draw.ts | 6 ++-- src/modules/journey-model.test.ts | 29 ++++++++++++++++++ src/modules/journey-model.ts | 49 +++++++++++++++++++++++++++++-- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/modules/journey-draw.ts b/src/modules/journey-draw.ts index 1148ec631..af2b88ce9 100644 --- a/src/modules/journey-draw.ts +++ b/src/modules/journey-draw.ts @@ -7,6 +7,7 @@ import type { PackJourney, } from "./journey-model"; import { + buildJourneyResolutionContext, burgJourneyStopRef, emptyPackJourney, journeyLegToRefString, @@ -145,10 +146,7 @@ export function journeyRampSamplerForConfig(cfg: JourneyStyleConfig): (u: number } function journeyResolutionCtx(): JourneyResolutionContext { - return { - burgs: pack.burgs ?? [], - markers: pack.markers ?? [], - }; + return buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []); } function journeyNormalizeCtx(): JourneyNormalizePackContext { diff --git a/src/modules/journey-model.test.ts b/src/modules/journey-model.test.ts index f77e0909c..ad71f77f4 100644 --- a/src/modules/journey-model.test.ts +++ b/src/modules/journey-model.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildJourneyResolutionContext, burgJourneyStopRef, journeyLegToRefString, journeyRefStringToLeg, @@ -119,6 +120,34 @@ describe("journeyResolvedCoordinates", () => { }); }); +describe("buildJourneyResolutionContext", () => { + it("matches linear resolve for burgs and markers", () => { + const burgs = [ + { i: 1, x: 10, y: 20, removed: true }, + { i: 1, x: 11, y: 21, removed: false }, + { i: 2, x: 30, y: 40, removed: false }, + ]; + const markers = [ + { i: 0, x: 0, y: 1 }, + { i: 3, x: 50, y: 60 }, + ]; + const plain: JourneyResolutionContext = { burgs, markers }; + const indexed = buildJourneyResolutionContext(burgs, markers); + expect(resolveJourneyLeg({ kind: "burg", id: 1 }, indexed)).toEqual( + resolveJourneyLeg({ kind: "burg", id: 1 }, plain), + ); + expect(resolveJourneyLeg({ kind: "burg", id: 2 }, indexed)).toEqual( + resolveJourneyLeg({ kind: "burg", id: 2 }, plain), + ); + expect(resolveJourneyLeg({ kind: "marker", id: 3 }, indexed)).toEqual( + resolveJourneyLeg({ kind: "marker", id: 3 }, plain), + ); + expect(resolveJourneyLeg({ kind: "marker", id: 999 }, indexed)).toEqual( + resolveJourneyLeg({ kind: "marker", id: 999 }, plain), + ); + }); +}); + describe("journeyResolvedStopEntries", () => { const j: PackJourney = { stops: [ diff --git a/src/modules/journey-model.ts b/src/modules/journey-model.ts index bb1adbf90..9f8e0aa66 100644 --- a/src/modules/journey-model.ts +++ b/src/modules/journey-model.ts @@ -24,6 +24,48 @@ export interface PackJourney { export interface JourneyResolutionContext { burgs: Array<{ i?: number; x?: number; y?: number; removed?: boolean }>; markers: Array<{ i?: number; x?: number; y?: number }>; + /** First matching non-removed burg per id (same semantics as linear find). When set, `resolveJourneyLeg` uses O(1) lookup. */ + burgById?: Map; + /** First marker per id (same semantics as linear find). */ + markerById?: Map; +} + +function indexBurgsById( + burgs: JourneyResolutionContext["burgs"], +): Map { + const m = new Map(); + for (const b of burgs) { + if (b.removed) continue; + const id = b.i; + if (id === undefined || typeof id !== "number") continue; + if (!m.has(id)) m.set(id, b); + } + return m; +} + +function indexMarkersById( + markers: JourneyResolutionContext["markers"], +): Map { + const m = new Map(); + for (const mk of markers) { + const id = mk.i; + if (id === undefined || typeof id !== "number") continue; + if (!m.has(id)) m.set(id, mk); + } + return m; +} + +/** Build resolution context with id indexes (preferred for redraw / many stops). */ +export function buildJourneyResolutionContext( + burgs: JourneyResolutionContext["burgs"], + markers: JourneyResolutionContext["markers"], +): JourneyResolutionContext { + return { + burgs, + markers, + burgById: indexBurgsById(burgs), + markerById: indexMarkersById(markers), + }; } /** Optional pack slice for pruning dead burg/marker refs during normalize. */ @@ -190,7 +232,9 @@ export function resolveJourneyLeg( ctx: JourneyResolutionContext, ): [number, number] | null { if (leg.kind === "burg") { - const burg = ctx.burgs.find((b) => b.i === leg.id && !b.removed); + const burg = + ctx.burgById?.get(leg.id) ?? + ctx.burgs.find((b) => b.i === leg.id && !b.removed); if (!burg) { tryWarnMissing(`journey: missing burg ${leg.id}`); return null; @@ -200,7 +244,8 @@ export function resolveJourneyLeg( return p; } - const marker = ctx.markers.find((m) => m.i === leg.id); + const marker = + ctx.markerById?.get(leg.id) ?? ctx.markers.find((m) => m.i === leg.id); if (!marker) { tryWarnMissing(`journey: missing marker ${leg.id}`); return null; From 239541d9d4189b7247ecb029e3af087c4e3f9d34 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Thu, 7 May 2026 23:57:59 +0200 Subject: [PATCH 17/48] Refactor journey handling by introducing ensurePackJourneyNormalized to ensure pack.journey exists and is properly structured. Update journey editor to utilize this new function for managing journey stops. Remove deprecated journey-editor.js and update related modules for improved journey management. --- public/modules/io/load.js | 2 +- public/modules/ui/journey-editor.js | 242 ------------- public/modules/ui/layers.js | 36 +- public/modules/ui/style.js | 52 ++- src/index.html | 9 +- src/modules/index.ts | 1 + src/modules/journey-draw.ts | 509 +++++---------------------- src/modules/journey-editor.ts | 264 ++++++++++++++ src/modules/journey-model.test.ts | 10 + src/modules/journey-model.ts | 25 +- src/modules/journey-path-geometry.ts | 251 +++++++++++++ src/modules/journey-style-config.ts | 150 ++++++++ src/renderers/draw-scalebar.ts | 2 +- src/types/PackedGraph.ts | 2 +- src/types/global.ts | 10 +- 15 files changed, 858 insertions(+), 707 deletions(-) delete mode 100644 public/modules/ui/journey-editor.js create mode 100644 src/modules/journey-editor.ts create mode 100644 src/modules/journey-path-geometry.ts create mode 100644 src/modules/journey-style-config.ts diff --git a/public/modules/io/load.js b/public/modules/io/load.js index ddc6ec8f7..46bb55964 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -434,7 +434,7 @@ async function parseLoadedData(data, mapVersion) { ? parsedJourney : {stops: []}; pack.journey = j; - if (window.JourneyPack) window.JourneyPack.normalizePackJourney(pack.journey, pack); + if (window.Journey) window.Journey.ensurePackJourneyNormalized(pack); } if (data[31]) { diff --git a/public/modules/ui/journey-editor.js b/public/modules/ui/journey-editor.js deleted file mode 100644 index bdb50e4d1..000000000 --- a/public/modules/ui/journey-editor.js +++ /dev/null @@ -1,242 +0,0 @@ -"use strict"; - -function journeyEscapeAttr(s) { - return String(s) - .replace(/&/g, "&") - .replace(/"/g, """); -} - -function journeyEscapeText(s) { - return String(s) - .replace(/&/g, "&") - .replace(//g, ">"); -} - -function ensurePackJourney() { - if (!pack.journey) pack.journey = { stops: [] }; - if (window.JourneyPack) window.JourneyPack.normalizePackJourney(pack.journey, pack); -} - -function journeyStopSelectOptions(currentRef) { - ensurePackJourney(); - const JP = window.JourneyPack; - let html = ""; - const known = new Set(); - - if (!currentRef) { - html += - ''; - } - - if (JP) { - html += ''; - for (const m of pack.markers || []) { - if (m.i == null || !Number.isFinite(m.x) || !Number.isFinite(m.y)) continue; - const ref = JP.markerJourneyStopRef(m.i); - known.add(ref); - const sel = ref === currentRef ? " selected" : ""; - const typeLabel = m.type ? String(m.type) : "Marker"; - const label = `${typeLabel} #${m.i} (${rn(m.x, 2)}, ${rn(m.y, 2)})`; - html += ``; - } - html += ""; - - html += ''; - for (const b of pack.burgs || []) { - if (b.removed || b.i == null || !Number.isFinite(b.x) || !Number.isFinite(b.y)) continue; - const ref = JP.burgJourneyStopRef(b.i); - known.add(ref); - const sel = ref === currentRef ? " selected" : ""; - const nm = b.name && String(b.name).trim() !== "" ? b.name : `Burg ${b.i}`; - const label = `${nm} (${rn(b.x, 2)}, ${rn(b.y, 2)})`; - html += ``; - } - html += ""; - } - - if (currentRef && !known.has(currentRef)) { - html += ``; - } - return html; -} - -function journeyLegToSelectValue(leg) { - const JP = window.JourneyPack; - if (!JP || !leg) return ""; - return JP.journeyLegToRefString(leg); -} - -function journeyRenderStopRows(container) { - ensurePackJourney(); - const stops = pack.journey.stops; - const rows = stops.length === 0 ? [null] : stops; - rows.forEach((leg, i) => { - const currentRef = leg ? journeyLegToSelectValue(leg) : ""; - const showRemove = stops.length > 0; - const removeStyle = showRemove ? "" : "visibility:hidden;pointer-events:none"; - container.insertAdjacentHTML( - "beforeend", - /* html */ `
    - #${i + 1} - - -
    `, - ); - }); -} - -function journeyEditorRefreshStopsOnly() { - const stBody = ensureEl("journeyEditorStopsBody"); - stBody.innerHTML = ""; - journeyRenderStopRows(stBody); -} - -function journeyEditorRefreshBody() { - const stBody = ensureEl("journeyEditorStopsBody"); - stBody.innerHTML = ""; - ensurePackJourney(); - journeyRenderStopRows(stBody); -} - -function journeyEditorRootChange(ev) { - const t = ev.target; - - if (t.classList.contains("journey-stop-select")) { - const row = t.closest("[data-stop-index]"); - if (!row) return; - const idx = +row.dataset.stopIndex; - if (!Number.isFinite(idx)) return; - const val = t.value; - ensurePackJourney(); - const JP = window.JourneyPack; - if (!JP || !val) return; - const leg = JP.journeyRefStringToLeg(val); - if (!leg) return; - - const stops = pack.journey.stops; - if (stops.length === 0) { - stops.push(leg); - } else { - stops[idx] = leg; - } - journeyEditorRefreshBody(); - drawJourney(); - } -} - -function journeyEditorRootClick(ev) { - const t = ev.target; - - if (t.classList.contains("journey-stop-remove")) { - const row = t.closest("[data-stop-index]"); - if (!row) return; - const idx = +row.dataset.stopIndex; - if (!Number.isFinite(idx)) return; - ensurePackJourney(); - pack.journey.stops.splice(idx, 1); - journeyEditorRefreshBody(); - drawJourney(); - } -} - -function journeyAppendStopRef(stopRef) { - ensurePackJourney(); - const JP = window.JourneyPack; - if (!JP || !JP.resolveJourneyStopPosition) return; - const ctx = { burgs: pack.burgs ?? [], markers: pack.markers ?? [] }; - if (!JP.resolveJourneyStopPosition(stopRef, ctx)) return; - const leg = JP.journeyRefStringToLeg(stopRef); - if (!leg) return; - pack.journey.stops.push(leg); - journeyEditorRefreshBody(); - drawJourney(); -} - -function journeyEditorAddLegClick() { - ensurePackJourney(); - const stops = pack.journey.stops; - if (!stops.length) { - tip("Choose the first stop in the Journey row (marker or burg), then use + to add legs.", false, "warn"); - return; - } - stops.push(stops[stops.length - 1]); - journeyEditorRefreshBody(); - drawJourney(); -} - -function journeyEditorOnClick() { - const evt = d3.event.sourceEvent || window.event; - const target = evt.target; - - let circleEl = null; - if (target?.classList?.contains("journey-waypoint")) circleEl = target; - else if (target?.closest?.(".journey-waypoint")) circleEl = target.closest(".journey-waypoint"); - - if (circleEl) { - const stopRef = circleEl.getAttribute("data-journey-stop-ref"); - if (stopRef) { - journeyAppendStopRef(stopRef); - return; - } - return; - } - - tip("Add stops from the Journey dropdown (markers and burgs only).", false, "info"); -} - -function closeJourneyEditor() { - ensureEl("journeyEditorStopsBody").innerHTML = ""; - viewbox.on("click.journey", null).style("cursor", null); - clearMainTip(); - restoreDefaultEvents(); -} - -function editJourney() { - if (customization) return; - closeDialogs("#journeyEditor, .stable"); - ensurePackJourney(); - - if (!layerIsOn("toggleJourney")) toggleJourney(); - - tip( - "Build the path with markers and burgs only—each leg follows live map positions. Use + to repeat the last stop. Click a journey circle to append that stop again. Undo / Clear affect the path only.", - true, - ); - viewbox.style("cursor", "default").on("click.journey", journeyEditorOnClick); - - $("#journeyEditor").dialog({ - title: "Journey editor", - resizable: false, - position: {my: "left top", at: "left+10 top+10", of: "#map"}, - close: closeJourneyEditor, - }); - - if (modules.editJourney) { - journeyEditorRefreshBody(); - return; - } - modules.editJourney = true; - - $("#journeyEditorRoot").on("change.journeyEd", journeyEditorRootChange).on("click.journeyEd", journeyEditorRootClick); - - $("#journeyEditorAddLeg").on("click.journeyEd", journeyEditorAddLegClick); - - $("#journeyEditorUndo").on("click.journeyEd", () => { - ensurePackJourney(); - pack.journey.stops.pop(); - journeyEditorRefreshBody(); - drawJourney(); - }); - - $("#journeyEditorClear").on("click.journeyEd", () => { - ensurePackJourney(); - pack.journey.stops = []; - journeyEditorRefreshBody(); - drawJourney(); - }); - - $("#journeyEditorDone").on("click.journeyEd", () => $("#journeyEditor").dialog("close")); - - journeyEditorRefreshBody(); -} diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 1b2d9bb59..2a586f23a 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -4,6 +4,14 @@ let presets = {}; // global object restoreCustomPresets(); // run on-load +/** Layer id list for a preset key (built-in default vs stored custom array). */ +function layersArrayForPresetKey(presetKey) { + const raw = presets[presetKey]; + return Array.isArray(raw) + ? raw + : getDefaultPresets()[presetKey] || getDefaultPresets().political; +} + function getDefaultPresets() { return { political: [ @@ -65,7 +73,7 @@ function getDefaultPresets() { "toggleScaleBar", "toggleVignette" ], - campaign: [ + journeyPath: [ "toggleHeight", "toggleLakes", "toggleCells", @@ -125,10 +133,7 @@ function applyLayersPreset() { const preset = localStorage.getItem("preset") || ensureEl("layersPreset").value; setLayersPreset(preset); - const raw = presets[preset]; - const layers = Array.isArray(raw) - ? raw - : getDefaultPresets()[preset] || getDefaultPresets().political; + const layers = layersArrayForPresetKey(preset); document.querySelectorAll("#mapLayers > li").forEach(el => { const shouldBeOn = layers.includes(el.id); if (shouldBeOn) el.classList.remove("buttonoff"); @@ -149,10 +154,7 @@ function setLayersPreset(preset) { function handleLayersPresetChange(preset) { setLayersPreset(preset); - const raw = presets[preset]; - const layers = Array.isArray(raw) - ? raw - : getDefaultPresets()[preset] || getDefaultPresets().political; + const layers = layersArrayForPresetKey(preset); document.querySelectorAll("#mapLayers > li").forEach(el => { const isOn = layerIsOn(el.id); const shouldBeOn = layers.includes(el.id); @@ -877,16 +879,11 @@ function toggleMarkers(event) { } } -function journeyZoomExtentMin() { - return Math.max(+ensureEl("zoomExtentMin").value, 0.01); -} - function drawJourney() { TIME && console.time("drawJourney"); - if (!pack.journey) pack.journey = {stops: []}; - if (window.JourneyPack) window.JourneyPack.normalizePackJourney(pack.journey, pack); - const zs = typeof scale === "number" && Number.isFinite(scale) ? scale : 1; - JourneyDraw.redraw(defs, journeys, zs, journeyZoomExtentMin()); + if (window.Journey) window.Journey.ensurePackJourneyNormalized(pack); + const zm = Math.max(+ensureEl("zoomExtentMin").value, 0.01); + JourneyDraw.redraw(defs, journeys, scale, zm); // Presets only flip layer buttons; they never call toggleJourney/fadeIn. Match visible state to layerIsOn. if (layerIsOn("toggleJourney")) journeys.style("display", null); TIME && console.timeEnd("drawJourney"); @@ -896,9 +893,8 @@ function syncJourneyZoom(zoomScale) { if (!layerIsOn("toggleJourney")) return; const jn = journeys.node(); if (!jn || getComputedStyle(jn).display === "none") return; - const zs = - typeof zoomScale === "number" && Number.isFinite(zoomScale) ? zoomScale : 1; - JourneyDraw.syncZoom(defs, journeys, zs, journeyZoomExtentMin()); + const zm = Math.max(+ensureEl("zoomExtentMin").value, 0.01); + JourneyDraw.syncZoom(defs, journeys, zoomScale, zm); } function toggleJourney(event) { diff --git a/public/modules/ui/style.js b/public/modules/ui/style.js index eeaf84ee2..23bd76150 100644 --- a/public/modules/ui/style.js +++ b/public/modules/ui/style.js @@ -90,9 +90,22 @@ function journeyStyleHexForPicker(raw, fallback) { return fb; } -/** Match built-in rainbow ramp endpoints in journey-draw (when data-rainbow-stops unset). */ -const JOURNEY_UI_GRADIENT_DEFAULT_FROM = "#e81416"; -const JOURNEY_UI_GRADIENT_DEFAULT_TO = "#70389d"; +/** Fallbacks if TS bundle not loaded yet; normally mirror `window.Journey.STYLE_DEFAULTS`. */ +function journeyUiDefaults() { + const d = window.Journey && window.Journey.STYLE_DEFAULTS; + return { + gradientFromHex: (d && d.gradientFromHex) || "#e81416", + gradientToHex: (d && d.gradientToHex) || "#70389d", + lineScreenPx: (d && d.lineScreenPx) || 6, + waypointRScreenPx: (d && d.waypointRScreenPx) || 9, + waypointRingScreenPx: (d && d.waypointRingScreenPx) || 4.5, + outlineScreenPx: (d && d.outlineScreenPx) || 2, + solidStroke: (d && d.solidStroke) || "#5c5c70", + waypointFill: (d && d.waypointFill) || "#ffffff", + waypointStroke: (d && d.waypointStroke) || "#000000", + outlineColor: (d && d.outlineColor) || "#000000", + }; +} function journeyParseStopsList(raw) { if (raw == null || !String(raw).trim()) return null; @@ -110,12 +123,13 @@ function journeyPopulateRainbowUi(j) { const parsed = journeyParseStopsList(raw); let fromHex; let toHex; + const def = journeyUiDefaults(); if (parsed && parsed.length >= 2) { - fromHex = journeyStyleHexForPicker(parsed[0], JOURNEY_UI_GRADIENT_DEFAULT_FROM); - toHex = journeyStyleHexForPicker(parsed[parsed.length - 1], JOURNEY_UI_GRADIENT_DEFAULT_TO); + fromHex = journeyStyleHexForPicker(parsed[0], def.gradientFromHex); + toHex = journeyStyleHexForPicker(parsed[parsed.length - 1], def.gradientToHex); } else { - fromHex = JOURNEY_UI_GRADIENT_DEFAULT_FROM; - toHex = JOURNEY_UI_GRADIENT_DEFAULT_TO; + fromHex = def.gradientFromHex; + toHex = def.gradientToHex; } ensureEl("styleJourneyGradientFrom").value = fromHex; ensureEl("styleJourneyGradientFromOutput").value = fromHex; @@ -276,25 +290,33 @@ function selectStyleElement() { if (styleElement === "journeys") { ensureEl("styleJourney").style.display = "table-row-group"; const j = el; + const jd = journeyUiDefaults(); + const numAttr = (name, fb) => { + const v = parseFloat(j.attr(name)); + return Number.isFinite(v) ? v : fb; + }; const modeRaw = (j.attr("data-color-mode") || "rainbow").toLowerCase(); ensureEl("styleJourneyColorMode").value = modeRaw === "solid" ? "solid" : "rainbow"; - const solidHex = journeyStyleHexForPicker(j.attr("data-solid-stroke"), "#5c5c70"); + const solidHex = journeyStyleHexForPicker(j.attr("data-solid-stroke"), jd.solidStroke); ensureEl("styleJourneySolidStroke").value = solidHex; ensureEl("styleJourneySolidStrokeOutput").value = solidHex; journeyPopulateRainbowUi(j); - ensureEl("styleJourneyLineScreenPx").value = j.attr("data-line-screen-px") || 6; - const wpf = journeyStyleHexForPicker(j.attr("data-waypoint-fill"), "#ffffff"); + ensureEl("styleJourneyLineScreenPx").value = numAttr("data-line-screen-px", jd.lineScreenPx); + const wpf = journeyStyleHexForPicker(j.attr("data-waypoint-fill"), jd.waypointFill); ensureEl("styleJourneyWaypointFill").value = wpf; ensureEl("styleJourneyWaypointFillOutput").value = wpf; - const wps = journeyStyleHexForPicker(j.attr("data-waypoint-stroke"), "#000000"); + const wps = journeyStyleHexForPicker(j.attr("data-waypoint-stroke"), jd.waypointStroke); ensureEl("styleJourneyWaypointStroke").value = wps; ensureEl("styleJourneyWaypointStrokeOutput").value = wps; - ensureEl("styleJourneyWaypointRScreenPx").value = j.attr("data-waypoint-r-screen-px") || 9; - ensureEl("styleJourneyWaypointRingScreenPx").value = j.attr("data-waypoint-ring-screen-px") || 4.5; - const oc = journeyStyleHexForPicker(j.attr("data-outline-color"), "#000000"); + ensureEl("styleJourneyWaypointRScreenPx").value = numAttr("data-waypoint-r-screen-px", jd.waypointRScreenPx); + ensureEl("styleJourneyWaypointRingScreenPx").value = numAttr( + "data-waypoint-ring-screen-px", + jd.waypointRingScreenPx, + ); + const oc = journeyStyleHexForPicker(j.attr("data-outline-color"), jd.outlineColor); ensureEl("styleJourneyOutlineColor").value = oc; ensureEl("styleJourneyOutlineColorOutput").value = oc; - ensureEl("styleJourneyOutlineScreenPx").value = j.attr("data-outline-screen-px") || 2; + ensureEl("styleJourneyOutlineScreenPx").value = numAttr("data-outline-screen-px", jd.outlineScreenPx); journeyStyleSyncRowVisibility(); } diff --git a/src/index.html b/src/index.html index 91085f5bd..eb72680c7 100644 --- a/src/index.html +++ b/src/index.html @@ -472,7 +472,7 @@ - + @@ -8699,14 +8699,14 @@ - + - + @@ -8718,7 +8718,6 @@ - @@ -8751,7 +8750,7 @@ - + diff --git a/src/modules/index.ts b/src/modules/index.ts index e87f5f093..a696c986c 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -10,6 +10,7 @@ import "./biomes"; import "./cultures-generator"; import "./routes-generator"; import "./journey-draw"; +import "./journey-editor"; import "./states-generator"; import "./zones-generator"; import "./religions-generator"; diff --git a/src/modules/journey-draw.ts b/src/modules/journey-draw.ts index af2b88ce9..b940b929a 100644 --- a/src/modules/journey-draw.ts +++ b/src/modules/journey-draw.ts @@ -1,7 +1,9 @@ +/** + * Journey SVG rendering (#journeys): delegates geometry/style to sibling modules; + * exposes Routes-like `window.Journey` API for legacy scripts. + */ import type { Selection } from "d3"; -import { interpolateRgbBasis } from "d3"; import type { - JourneyNormalizePackContext, JourneyResolutionContext, JourneyResolvedStopEntry, PackJourney, @@ -10,6 +12,7 @@ import { buildJourneyResolutionContext, burgJourneyStopRef, emptyPackJourney, + ensurePackJourneyNormalized, journeyLegToRefString, journeyRefStringToLeg, journeyResolvedCoordinates, @@ -19,234 +22,59 @@ import { resolveJourneyLeg, resolveJourneyStopPosition, } from "./journey-model"; +import { + arrowPositionsAlongPolyline, + bendSegmentChord, + chordGradientT, + directedChordOccurrenceIndex, + journeyArrowSpacingMapUnits, + journeyLodTier, + journeyPolylineSamplesForTier, + laneMultipliersForSegments, + MIN_SEG_LEN, + polylineLength, + polylinePath, + quadraticSamples, + segmentUInterval, +} from "./journey-path-geometry"; +import { + journeyRampSamplerForConfig, + JOURNEY_STYLE_DEFAULTS, + readJourneyStyleConfig, +} from "./journey-style-config"; import { rn } from "../utils/numberUtils"; -/** Rainbow ramp endpoints used as one continuous gradient sliced per segment. */ -export const JOURNEY_RAINBOW_STOPS = [ - "#e81416", - "#ff7518", - "#ffdc00", - "#32cd32", - "#00bfff", - "#4e529a", - "#70389d", -]; - -const rampInterpolator = interpolateRgbBasis(JOURNEY_RAINBOW_STOPS); - -/** Default path/arrows color when `data-color-mode` is solid and no `data-solid-stroke`. */ -export const JOURNEY_DEFAULT_SOLID_STROKE = "#5c5c70"; - -export type JourneyColorMode = "rainbow" | "solid"; - -/** Resolved presentation for `#journeys` (from `data-*` + defaults). */ -export interface JourneyStyleConfig { - colorMode: JourneyColorMode; - solidStroke: string; - rainbowStops: readonly string[]; - lineScreenPx: number; - waypointFill: string; - waypointStroke: string; - waypointRScreenPx: number; - waypointRingScreenPx: number; - outlineColor: string; - outlineScreenPx: number; -} - -function clamp(n: number, lo: number, hi: number): number { - return Math.min(hi, Math.max(lo, n)); -} - -/** Parse comma-separated hex/color tokens; returns null if fewer than two usable stops. */ -export function parseJourneyRainbowStops(raw: string | null | undefined): string[] | null { - if (raw == null || !String(raw).trim()) return null; - const parts = String(raw) - .split(",") - .map(s => s.trim()) - .filter(Boolean); - return parts.length >= 2 ? parts : null; -} - -/** - * Read journey style from `#journeys` SVG attributes (`data-*`). - * Safe with `null` / missing element (uses built-in defaults matching former hardcoded constants). - */ -export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { - const get = (name: string): string | null => - el && typeof el.getAttribute === "function" ? el.getAttribute(name) : null; - - /** Parse numeric data-*; keeps `0` (unlike `parseFloat(x) || fallback`). */ - const attrPx = (name: string, fallback: number): number => { - const v = Number.parseFloat(get(name) ?? ""); - return Number.isFinite(v) ? v : fallback; - }; - - const modeRaw = (get("data-color-mode") || "rainbow").toLowerCase().trim(); - const colorMode: JourneyColorMode = modeRaw === "solid" ? "solid" : "rainbow"; - - const parsedStops = parseJourneyRainbowStops(get("data-rainbow-stops")); - const rainbowStops = - parsedStops && parsedStops.length >= 2 ? parsedStops : [...JOURNEY_RAINBOW_STOPS]; - - const solidStroke = - get("data-solid-stroke")?.trim() || JOURNEY_DEFAULT_SOLID_STROKE; - - const lineScreenPx = clamp( - attrPx("data-line-screen-px", JOURNEY_STROKE_SCREEN_PX), - 0.5, - 96, - ); - - const waypointFill = get("data-waypoint-fill")?.trim() || "#ffffff"; - const waypointStroke = get("data-waypoint-stroke")?.trim() || "#000000"; - - const waypointRScreenPx = clamp( - attrPx("data-waypoint-r-screen-px", JOURNEY_WAYPOINT_R_SCREEN_PX), - 2, - 120, - ); - - const waypointRingScreenPx = clamp( - attrPx("data-waypoint-ring-screen-px", JOURNEY_WAYPOINT_STROKE_SCREEN_PX), - 0, - 48, - ); - - const outlineColor = get("data-outline-color")?.trim() || "#000000"; - - const outlineScreenPx = clamp( - attrPx("data-outline-screen-px", JOURNEY_HALO_DILATE_SCREEN_PX), - 0, - 32, - ); - - return { - colorMode, - solidStroke, - rainbowStops, - lineScreenPx, - waypointFill, - waypointStroke, - waypointRScreenPx, - waypointRingScreenPx, - outlineColor, - outlineScreenPx, - }; -} - -/** Uniform ramp sampler along one logical journey (same contract as `journeyRampColor`). */ -export function journeyRampSamplerForConfig(cfg: JourneyStyleConfig): (u: number) => string { - if (cfg.colorMode === "solid") { - const c = cfg.solidStroke; - return (_u: number) => c; - } - const stops = cfg.rainbowStops.length >= 2 ? cfg.rainbowStops : JOURNEY_RAINBOW_STOPS; - const interp = interpolateRgbBasis([...stops]); - return (u: number) => interp(Math.max(0, Math.min(1, u))); -} - -function journeyResolutionCtx(): JourneyResolutionContext { - return buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []); -} - -function journeyNormalizeCtx(): JourneyNormalizePackContext { - return { - burgs: pack.burgs ?? [], - markers: pack.markers ?? [], - }; -} - -/** Parameter `u` in [0, 1] along the whole journey ramp. */ -export function journeyRampColor(u: number): string { - return rampInterpolator(Math.max(0, Math.min(1, u))); -} - -/** Inclusive interval along the ramp for segment `segmentIndex` of `segmentCount` edges. */ -export function segmentUInterval( - segmentCount: number, - segmentIndex: number, -): [number, number] { - if (segmentCount <= 0) return [0, 0]; - const u0 = segmentIndex / segmentCount; - const u1 = (segmentIndex + 1) / segmentCount; - return [u0, u1]; -} - -/** Quantized directed chord id for lane stacking (A→B ≠ B→A). */ -export function chordKey(a: [number, number], b: [number, number]): string { - return `${rn(a[0], 2)},${rn(a[1], 2)}->${rn(b[0], 2)},${rn(b[1], 2)}`; -} - -/** - * Fraction along chord A→B for point P (clamped to [0, 1]), matching `linearGradient` - * `userSpaceOnUse` with axis from A to B (constant perpendicular to that axis). - */ -export function chordGradientT( - a: [number, number], - b: [number, number], - px: number, - py: number, -): number { - const vx = b[0] - a[0]; - const vy = b[1] - a[1]; - const len2 = vx * vx + vy * vy; - if (len2 < 1e-18) return 0; - const t = ((px - a[0]) * vx + (py - a[1]) * vy) / len2; - return Math.max(0, Math.min(1, t)); -} +export { + JOURNEY_DEFAULT_SOLID_STROKE, + JOURNEY_RAINBOW_STOPS, + JOURNEY_STYLE_DEFAULTS, + journeyRampColor, + parseJourneyRainbowStops, + readJourneyStyleConfig, + journeyRampSamplerForConfig, + type JourneyColorMode, + type JourneyStyleConfig, +} from "./journey-style-config"; + +export { + arrowPositionsAlongPolyline, + bendSegmentChord, + chordGradientT, + chordKey, + directedChordOccurrenceIndex, + journeyArrowSpacingMapUnits, + journeyArrowSpacingMulForTier, + journeyLodTier, + journeyPolylineSamplesForTier, + laneMultipliersForSegments, + segmentUInterval, + type ArrowSample, +} from "./journey-path-geometry"; /** Arrowhead path (local coords before translate/rotate); 2× prior triangle size. */ const ARROW_PATH_D = "M0,-8.4 L22.5,0 L0,8.4 Z"; -const MIN_SEG_LEN = 0.05; -const LANE_WIDTH = 3.5; - -/** Target screen px under `#viewbox` scale (stroke-width × scale ≈ px). */ -const JOURNEY_STROKE_SCREEN_PX = 6; -const JOURNEY_WAYPOINT_R_SCREEN_PX = 9; -const JOURNEY_WAYPOINT_STROKE_SCREEN_PX = 4.5; -const JOURNEY_HALO_DILATE_SCREEN_PX = 2; -/** Baseline arrow spacing at scale 1 (screen-ish); multiplied by tier below. */ -const JOURNEY_ARROW_SPACING_BASE_PX = 38; - -const LOD_TIER_MAX = 6; - -const LOD_ARROW_SPACING_MUL: readonly number[] = [ - 2.25, 1.9, 1.6, 1.35, 1.2, 1.08, 1, -]; - -/** - * Discrete LOD tier from zoom `scale` vs minimum zoom extent (both > 0). - * Tier rises as the user zooms in relative to `zoomMin`. - */ -export function journeyLodTier(scale: number, zoomMin: number): number { - const s = Math.max(scale, 1e-9); - const zmin = Math.max(zoomMin, 1e-9); - const raw = Math.floor(Math.log2(s)) - Math.floor(Math.log2(zmin)); - return Math.max(0, Math.min(LOD_TIER_MAX, raw)); -} - -/** Polyline subdivisions for quadratic sampling; higher tier ⇒ smoother curve. */ -export function journeyPolylineSamplesForTier(tier: number): number { - const t = Math.max(0, Math.min(LOD_TIER_MAX, tier)); - return Math.min(44, Math.max(12, 14 + t * 5)); -} - -/** Fewer arrows when tier is low (zoomed out). */ -export function journeyArrowSpacingMulForTier(tier: number): number { - const t = Math.max(0, Math.min(LOD_TIER_MAX, tier)); - return LOD_ARROW_SPACING_MUL[t] ?? 1; -} - -/** Arrow spacing in map units (~constant screen spacing × LOD density). */ -export function journeyArrowSpacingMapUnits( - scale: number, - tier: number, - spacingPx = JOURNEY_ARROW_SPACING_BASE_PX, -): number { - const k = Math.max(scale, 1e-9); - return (spacingPx * journeyArrowSpacingMulForTier(tier)) / k; -} +const JOURNEY_OUTLINE_FILTER_ID = "journeyUnifiedOutline"; function mapMetricScreenToWorld( screenPx: number, @@ -268,9 +96,6 @@ function arrowTransform( return `translate(${rn(x, 2)},${rn(y, 2)}) rotate(${rn(angleDeg, 2)}) scale(${inv})`; } -/** Single defs filter id: dilated silhouette behind segment stroke + arrows (unified halo). */ -const JOURNEY_OUTLINE_FILTER_ID = "journeyUnifiedOutline"; - function ensureJourneyOutlineFilter( defs: Selection, morphologyRadiusMap: number, @@ -308,182 +133,13 @@ function ensureJourneyOutlineFilter( merge.append("feMergeNode").attr("in", "SourceGraphic"); } -/** Max perpendicular lift at chord midpoint (fraction of chord length), scale ~ repeat index below. */ -const BEND_BASE = 0.14; -/** Extra curvature per reuse count `k` of the same directed chord: bend *= (1 + k * CURVATURE_REPEAT_GAIN). */ -const CURVATURE_REPEAT_GAIN = 0.45; - -/** - * SVG maps use Y-down; negate CCW left normal so traveler‑relative “left” matches screen intuition. - */ -const LEFT_NORMAL_SCREEN_SIGN = -1; - -/** 0-based reuse index per directed chord (first traversal → 0, second identical chord → 1, …). */ -export function directedChordOccurrenceIndex( - points: [number, number][], -): number[] { - const indices: number[] = []; - const counters = new Map(); - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]; - const p1 = points[i + 1]; - if (Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) < MIN_SEG_LEN) { - indices.push(0); - continue; - } - const key = chordKey(p0, p1); - const k = counters.get(key) ?? 0; - indices.push(k); - counters.set(key, k + 1); - } - return indices; -} - -/** Chord midpoint perpendicular bend magnitude after applying repeat scaling. */ -export function bendSegmentChord(len: number, repeatIndex: number): number { - return ( - BEND_BASE * len * (1 + Math.max(0, repeatIndex) * CURVATURE_REPEAT_GAIN) - ); -} - -/** Lane multiplier per segment (centered around 0) for duplicate directed chords. */ -export function laneMultipliersForSegments( - points: [number, number][], -): number[] { - const keys: string[] = []; - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]; - const p1 = points[i + 1]; - if (Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) < MIN_SEG_LEN) { - keys.push(""); - continue; - } - keys.push(chordKey(p0, p1)); - } - - const counts = new Map(); - for (const k of keys) { - if (!k) continue; - counts.set(k, (counts.get(k) ?? 0) + 1); - } - - const idx = new Map(); - const lanes: number[] = []; - for (const k of keys) { - if (!k || (counts.get(k) ?? 0) <= 1) { - lanes.push(0); - continue; - } - const c = counts.get(k)!; - const slot = idx.get(k) ?? 0; - idx.set(k, slot + 1); - lanes.push(slot - (c - 1) / 2); - } - return lanes; -} - -function quadraticSamples( - a: [number, number], - b: [number, number], - bendAmount: number, - lane: number, - samples: number, -): [number, number][] { - const dx = b[0] - a[0]; - const dy = b[1] - a[1]; - const len = Math.hypot(dx, dy) || 1; - /** Left of travel direction (−v rotated CCW). Reverse traversal swaps normal ⇒ opposite bulge on chord. */ - const nx = -dy / len; - const ny = dx / len; - const mx = (a[0] + b[0]) / 2; - const my = (a[1] + b[1]) / 2; - const cx = mx + nx * bendAmount * LEFT_NORMAL_SCREEN_SIGN; - const cy = my + ny * bendAmount * LEFT_NORMAL_SCREEN_SIGN; - - const pts: [number, number][] = []; - for (let i = 0; i <= samples; i++) { - const t = i / samples; - const omt = 1 - t; - let x = omt * omt * a[0] + 2 * omt * t * cx + t * t * b[0]; - let y = omt * omt * a[1] + 2 * omt * t * cy + t * t * b[1]; - const tx = 2 * omt * (cx - a[0]) + 2 * t * (b[0] - cx); - const ty = 2 * omt * (cy - a[1]) + 2 * t * (b[1] - cy); - const tlen = Math.hypot(tx, ty) || 1; - const px = -ty / tlen; - const py = tx / tlen; - const fade = Math.sin(Math.PI * t); - const off = lane * LANE_WIDTH * fade; - x += px * off; - y += py * off; - pts.push([x, y]); - } - return pts; -} - -function polylinePath(pts: [number, number][]): string { - if (pts.length === 0) return ""; - let d = `M${rn(pts[0][0], 2)},${rn(pts[0][1], 2)}`; - for (let i = 1; i < pts.length; i++) { - d += `L${rn(pts[i][0], 2)},${rn(pts[i][1], 2)}`; - } - return d; -} - -export interface ArrowSample { - x: number; - y: number; - angleDeg: number; -} - -/** Arrow markers spaced along a polyline (map coords). */ -export function arrowPositionsAlongPolyline( - pts: [number, number][], - spacing: number, -): ArrowSample[] { - const result: ArrowSample[] = []; - if (pts.length < 2 || spacing <= 0) return result; - - let cumulative = 0; - let nextAt = spacing; - - for (let i = 0; i < pts.length - 1; i++) { - const x0 = pts[i][0]; - const y0 = pts[i][1]; - const x1 = pts[i + 1][0]; - const y1 = pts[i + 1][1]; - const segLen = Math.hypot(x1 - x0, y1 - y0); - if (segLen < 1e-9) continue; - const angleDeg = (Math.atan2(y1 - y0, x1 - x0) * 180) / Math.PI; - - const segEnd = cumulative + segLen; - while (nextAt <= segEnd + 1e-9) { - const alongSeg = nextAt - cumulative; - const t = alongSeg / segLen; - result.push({ - x: x0 + t * (x1 - x0), - y: y0 + t * (y1 - y0), - angleDeg, - }); - nextAt += spacing; - } - cumulative = segEnd; - } - - return result; -} - -function polylineLength(pts: [number, number][]): number { - let len = 0; - for (let i = 0; i < pts.length - 1; i++) { - len += Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]); - } - return len; +function journeyResolutionCtx(): JourneyResolutionContext { + return buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []); } export class JourneyDrawModule { private lastLodTier: number | null = null; - /** Full rebuild (data / LOD tier geometry). Pass current viewbox `scale` and zoom extent min for LOD. */ redraw( defs: Selection, journeys: Selection, @@ -498,7 +154,7 @@ export class JourneyDrawModule { this.lastLodTier = null; return; } - normalizePackJourney(pack.journey, journeyNormalizeCtx()); + ensurePackJourneyNormalized(pack); const journeyData = pack.journey as PackJourney; const resCtx = journeyResolutionCtx(); const resolvedStops = journeyResolvedStopEntries(journeyData, resCtx); @@ -508,8 +164,8 @@ export class JourneyDrawModule { } const points = resolvedStops.map((r: JourneyResolvedStopEntry) => r.coord); - const zs = Number.isFinite(zoomScale) ? zoomScale : 1; - const zm = Number.isFinite(zoomMinForLod) ? zoomMinForLod : 0.05; + const zs = zoomScale; + const zm = zoomMinForLod; const styleCfg = readJourneyStyleConfig(journeys.node()); const rampAt = journeyRampSamplerForConfig(styleCfg); @@ -674,7 +330,6 @@ export class JourneyDrawModule { this.lastLodTier = tier; } - /** Zoom-only updates: cheap attr patch if LOD tier unchanged; else full `redraw`. */ syncZoom( defs: Selection, journeys: Selection, @@ -682,15 +337,15 @@ export class JourneyDrawModule { zoomMinForLod = 0.05, ): void { if (!pack.journey) return; - normalizePackJourney(pack.journey, journeyNormalizeCtx()); + ensurePackJourneyNormalized(pack); const points = journeyResolvedCoordinates( pack.journey as PackJourney, journeyResolutionCtx(), ); if (!points.length) return; - const zs = Number.isFinite(zoomScale) ? zoomScale : 1; - const zm = Number.isFinite(zoomMinForLod) ? zoomMinForLod : 0.05; + const zs = zoomScale; + const zm = zoomMinForLod; const tier = journeyLodTier(zs, zm); const S = Math.max(0, points.length - 1); @@ -758,9 +413,25 @@ export class JourneyDrawModule { } } -if (typeof window !== "undefined") { - window.JourneyDraw = new JourneyDrawModule(); - window.JourneyPack = { +/** Routes-like facade: pack helpers + style defaults for legacy UI scripts. */ +export type JourneyGlobalApi = { + STYLE_DEFAULTS: typeof JOURNEY_STYLE_DEFAULTS; + ensurePackJourneyNormalized: typeof ensurePackJourneyNormalized; + normalizePackJourney: typeof normalizePackJourney; + journeyResolvedCoordinates: typeof journeyResolvedCoordinates; + resolveJourneyStopPosition: typeof resolveJourneyStopPosition; + resolveJourneyLeg: typeof resolveJourneyLeg; + journeyRefStringToLeg: typeof journeyRefStringToLeg; + emptyPackJourney: typeof emptyPackJourney; + burgJourneyStopRef: typeof burgJourneyStopRef; + markerJourneyStopRef: typeof markerJourneyStopRef; + journeyLegToRefString: typeof journeyLegToRefString; +}; + +function createJourneyGlobalApi(): JourneyGlobalApi { + return { + STYLE_DEFAULTS: JOURNEY_STYLE_DEFAULTS, + ensurePackJourneyNormalized, normalizePackJourney, journeyResolvedCoordinates, resolveJourneyStopPosition, @@ -773,20 +444,20 @@ if (typeof window !== "undefined") { }; } +if (typeof window !== "undefined") { + window.JourneyDraw = new JourneyDrawModule(); + const journeyApi = createJourneyGlobalApi(); + window.Journey = journeyApi; + window.JourneyPack = journeyApi; +} + declare global { var pack: import("../types/PackedGraph").PackedGraph; interface Window { JourneyDraw?: JourneyDrawModule; - JourneyPack?: { - normalizePackJourney: typeof normalizePackJourney; - journeyResolvedCoordinates: typeof journeyResolvedCoordinates; - resolveJourneyStopPosition: typeof resolveJourneyStopPosition; - resolveJourneyLeg: typeof resolveJourneyLeg; - journeyRefStringToLeg: typeof journeyRefStringToLeg; - emptyPackJourney: typeof emptyPackJourney; - burgJourneyStopRef: typeof burgJourneyStopRef; - markerJourneyStopRef: typeof markerJourneyStopRef; - journeyLegToRefString: typeof journeyLegToRefString; - }; + /** Journey pack helpers + defaults (Routes-style); prefer over `JourneyPack`. */ + Journey?: JourneyGlobalApi; + /** @deprecated Use `Journey` */ + JourneyPack?: JourneyGlobalApi; } } diff --git a/src/modules/journey-editor.ts b/src/modules/journey-editor.ts new file mode 100644 index 000000000..729e90cf6 --- /dev/null +++ b/src/modules/journey-editor.ts @@ -0,0 +1,264 @@ +/** + * Journey editor dialog — bundled TS mirroring routes/markers editor patterns. + */ +import { rn } from "../utils/numberUtils"; +import type { JourneyResolutionContext } from "./journey-model"; +import { + buildJourneyResolutionContext, + burgJourneyStopRef, + ensurePackJourneyNormalized, + journeyLegToRefString, + journeyRefStringToLeg, + markerJourneyStopRef, + resolveJourneyStopPosition, +} from "./journey-model"; + +function escapeAttr(s: string): string { + return String(s).replace(/&/g, "&").replace(/"/g, """); +} + +function escapeText(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function resolutionCtx(): JourneyResolutionContext { + return buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []); +} + +function journeyStopSelectOptions(currentRef: string): string { + ensurePackJourneyNormalized(pack); + let html = ""; + const known = new Set(); + + if (!currentRef) { + html += + ''; + } + + html += ''; + for (const m of pack.markers || []) { + if (m.i == null || !Number.isFinite(m.x) || !Number.isFinite(m.y)) continue; + const ref = markerJourneyStopRef(m.i); + known.add(ref); + const sel = ref === currentRef ? " selected" : ""; + const typeLabel = m.type ? String(m.type) : "Marker"; + const label = `${typeLabel} #${m.i} (${rn(m.x, 2)}, ${rn(m.y, 2)})`; + html += ``; + } + html += ""; + + html += ''; + for (const b of pack.burgs || []) { + if (b.removed || b.i == null || !Number.isFinite(b.x) || !Number.isFinite(b.y)) + continue; + const ref = burgJourneyStopRef(b.i); + known.add(ref); + const sel = ref === currentRef ? " selected" : ""; + const nm = + b.name && String(b.name).trim() !== "" ? String(b.name) : `Burg ${b.i}`; + const label = `${nm} (${rn(b.x, 2)}, ${rn(b.y, 2)})`; + html += ``; + } + html += ""; + + if (currentRef && !known.has(currentRef)) { + html += ``; + } + return html; +} + +function journeyLegToSelectValue( + leg: import("./journey-model").JourneyStopLeg | null, +): string { + if (!leg) return ""; + return journeyLegToRefString(leg); +} + +function journeyRenderStopRows(container: HTMLElement): void { + ensurePackJourneyNormalized(pack); + const stops = pack.journey!.stops; + const rows = stops.length === 0 ? [null] : stops; + rows.forEach((leg, i) => { + const currentRef = leg ? journeyLegToSelectValue(leg) : ""; + const showRemove = stops.length > 0; + const removeStyle = showRemove + ? "" + : "visibility:hidden;pointer-events:none"; + container.insertAdjacentHTML( + "beforeend", + /* html */ `
    + #${i + 1} + + +
    `, + ); + }); +} + +function journeyEditorRefreshBody(): void { + const stBody = ensureEl("journeyEditorStopsBody"); + stBody.innerHTML = ""; + ensurePackJourneyNormalized(pack); + journeyRenderStopRows(stBody); +} + +function journeyEditorRootChange(ev: Event): void { + const t = ev.target as HTMLElement; + + if (t.classList.contains("journey-stop-select")) { + const row = t.closest("[data-stop-index]"); + if (!row) return; + const idx = +(row as HTMLElement).dataset.stopIndex!; + if (!Number.isFinite(idx)) return; + const val = (t as HTMLSelectElement).value; + ensurePackJourneyNormalized(pack); + if (!val) return; + const leg = journeyRefStringToLeg(val); + if (!leg) return; + + const stops = pack.journey!.stops; + if (stops.length === 0) { + stops.push(leg); + } else { + stops[idx] = leg; + } + journeyEditorRefreshBody(); + drawJourney(); + } +} + +function journeyEditorRootClick(ev: Event): void { + const t = ev.target as HTMLElement; + + if (t.classList.contains("journey-stop-remove")) { + const row = t.closest("[data-stop-index]"); + if (!row) return; + const idx = +(row as HTMLElement).dataset.stopIndex!; + if (!Number.isFinite(idx)) return; + ensurePackJourneyNormalized(pack); + pack.journey!.stops.splice(idx, 1); + journeyEditorRefreshBody(); + drawJourney(); + } +} + +function journeyAppendStopRef(stopRef: string): void { + ensurePackJourneyNormalized(pack); + const ctx = resolutionCtx(); + if (!resolveJourneyStopPosition(stopRef, ctx)) return; + const leg = journeyRefStringToLeg(stopRef); + if (!leg) return; + pack.journey!.stops.push(leg); + journeyEditorRefreshBody(); + drawJourney(); +} + +function journeyEditorAddLegClick(): void { + ensurePackJourneyNormalized(pack); + const stops = pack.journey!.stops; + if (!stops.length) { + tip( + "Choose the first stop in the Journey row (marker or burg), then use + to add legs.", + false, + "warn", + ); + return; + } + stops.push(stops[stops.length - 1]); + journeyEditorRefreshBody(); + drawJourney(); +} + +function journeyEditorOnClick(): void { + const d3g = globalThis as typeof globalThis & { + d3?: { event?: { sourceEvent?: Event } }; + }; + const evt = + d3g.d3?.event?.sourceEvent ?? window.event; + const target = evt?.target as HTMLElement | undefined; + + let circleEl: Element | null = null; + if (target?.classList?.contains("journey-waypoint")) circleEl = target; + else if (target?.closest?.(".journey-waypoint")) + circleEl = target.closest(".journey-waypoint"); + + if (circleEl) { + const stopRef = circleEl.getAttribute("data-journey-stop-ref"); + if (stopRef) { + journeyAppendStopRef(stopRef); + return; + } + return; + } + + tip("Add stops from the Journey dropdown (markers and burgs only).", false, "info"); +} + +function closeJourneyEditor(): void { + ensureEl("journeyEditorStopsBody").innerHTML = ""; + viewbox.on("click.journey", null).style("cursor", null); + clearMainTip(); + restoreDefaultEvents(); +} + +function editJourney(): void { + if (customization) return; + closeDialogs("#journeyEditor, .stable"); + ensurePackJourneyNormalized(pack); + + if (!layerIsOn("toggleJourney")) toggleJourney(); + + tip( + "Build the path with markers and burgs only—each leg follows live map positions. Use + to repeat the last stop. Click a journey circle to append that stop again. Undo / Clear affect the path only.", + true, + ); + viewbox.style("cursor", "default").on("click.journey", journeyEditorOnClick); + + $("#journeyEditor").dialog({ + title: "Journey editor", + resizable: false, + position: { my: "left top", at: "left+10 top+10", of: "#map" }, + close: closeJourneyEditor, + }); + + if (modules.editJourney) { + journeyEditorRefreshBody(); + return; + } + modules.editJourney = true; + + $("#journeyEditorRoot") + .on("change.journeyEd", journeyEditorRootChange) + .on("click.journeyEd", journeyEditorRootClick); + + $("#journeyEditorAddLeg").on("click.journeyEd", journeyEditorAddLegClick); + + $("#journeyEditorUndo").on("click.journeyEd", () => { + ensurePackJourneyNormalized(pack); + pack.journey!.stops.pop(); + journeyEditorRefreshBody(); + drawJourney(); + }); + + $("#journeyEditorClear").on("click.journeyEd", () => { + ensurePackJourneyNormalized(pack); + pack.journey!.stops = []; + journeyEditorRefreshBody(); + drawJourney(); + }); + + $("#journeyEditorDone").on("click.journeyEd", () => + $("#journeyEditor").dialog("close"), + ); + + journeyEditorRefreshBody(); +} + +if (typeof window !== "undefined") { + window.editJourney = editJourney; +} + +export { editJourney }; diff --git a/src/modules/journey-model.test.ts b/src/modules/journey-model.test.ts index ad71f77f4..14bb9c327 100644 --- a/src/modules/journey-model.test.ts +++ b/src/modules/journey-model.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildJourneyResolutionContext, burgJourneyStopRef, + ensurePackJourneyNormalized, journeyLegToRefString, journeyRefStringToLeg, journeyResolvedCoordinates, @@ -14,8 +15,17 @@ import { type JourneyResolutionContext, type JourneyStopLeg, type PackJourney, + type PackWithOptionalJourney, } from "./journey-model"; +describe("ensurePackJourneyNormalized", () => { + it("creates pack.journey when absent and normalizes", () => { + const pack: PackWithOptionalJourney = { burgs: [], markers: [] }; + ensurePackJourneyNormalized(pack); + expect(pack.journey).toEqual({ stops: [] }); + }); +}); + describe("normalizePackJourney", () => { it("strips stray points/stopIds/waypoints and yields empty stops", () => { const j: Record = { diff --git a/src/modules/journey-model.ts b/src/modules/journey-model.ts index 9f8e0aa66..1d92d6081 100644 --- a/src/modules/journey-model.ts +++ b/src/modules/journey-model.ts @@ -179,9 +179,30 @@ function legIsAllowed( return markerExistsInPack(pack, leg.id); } +/** Pack slice used when ensuring `pack.journey` exists and is sanitized. */ +export type PackWithOptionalJourney = JourneyNormalizePackContext & { + journey?: unknown; +}; + +/** + * Ensures `pack.journey` exists and mutates it to canonical `{ stops }` via + * {@link normalizePackJourney}. Single entry point for layers / editor / load. + */ +export function ensurePackJourneyNormalized(pack: PackWithOptionalJourney): void { + if ( + !pack.journey || + typeof pack.journey !== "object" || + Array.isArray(pack.journey) + ) { + pack.journey = { stops: [] }; + } + normalizePackJourney(pack.journey, pack); +} + /** - * Mutates `j` into canonical `{ stops }`. Migrates legacy `stopIds` (burg/marker - * strings only; waypoint ids dropped). Removes `stopIds`, `waypoints`, `points`. + * Mutates journey object into canonical `{ stops }`. + * Also strips stray keys (`points`, old `stopIds` / `waypoints`) from edited or + * hand-merged JSON so draw/load never sees invalid shapes. */ export function normalizePackJourney( j: unknown, diff --git a/src/modules/journey-path-geometry.ts b/src/modules/journey-path-geometry.ts new file mode 100644 index 000000000..d4d20489c --- /dev/null +++ b/src/modules/journey-path-geometry.ts @@ -0,0 +1,251 @@ +import { rn } from "../utils/numberUtils"; + +/** Quantized directed chord id for lane stacking (A→B ≠ B→A). */ +export function chordKey(a: [number, number], b: [number, number]): string { + return `${rn(a[0], 2)},${rn(a[1], 2)}->${rn(b[0], 2)},${rn(b[1], 2)}`; +} + +/** + * Fraction along chord A→B for point P (clamped to [0, 1]), matching `linearGradient` + * `userSpaceOnUse` with axis from A to B (constant perpendicular to that axis). + */ +export function chordGradientT( + a: [number, number], + b: [number, number], + px: number, + py: number, +): number { + const vx = b[0] - a[0]; + const vy = b[1] - a[1]; + const len2 = vx * vx + vy * vy; + if (len2 < 1e-18) return 0; + const t = ((px - a[0]) * vx + (py - a[1]) * vy) / len2; + return Math.max(0, Math.min(1, t)); +} + +export const MIN_SEG_LEN = 0.05; +const LANE_WIDTH = 3.5; + +/** Baseline arrow spacing at scale 1 (screen-ish); multiplied by tier below. */ +const JOURNEY_ARROW_SPACING_BASE_PX = 38; + +const LOD_TIER_MAX = 6; + +const LOD_ARROW_SPACING_MUL: readonly number[] = [ + 2.25, 1.9, 1.6, 1.35, 1.2, 1.08, 1, +]; + +/** + * Discrete LOD tier from zoom `scale` vs minimum zoom extent (both > 0). + * Tier rises as the user zooms in relative to `zoomMin`. + */ +export function journeyLodTier(scale: number, zoomMin: number): number { + const s = Math.max(scale, 1e-9); + const zmin = Math.max(zoomMin, 1e-9); + const raw = Math.floor(Math.log2(s)) - Math.floor(Math.log2(zmin)); + return Math.max(0, Math.min(LOD_TIER_MAX, raw)); +} + +/** Polyline subdivisions for quadratic sampling; higher tier ⇒ smoother curve. */ +export function journeyPolylineSamplesForTier(tier: number): number { + const t = Math.max(0, Math.min(LOD_TIER_MAX, tier)); + return Math.min(44, Math.max(12, 14 + t * 5)); +} + +/** Fewer arrows when tier is low (zoomed out). */ +export function journeyArrowSpacingMulForTier(tier: number): number { + const t = Math.max(0, Math.min(LOD_TIER_MAX, tier)); + return LOD_ARROW_SPACING_MUL[t] ?? 1; +} + +/** Arrow spacing in map units (~constant screen spacing × LOD density). */ +export function journeyArrowSpacingMapUnits( + scale: number, + tier: number, + spacingPx = JOURNEY_ARROW_SPACING_BASE_PX, +): number { + const k = Math.max(scale, 1e-9); + return (spacingPx * journeyArrowSpacingMulForTier(tier)) / k; +} + +/** Max perpendicular lift at chord midpoint (fraction of chord length), scale ~ repeat index below. */ +const BEND_BASE = 0.14; +/** Extra curvature per reuse count `k` of the same directed chord: bend *= (1 + k * CURVATURE_REPEAT_GAIN). */ +const CURVATURE_REPEAT_GAIN = 0.45; + +/** + * SVG maps use Y-down; negate CCW left normal so traveler‑relative “left” matches screen intuition. + */ +const LEFT_NORMAL_SCREEN_SIGN = -1; + +/** 0-based reuse index per directed chord (first traversal → 0, second identical chord → 1, …). */ +export function directedChordOccurrenceIndex( + points: [number, number][], +): number[] { + const indices: number[] = []; + const counters = new Map(); + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]; + const p1 = points[i + 1]; + if (Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) < MIN_SEG_LEN) { + indices.push(0); + continue; + } + const key = chordKey(p0, p1); + const k = counters.get(key) ?? 0; + indices.push(k); + counters.set(key, k + 1); + } + return indices; +} + +/** Chord midpoint perpendicular bend magnitude after applying repeat scaling. */ +export function bendSegmentChord(len: number, repeatIndex: number): number { + return ( + BEND_BASE * len * (1 + Math.max(0, repeatIndex) * CURVATURE_REPEAT_GAIN) + ); +} + +/** Lane multiplier per segment (centered around 0) for duplicate directed chords. */ +export function laneMultipliersForSegments( + points: [number, number][], +): number[] { + const keys: string[] = []; + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]; + const p1 = points[i + 1]; + if (Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) < MIN_SEG_LEN) { + keys.push(""); + continue; + } + keys.push(chordKey(p0, p1)); + } + + const counts = new Map(); + for (const k of keys) { + if (!k) continue; + counts.set(k, (counts.get(k) ?? 0) + 1); + } + + const idx = new Map(); + const lanes: number[] = []; + for (const k of keys) { + if (!k || (counts.get(k) ?? 0) <= 1) { + lanes.push(0); + continue; + } + const c = counts.get(k)!; + const slot = idx.get(k) ?? 0; + idx.set(k, slot + 1); + lanes.push(slot - (c - 1) / 2); + } + return lanes; +} + +export function quadraticSamples( + a: [number, number], + b: [number, number], + bendAmount: number, + lane: number, + samples: number, +): [number, number][] { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len; + const ny = dx / len; + const mx = (a[0] + b[0]) / 2; + const my = (a[1] + b[1]) / 2; + const cx = mx + nx * bendAmount * LEFT_NORMAL_SCREEN_SIGN; + const cy = my + ny * bendAmount * LEFT_NORMAL_SCREEN_SIGN; + + const pts: [number, number][] = []; + for (let i = 0; i <= samples; i++) { + const t = i / samples; + const omt = 1 - t; + let x = omt * omt * a[0] + 2 * omt * t * cx + t * t * b[0]; + let y = omt * omt * a[1] + 2 * omt * t * cy + t * t * b[1]; + const tx = 2 * omt * (cx - a[0]) + 2 * t * (b[0] - cx); + const ty = 2 * omt * (cy - a[1]) + 2 * t * (b[1] - cy); + const tlen = Math.hypot(tx, ty) || 1; + const px = -ty / tlen; + const py = tx / tlen; + const fade = Math.sin(Math.PI * t); + const off = lane * LANE_WIDTH * fade; + x += px * off; + y += py * off; + pts.push([x, y]); + } + return pts; +} + +export function polylinePath(pts: [number, number][]): string { + if (pts.length === 0) return ""; + let d = `M${rn(pts[0][0], 2)},${rn(pts[0][1], 2)}`; + for (let i = 1; i < pts.length; i++) { + d += `L${rn(pts[i][0], 2)},${rn(pts[i][1], 2)}`; + } + return d; +} + +export interface ArrowSample { + x: number; + y: number; + angleDeg: number; +} + +/** Arrow markers spaced along a polyline (map coords). */ +export function arrowPositionsAlongPolyline( + pts: [number, number][], + spacing: number, +): ArrowSample[] { + const result: ArrowSample[] = []; + if (pts.length < 2 || spacing <= 0) return result; + + let cumulative = 0; + let nextAt = spacing; + + for (let i = 0; i < pts.length - 1; i++) { + const x0 = pts[i][0]; + const y0 = pts[i][1]; + const x1 = pts[i + 1][0]; + const y1 = pts[i + 1][1]; + const segLen = Math.hypot(x1 - x0, y1 - y0); + if (segLen < 1e-9) continue; + const angleDeg = (Math.atan2(y1 - y0, x1 - x0) * 180) / Math.PI; + + const segEnd = cumulative + segLen; + while (nextAt <= segEnd + 1e-9) { + const alongSeg = nextAt - cumulative; + const t = alongSeg / segLen; + result.push({ + x: x0 + t * (x1 - x0), + y: y0 + t * (y1 - y0), + angleDeg, + }); + nextAt += spacing; + } + cumulative = segEnd; + } + + return result; +} + +export function polylineLength(pts: [number, number][]): number { + let len = 0; + for (let i = 0; i < pts.length - 1; i++) { + len += Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]); + } + return len; +} + +/** Inclusive interval along the ramp for segment `segmentIndex` of `segmentCount` edges. */ +export function segmentUInterval( + segmentCount: number, + segmentIndex: number, +): [number, number] { + if (segmentCount <= 0) return [0, 0]; + const u0 = segmentIndex / segmentCount; + const u1 = (segmentIndex + 1) / segmentCount; + return [u0, u1]; +} diff --git a/src/modules/journey-style-config.ts b/src/modules/journey-style-config.ts new file mode 100644 index 000000000..43833ce8c --- /dev/null +++ b/src/modules/journey-style-config.ts @@ -0,0 +1,150 @@ +import { interpolateRgbBasis } from "d3"; + +/** Rainbow ramp endpoints used as one continuous gradient sliced per segment. */ +export const JOURNEY_RAINBOW_STOPS = [ + "#e81416", + "#ff7518", + "#ffdc00", + "#32cd32", + "#00bfff", + "#4e529a", + "#70389d", +]; + +/** Default path/arrows color when `data-color-mode` is solid and no `data-solid-stroke`. */ +export const JOURNEY_DEFAULT_SOLID_STROKE = "#5c5c70"; + +/** Single source for Style tab + `readJourneyStyleConfig` (also on `window.Journey.STYLE_DEFAULTS`). */ +export const JOURNEY_STYLE_DEFAULTS = { + lineScreenPx: 6, + waypointRScreenPx: 9, + waypointRingScreenPx: 4.5, + outlineScreenPx: 2, + solidStroke: JOURNEY_DEFAULT_SOLID_STROKE, + waypointFill: "#ffffff", + waypointStroke: "#000000", + outlineColor: "#000000", + /** Gradient picker defaults when `data-rainbow-stops` is unset (match ramp ends). */ + gradientFromHex: "#e81416", + gradientToHex: "#70389d", +} as const; + +export type JourneyColorMode = "rainbow" | "solid"; + +/** Resolved presentation for `#journeys` (from `data-*` + defaults). */ +export interface JourneyStyleConfig { + colorMode: JourneyColorMode; + solidStroke: string; + rainbowStops: readonly string[]; + lineScreenPx: number; + waypointFill: string; + waypointStroke: string; + waypointRScreenPx: number; + waypointRingScreenPx: number; + outlineColor: string; + outlineScreenPx: number; +} + +function clamp(n: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, n)); +} + +const builtinRampInterpolator = interpolateRgbBasis(JOURNEY_RAINBOW_STOPS); + +/** Parse comma-separated hex/color tokens; returns null if fewer than two usable stops. */ +export function parseJourneyRainbowStops(raw: string | null | undefined): string[] | null { + if (raw == null || !String(raw).trim()) return null; + const parts = String(raw) + .split(",") + .map(s => s.trim()) + .filter(Boolean); + return parts.length >= 2 ? parts : null; +} + +/** + * Read journey style from `#journeys` SVG attributes (`data-*`). + * Safe with `null` / missing element (uses {@link JOURNEY_STYLE_DEFAULTS}). + */ +export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { + const get = (name: string): string | null => + el && typeof el.getAttribute === "function" ? el.getAttribute(name) : null; + + const attrPx = (name: string, fallback: number): number => { + const v = Number.parseFloat(get(name) ?? ""); + return Number.isFinite(v) ? v : fallback; + }; + + const modeRaw = (get("data-color-mode") || "rainbow").toLowerCase().trim(); + const colorMode: JourneyColorMode = modeRaw === "solid" ? "solid" : "rainbow"; + + const parsedStops = parseJourneyRainbowStops(get("data-rainbow-stops")); + const rainbowStops = + parsedStops && parsedStops.length >= 2 ? parsedStops : [...JOURNEY_RAINBOW_STOPS]; + + const solidStroke = + get("data-solid-stroke")?.trim() || JOURNEY_STYLE_DEFAULTS.solidStroke; + + const lineScreenPx = clamp( + attrPx("data-line-screen-px", JOURNEY_STYLE_DEFAULTS.lineScreenPx), + 0.5, + 96, + ); + + const waypointFill = + get("data-waypoint-fill")?.trim() || JOURNEY_STYLE_DEFAULTS.waypointFill; + const waypointStroke = + get("data-waypoint-stroke")?.trim() || JOURNEY_STYLE_DEFAULTS.waypointStroke; + + const waypointRScreenPx = clamp( + attrPx("data-waypoint-r-screen-px", JOURNEY_STYLE_DEFAULTS.waypointRScreenPx), + 2, + 120, + ); + + const waypointRingScreenPx = clamp( + attrPx( + "data-waypoint-ring-screen-px", + JOURNEY_STYLE_DEFAULTS.waypointRingScreenPx, + ), + 0, + 48, + ); + + const outlineColor = + get("data-outline-color")?.trim() || JOURNEY_STYLE_DEFAULTS.outlineColor; + + const outlineScreenPx = clamp( + attrPx("data-outline-screen-px", JOURNEY_STYLE_DEFAULTS.outlineScreenPx), + 0, + 32, + ); + + return { + colorMode, + solidStroke, + rainbowStops, + lineScreenPx, + waypointFill, + waypointStroke, + waypointRScreenPx, + waypointRingScreenPx, + outlineColor, + outlineScreenPx, + }; +} + +/** Uniform ramp sampler along one logical journey (same contract as `journeyRampColor`). */ +export function journeyRampSamplerForConfig(cfg: JourneyStyleConfig): (u: number) => string { + if (cfg.colorMode === "solid") { + const c = cfg.solidStroke; + return (_u: number) => c; + } + const stops = cfg.rainbowStops.length >= 2 ? cfg.rainbowStops : JOURNEY_RAINBOW_STOPS; + const interp = interpolateRgbBasis([...stops]); + return (u: number) => interp(Math.max(0, Math.min(1, u))); +} + +/** Parameter `u` in [0, 1] along the whole journey ramp (built-in rainbow). */ +export function journeyRampColor(u: number): string { + return builtinRampInterpolator(Math.max(0, Math.min(1, u))); +} diff --git a/src/renderers/draw-scalebar.ts b/src/renderers/draw-scalebar.ts index 12c46d556..d3a61a69b 100644 --- a/src/renderers/draw-scalebar.ts +++ b/src/renderers/draw-scalebar.ts @@ -14,7 +14,7 @@ declare global { ) => void; } -type ScaleBarSelection = d3.Selection< +type ScaleBarSelection = Selection< SVGGElement, unknown, HTMLElement, diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index efba92b1e..30351e021 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -66,6 +66,6 @@ export interface PackedGraph { markers: any[]; ice: any[]; provinces: Province[]; - /** User journey: ordered burg / marker legs (`stops`). */ + /** Single journey path: ordered burg/marker stops (`stops`); positions resolved from `burgs` / `markers`. */ journey?: PackJourney; } diff --git a/src/types/global.ts b/src/types/global.ts index 479266857..68c028d8b 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -99,11 +99,19 @@ declare global { var createDefaultRuler: () => void; var showStatistics: () => void; var closeDialogs: (except?: string) => void; + var ensureEl: (id: string) => HTMLElement; + /** Journey layer redraw (public/modules/ui/layers.js). */ + var drawJourney: () => void; + var toggleJourney: (event?: Event) => void; + var clearMainTip: () => void; + var restoreDefaultEvents: () => void; + /** Tools / hotkeys entry for journey editor (src/modules/journey-editor.ts). */ + var editJourney: () => void; + var customization: number; var getHeight: (h: number) => string; var getLatitude: (y: number, precision?: number) => number; var getLongitude: (x: number, precision?: number) => number; var getFileName: (name: string) => string; - var customization: number; var speak: (text: string) => void; var uploadFile: ( el: HTMLInputElement, From 411627b98f61f4b201ca64eabbf605d1a20ee823 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 00:05:12 +0200 Subject: [PATCH 18/48] Refactor journey resolution context usage in JourneyDrawModule and journey-editor. Remove deprecated journeyResolutionCtx function and streamline context creation by directly using buildJourneyResolutionContext. Update related logic for improved clarity and maintainability. --- src/modules/journey-draw.ts | 24 ++++++------------------ src/modules/journey-editor.ts | 16 ++-------------- 2 files changed, 8 insertions(+), 32 deletions(-) diff --git a/src/modules/journey-draw.ts b/src/modules/journey-draw.ts index b940b929a..6e2062b68 100644 --- a/src/modules/journey-draw.ts +++ b/src/modules/journey-draw.ts @@ -3,11 +3,7 @@ * exposes Routes-like `window.Journey` API for legacy scripts. */ import type { Selection } from "d3"; -import type { - JourneyResolutionContext, - JourneyResolvedStopEntry, - PackJourney, -} from "./journey-model"; +import type { JourneyResolvedStopEntry, PackJourney } from "./journey-model"; import { buildJourneyResolutionContext, burgJourneyStopRef, @@ -133,10 +129,6 @@ function ensureJourneyOutlineFilter( merge.append("feMergeNode").attr("in", "SourceGraphic"); } -function journeyResolutionCtx(): JourneyResolutionContext { - return buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []); -} - export class JourneyDrawModule { private lastLodTier: number | null = null; @@ -156,7 +148,7 @@ export class JourneyDrawModule { } ensurePackJourneyNormalized(pack); const journeyData = pack.journey as PackJourney; - const resCtx = journeyResolutionCtx(); + const resCtx = buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []); const resolvedStops = journeyResolvedStopEntries(journeyData, resCtx); if (!resolvedStops.length) { this.lastLodTier = null; @@ -340,7 +332,7 @@ export class JourneyDrawModule { ensurePackJourneyNormalized(pack); const points = journeyResolvedCoordinates( pack.journey as PackJourney, - journeyResolutionCtx(), + buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []), ); if (!points.length) return; @@ -428,8 +420,9 @@ export type JourneyGlobalApi = { journeyLegToRefString: typeof journeyLegToRefString; }; -function createJourneyGlobalApi(): JourneyGlobalApi { - return { +if (typeof window !== "undefined") { + window.JourneyDraw = new JourneyDrawModule(); + const journeyApi: JourneyGlobalApi = { STYLE_DEFAULTS: JOURNEY_STYLE_DEFAULTS, ensurePackJourneyNormalized, normalizePackJourney, @@ -442,11 +435,6 @@ function createJourneyGlobalApi(): JourneyGlobalApi { markerJourneyStopRef, journeyLegToRefString, }; -} - -if (typeof window !== "undefined") { - window.JourneyDraw = new JourneyDrawModule(); - const journeyApi = createJourneyGlobalApi(); window.Journey = journeyApi; window.JourneyPack = journeyApi; } diff --git a/src/modules/journey-editor.ts b/src/modules/journey-editor.ts index 729e90cf6..026efaaec 100644 --- a/src/modules/journey-editor.ts +++ b/src/modules/journey-editor.ts @@ -2,7 +2,6 @@ * Journey editor dialog — bundled TS mirroring routes/markers editor patterns. */ import { rn } from "../utils/numberUtils"; -import type { JourneyResolutionContext } from "./journey-model"; import { buildJourneyResolutionContext, burgJourneyStopRef, @@ -24,10 +23,6 @@ function escapeText(s: string): string { .replace(/>/g, ">"); } -function resolutionCtx(): JourneyResolutionContext { - return buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []); -} - function journeyStopSelectOptions(currentRef: string): string { ensurePackJourneyNormalized(pack); let html = ""; @@ -70,19 +65,12 @@ function journeyStopSelectOptions(currentRef: string): string { return html; } -function journeyLegToSelectValue( - leg: import("./journey-model").JourneyStopLeg | null, -): string { - if (!leg) return ""; - return journeyLegToRefString(leg); -} - function journeyRenderStopRows(container: HTMLElement): void { ensurePackJourneyNormalized(pack); const stops = pack.journey!.stops; const rows = stops.length === 0 ? [null] : stops; rows.forEach((leg, i) => { - const currentRef = leg ? journeyLegToSelectValue(leg) : ""; + const currentRef = leg ? journeyLegToRefString(leg) : ""; const showRemove = stops.length > 0; const removeStyle = showRemove ? "" @@ -147,7 +135,7 @@ function journeyEditorRootClick(ev: Event): void { function journeyAppendStopRef(stopRef: string): void { ensurePackJourneyNormalized(pack); - const ctx = resolutionCtx(); + const ctx = buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []); if (!resolveJourneyStopPosition(stopRef, ctx)) return; const leg = journeyRefStringToLeg(stopRef); if (!leg) return; From cf6d2076ed0e35e5d0dbbce931a9a268671109ce Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 00:15:22 +0200 Subject: [PATCH 19/48] Update package versions and dependencies; refactor Playwright tests to use updated imports and improve URL handling. Clean up unused code and enhance SVG parsing logic for better compatibility. --- package-lock.json | 95 ++++++++++++++++++++------------- package.json | 7 +-- playwright.config.ts | 23 ++------ public/modules/io/load.js | 9 ++-- public/modules/ui/general.js | 3 +- public/modules/ui/layers.js | 16 ++---- src/renderers/draw-scalebar.ts | 2 +- tests/e2e/burgs.spec.ts | 6 +-- tests/e2e/fixtures.ts | 8 --- tests/e2e/journey-layer.spec.ts | 6 +-- tests/e2e/lakes-layer.spec.ts | 6 +-- tests/e2e/layers.spec.ts | 6 +-- tests/e2e/load-map.spec.ts | 4 +- tests/e2e/states.spec.ts | 6 +-- tests/e2e/zones-export.spec.ts | 6 +-- 15 files changed, 95 insertions(+), 108 deletions(-) delete mode 100644 tests/e2e/fixtures.ts diff --git a/package-lock.json b/package-lock.json index a00e2bf5e..82f789204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,23 @@ { "name": "fantasy-map-generator", - "version": "1.120.5", + "version": "1.122.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fantasy-map-generator", - "version": "1.120.5", + "version": "1.122.0", "license": "MIT", "dependencies": { "alea": "^1.0.1", "d3": "^7.9.0", "delaunator": "^5.0.1", + "driver.js": "^1.4.0", "lineclip": "^2.0.0", "polylabel": "^2.0.1" }, "devDependencies": { - "@biomejs/biome": "2.4.6", + "@biomejs/biome": "^2.4.13", "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", @@ -35,9 +36,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.6.tgz", - "integrity": "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz", + "integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -51,20 +52,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.6", - "@biomejs/cli-darwin-x64": "2.4.6", - "@biomejs/cli-linux-arm64": "2.4.6", - "@biomejs/cli-linux-arm64-musl": "2.4.6", - "@biomejs/cli-linux-x64": "2.4.6", - "@biomejs/cli-linux-x64-musl": "2.4.6", - "@biomejs/cli-win32-arm64": "2.4.6", - "@biomejs/cli-win32-x64": "2.4.6" + "@biomejs/cli-darwin-arm64": "2.4.13", + "@biomejs/cli-darwin-x64": "2.4.13", + "@biomejs/cli-linux-arm64": "2.4.13", + "@biomejs/cli-linux-arm64-musl": "2.4.13", + "@biomejs/cli-linux-x64": "2.4.13", + "@biomejs/cli-linux-x64-musl": "2.4.13", + "@biomejs/cli-win32-arm64": "2.4.13", + "@biomejs/cli-win32-x64": "2.4.13" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.6.tgz", - "integrity": "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz", + "integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==", "cpu": [ "arm64" ], @@ -79,9 +80,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.6.tgz", - "integrity": "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.13.tgz", + "integrity": "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==", "cpu": [ "x64" ], @@ -96,13 +97,16 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.6.tgz", - "integrity": "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz", + "integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -113,13 +117,16 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.6.tgz", - "integrity": "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.13.tgz", + "integrity": "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -130,13 +137,16 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.6.tgz", - "integrity": "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz", + "integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -147,13 +157,16 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.6.tgz", - "integrity": "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.13.tgz", + "integrity": "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -164,9 +177,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.6.tgz", - "integrity": "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.13.tgz", + "integrity": "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==", "cpu": [ "arm64" ], @@ -181,9 +194,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.6.tgz", - "integrity": "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==", + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz", + "integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==", "cpu": [ "x64" ], @@ -1992,6 +2005,12 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/driver.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", + "integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==", + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", diff --git a/package.json b/package.json index e2135feef..4bdc86ae7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fantasy-map-generator", - "version": "1.120.5", + "version": "1.122.0", "description": "Azgaar's _Fantasy Map Generator_ is a free web application that helps fantasy writers, game masters, and cartographers create and edit fantasy maps.", "homepage": "https://github.com/Azgaar/Fantasy-Map-Generator#readme", "bugs": { @@ -24,7 +24,7 @@ "format": "biome format --write" }, "devDependencies": { - "@biomejs/biome": "2.4.6", + "@biomejs/biome": "^2.4.6", "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", @@ -42,10 +42,11 @@ "alea": "^1.0.1", "d3": "^7.9.0", "delaunator": "^5.0.1", + "driver.js": "^1.4.0", "lineclip": "^2.0.0", "polylabel": "^2.0.1" }, "engines": { "node": ">=24.0.0" } -} \ No newline at end of file +} diff --git a/playwright.config.ts b/playwright.config.ts index e576ee2b4..5b73f1b59 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,17 +3,6 @@ import { defineConfig, devices } from '@playwright/test' const isCI = !!process.env.CI const skipBuild = !!process.env.SKIP_BUILD -/** Dedicated port avoids reuseExistingServer attaching to another project on the default Vite port. */ -const devPort = process.env.PLAYWRIGHT_DEV_PORT ?? '5199' -const devOrigin = `http://127.0.0.1:${devPort}` -const previewOrigin = 'http://127.0.0.1:4173' -/** Matches vite.config.ts base (NETLIFY uses '/' for deploy previews). */ -const appPath = process.env.NETLIFY ? '' : '/Fantasy-Map-Generator' -/** Trailing slash required so relative navigations resolve under the app path. */ -const baseURL = appPath - ? `${isCI ? previewOrigin : devOrigin}${appPath}/` - : `${isCI ? previewOrigin : devOrigin}/` - export default defineConfig({ testDir: './tests/e2e', fullyParallel: true, @@ -21,10 +10,10 @@ export default defineConfig({ retries: 0, workers: isCI ? 2 : undefined, reporter: 'html', - // Keeps toMatchSnapshot('_layer_.html') paths stable (no browser/OS suffix in filename). + // Use OS-independent snapshot names (HTML content is the same across platforms) snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}', use: { - baseURL, + baseURL: isCI ? 'http://localhost:4173' : 'http://localhost:5173', trace: 'on-first-retry', // Fixed viewport to ensure consistent map rendering viewport: { width: 1280, height: 720 }, @@ -38,12 +27,8 @@ export default defineConfig({ webServer: { // In CI: build (done as a separate cached step) and preview for production-like testing // In dev: use vite dev server (faster, no rebuild needed) - command: isCI - ? skipBuild - ? 'npm run preview -- --host 127.0.0.1' - : 'npm run build && npm run preview -- --host 127.0.0.1' - : `npm run dev -- --host 127.0.0.1 --port ${devPort}`, - url: baseURL, + command: isCI ? (skipBuild ? 'npm run preview' : 'npm run build && npm run preview') : 'npm run dev', + url: isCI ? 'http://localhost:4173' : 'http://localhost:5173', reuseExistingServer: !isCI, timeout: 120000, }, diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 46bb55964..d2ea4a537 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -164,10 +164,11 @@ async function parseLoadedResult(result) { const isDelimited = resultAsString.substring(0, 10).includes("|"); let content = isDelimited ? resultAsString : decodeURIComponent(atob(resultAsString)); - // fix if svg part has CRLF line endings instead of LF (some exports / old saves differ) - const svgMatch = content.match(/]*id="map"[\s\S]*?<\/svg>/i); - const svgContent = svgMatch?.[0]; - if (svgContent?.includes("\r\n")) { + // fix if svg part has CRLF line endings instead of LF + const svgMatch = content.match(/]*id="map"[\s\S]*?<\/svg>/); + const svgContent = svgMatch[0]; + const hasCrlfEndings = svgContent.includes("\r\n"); + if (hasCrlfEndings) { const correctedSvgContent = svgContent.replace(/\r\n/g, "\n"); content = content.replace(svgContent, correctedSvgContent); } diff --git a/public/modules/ui/general.js b/public/modules/ui/general.js index 5e26b0a40..65bfbfd12 100644 --- a/public/modules/ui/general.js +++ b/public/modules/ui/general.js @@ -55,8 +55,7 @@ function showDataTip(event) { if (!event.target) return; let dataTip = event.target.dataset.tip; - if (!dataTip && event.target.parentNode?.dataset?.tip) - dataTip = event.target.parentNode.dataset.tip; + if (!dataTip && event.target.parentNode.dataset.tip) dataTip = event.target.parentNode.dataset.tip; if (!dataTip) return; const shortcut = event.target.dataset.shortcut; diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 2a586f23a..5ca66219b 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -4,14 +4,6 @@ let presets = {}; // global object restoreCustomPresets(); // run on-load -/** Layer id list for a preset key (built-in default vs stored custom array). */ -function layersArrayForPresetKey(presetKey) { - const raw = presets[presetKey]; - return Array.isArray(raw) - ? raw - : getDefaultPresets()[presetKey] || getDefaultPresets().political; -} - function getDefaultPresets() { return { political: [ @@ -133,7 +125,7 @@ function applyLayersPreset() { const preset = localStorage.getItem("preset") || ensureEl("layersPreset").value; setLayersPreset(preset); - const layers = layersArrayForPresetKey(preset); + const layers = presets[preset]; // layers to be turned on document.querySelectorAll("#mapLayers > li").forEach(el => { const shouldBeOn = layers.includes(el.id); if (shouldBeOn) el.classList.remove("buttonoff"); @@ -154,7 +146,7 @@ function setLayersPreset(preset) { function handleLayersPresetChange(preset) { setLayersPreset(preset); - const layers = layersArrayForPresetKey(preset); + const layers = presets[preset]; // layers to be turned on document.querySelectorAll("#mapLayers > li").forEach(el => { const isOn = layerIsOn(el.id); const shouldBeOn = layers.includes(el.id); @@ -197,9 +189,7 @@ function getCurrentPreset() { .sort(); for (const preset in presets) { - const def = presets[preset]; - if (!Array.isArray(def)) continue; - if (JSON.stringify([...def].sort()) === JSON.stringify(layers)) { + if (JSON.stringify(presets[preset].sort()) === JSON.stringify(layers)) { layersPreset.value = preset; const isDefault = getDefaultPresets()[preset]; removePresetButton.style.display = isDefault ? "none" : "inline-block"; diff --git a/src/renderers/draw-scalebar.ts b/src/renderers/draw-scalebar.ts index d3a61a69b..12c46d556 100644 --- a/src/renderers/draw-scalebar.ts +++ b/src/renderers/draw-scalebar.ts @@ -14,7 +14,7 @@ declare global { ) => void; } -type ScaleBarSelection = Selection< +type ScaleBarSelection = d3.Selection< SVGGElement, unknown, HTMLElement, diff --git a/tests/e2e/burgs.spec.ts b/tests/e2e/burgs.spec.ts index 94b7f6132..94c24c27f 100644 --- a/tests/e2e/burgs.spec.ts +++ b/tests/e2e/burgs.spec.ts @@ -1,17 +1,17 @@ -import { expect, test } from "./fixtures"; +import { expect, test } from "@playwright/test"; test.describe("Burgs.add", () => { test.beforeEach(async ({ context, page }) => { await context.clearCookies(); - await page.goto(""); + await page.goto("/"); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); // Navigate with seed parameter and wait for full load - await page.goto("?seed=test-burgs&width=1280&height=720"); + await page.goto("/?seed=test-burgs&width=1280&height=720"); // Wait for map generation to complete await page.waitForFunction( diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts deleted file mode 100644 index 20d7c17cf..000000000 --- a/tests/e2e/fixtures.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Shared Playwright `test` / `expect` instance for e2e specs. - */ -import { expect, test as base } from "@playwright/test"; - -export const test = base; - -export { expect }; diff --git a/tests/e2e/journey-layer.spec.ts b/tests/e2e/journey-layer.spec.ts index d19327727..a3b72c1b2 100644 --- a/tests/e2e/journey-layer.spec.ts +++ b/tests/e2e/journey-layer.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "./fixtures"; +import { expect, test } from "@playwright/test"; /** Rich path: multiple stops, triple A↔B repetition (directed chord reuse + lanes), then a branch. */ const BACKTRACK_JOURNEY_POINTS: [number, number][] = [ @@ -36,13 +36,13 @@ test.describe("Journey layer", () => { test.beforeEach(async ({ context, page }) => { await context.clearCookies(); - await page.goto(""); + await page.goto("/"); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); - await page.goto("?seed=test-seed&width=1280&height=720"); + await page.goto("/?seed=test-seed&width=1280&height=720"); await page.waitForFunction(() => (window as unknown as { mapId?: unknown }).mapId !== undefined, { timeout: 60000, diff --git a/tests/e2e/lakes-layer.spec.ts b/tests/e2e/lakes-layer.spec.ts index 63c0f8f42..58ebb39f5 100644 --- a/tests/e2e/lakes-layer.spec.ts +++ b/tests/e2e/lakes-layer.spec.ts @@ -1,16 +1,16 @@ -import { expect, test } from "./fixtures"; +import { expect, test } from "@playwright/test"; test.describe("Lakes layer", () => { test.beforeEach(async ({ context, page }) => { await context.clearCookies(); - await page.goto(""); + await page.goto("/"); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); - await page.goto("?seed=test-seed&width=1280&height=720"); + await page.goto("/?seed=test-seed&width=1280&height=720"); // Wait for map generation to complete await page.waitForFunction(() => (window as any).mapId !== undefined, { diff --git a/tests/e2e/layers.spec.ts b/tests/e2e/layers.spec.ts index 12172105a..8afd24fbc 100644 --- a/tests/e2e/layers.spec.ts +++ b/tests/e2e/layers.spec.ts @@ -1,5 +1,5 @@ import type { Browser, BrowserContext, Page } from '@playwright/test' -import { expect, test } from './fixtures' +import { expect, test } from '@playwright/test' // All tests in this describe block only READ the DOM — they never modify state. // Load the map once for the entire suite instead of before every test. @@ -14,14 +14,14 @@ test.describe('map layers', () => { sharedPage = await sharedContext.newPage() await sharedContext.clearCookies() - await sharedPage.goto('') + await sharedPage.goto('/') await sharedPage.evaluate(() => { localStorage.clear() sessionStorage.clear() }) // Fixed seed keeps SVG/HTML snapshots in layers tests stable across runs. - await sharedPage.goto('?seed=test-seed&width=1280&height=720') + await sharedPage.goto('/?seed=test-seed&width=1280&height=720') // Wait for map generation to complete by checking window.mapId // mapId is exposed on window at the very end of showStatistics() diff --git a/tests/e2e/load-map.spec.ts b/tests/e2e/load-map.spec.ts index 4e5a9dbea..35feab4c4 100644 --- a/tests/e2e/load-map.spec.ts +++ b/tests/e2e/load-map.spec.ts @@ -1,11 +1,11 @@ -import { expect, test } from "./fixtures"; +import { expect, test } from "@playwright/test"; import path from "path"; test.describe("Map loading", () => { test.beforeEach(async ({ context, page }) => { await context.clearCookies(); - await page.goto(""); + await page.goto("/"); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); diff --git a/tests/e2e/states.spec.ts b/tests/e2e/states.spec.ts index 4f914e841..7681e27fc 100644 --- a/tests/e2e/states.spec.ts +++ b/tests/e2e/states.spec.ts @@ -1,17 +1,17 @@ -import { expect, test } from "./fixtures"; +import { expect, test } from "@playwright/test"; test.describe("States", () => { test.beforeEach(async ({context, page}) => { await context.clearCookies(); - await page.goto(""); + await page.goto("/"); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); // Navigate with seed parameter and wait for full load - await page.goto("?seed=test-states&width=1280&height=720"); + await page.goto("/?seed=test-states&width=1280&height=720"); // Wait for map generation to complete await page.waitForFunction(() => (window as any).mapId !== undefined, {timeout: 60000}); diff --git a/tests/e2e/zones-export.spec.ts b/tests/e2e/zones-export.spec.ts index 9a301f337..bd66fdcc2 100644 --- a/tests/e2e/zones-export.spec.ts +++ b/tests/e2e/zones-export.spec.ts @@ -1,17 +1,17 @@ -import { expect, test } from "./fixtures"; +import { expect, test } from "@playwright/test"; test.describe("Zone Export", () => { test.beforeEach(async ({ context, page }) => { await context.clearCookies(); - await page.goto(""); + await page.goto("/"); await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); // Navigate with seed parameter and wait for full load - await page.goto("?seed=test-zones-export&width=1280&height=720"); + await page.goto("/?seed=test-zones-export&width=1280&height=720"); // Wait for map generation to complete await page.waitForFunction( From 38ff8ea34bd49df520e55218b1224842b17000dd Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 00:19:06 +0200 Subject: [PATCH 20/48] Discard changes to package.json --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 4bdc86ae7..578ade425 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fantasy-map-generator", - "version": "1.122.0", + "version": "1.120.5", "description": "Azgaar's _Fantasy Map Generator_ is a free web application that helps fantasy writers, game masters, and cartographers create and edit fantasy maps.", "homepage": "https://github.com/Azgaar/Fantasy-Map-Generator#readme", "bugs": { @@ -24,7 +24,7 @@ "format": "biome format --write" }, "devDependencies": { - "@biomejs/biome": "^2.4.6", + "@biomejs/biome": "2.4.6", "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", @@ -42,7 +42,6 @@ "alea": "^1.0.1", "d3": "^7.9.0", "delaunator": "^5.0.1", - "driver.js": "^1.4.0", "lineclip": "^2.0.0", "polylabel": "^2.0.1" }, From 7090aad439fcf1ff466d1def8ba74f3a771bcc99 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 00:20:25 +0200 Subject: [PATCH 21/48] Discard changes to tests/e2e/burgs.spec.ts --- tests/e2e/burgs.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/burgs.spec.ts b/tests/e2e/burgs.spec.ts index 94c24c27f..f78bc38f7 100644 --- a/tests/e2e/burgs.spec.ts +++ b/tests/e2e/burgs.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { test, expect } from "@playwright/test"; test.describe("Burgs.add", () => { test.beforeEach(async ({ context, page }) => { From 9728b6a491b3b5cadaa4d3d91b6eae7ef02dacaa Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 00:20:36 +0200 Subject: [PATCH 22/48] Discard changes to tests/e2e/layers.spec.ts --- tests/e2e/layers.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e/layers.spec.ts b/tests/e2e/layers.spec.ts index 8afd24fbc..dd69cae1a 100644 --- a/tests/e2e/layers.spec.ts +++ b/tests/e2e/layers.spec.ts @@ -1,5 +1,4 @@ -import type { Browser, BrowserContext, Page } from '@playwright/test' -import { expect, test } from '@playwright/test' +import { Browser, BrowserContext, expect, Page, test } from '@playwright/test' // All tests in this describe block only READ the DOM — they never modify state. // Load the map once for the entire suite instead of before every test. @@ -7,8 +6,6 @@ let sharedContext: BrowserContext let sharedPage: Page test.describe('map layers', () => { - test.describe.configure({ mode: 'serial' }) - test.beforeAll(async ({ browser }: { browser: Browser }) => { sharedContext = await browser.newContext() sharedPage = await sharedContext.newPage() @@ -20,8 +17,11 @@ test.describe('map layers', () => { sessionStorage.clear() }) - // Fixed seed keeps SVG/HTML snapshots in layers tests stable across runs. - await sharedPage.goto('/?seed=test-seed&width=1280&height=720') + // Navigate with seed parameter and wait for full load + // NOTE: + // - We use a fixed seed ("test-seed") to make map generation deterministic for snapshot tests. + // - Snapshots are OS-independent (configured in playwright.config.ts). + await sharedPage.goto('/?seed=test-seed&&width=1280&height=720') // Wait for map generation to complete by checking window.mapId // mapId is exposed on window at the very end of showStatistics() From 2e5294011f60078d8783c8a03c1e576e20b60f9d Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 00:20:52 +0200 Subject: [PATCH 23/48] Discard changes to tests/e2e/lakes-layer.spec.ts --- tests/e2e/lakes-layer.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/lakes-layer.spec.ts b/tests/e2e/lakes-layer.spec.ts index 58ebb39f5..0d9a19102 100644 --- a/tests/e2e/lakes-layer.spec.ts +++ b/tests/e2e/lakes-layer.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { test, expect } from "@playwright/test"; test.describe("Lakes layer", () => { test.beforeEach(async ({ context, page }) => { From eb15e8a38c83b229a014518c734913e1f4450834 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 00:20:59 +0200 Subject: [PATCH 24/48] Discard changes to tests/e2e/load-map.spec.ts --- tests/e2e/load-map.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/load-map.spec.ts b/tests/e2e/load-map.spec.ts index 35feab4c4..c135f188c 100644 --- a/tests/e2e/load-map.spec.ts +++ b/tests/e2e/load-map.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { test, expect } from "@playwright/test"; import path from "path"; test.describe("Map loading", () => { From 45d5f4b2d5f1aab2a623cae6ee40ed24e40616d2 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 00:21:08 +0200 Subject: [PATCH 25/48] Discard changes to tests/e2e/states.spec.ts --- tests/e2e/states.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/states.spec.ts b/tests/e2e/states.spec.ts index 7681e27fc..2cba3dcf5 100644 --- a/tests/e2e/states.spec.ts +++ b/tests/e2e/states.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import {test, expect} from "@playwright/test"; test.describe("States", () => { test.beforeEach(async ({context, page}) => { From 10cb52674eacade9f4017ac0ad4ce00c48fd3f0e Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 00:21:17 +0200 Subject: [PATCH 26/48] Discard changes to tests/e2e/zones-export.spec.ts --- tests/e2e/zones-export.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/zones-export.spec.ts b/tests/e2e/zones-export.spec.ts index bd66fdcc2..b2a8356df 100644 --- a/tests/e2e/zones-export.spec.ts +++ b/tests/e2e/zones-export.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { test, expect } from "@playwright/test"; test.describe("Zone Export", () => { test.beforeEach(async ({ context, page }) => { From e2e51db84132cac653615e2535280d27f100b6f9 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 00:22:37 +0200 Subject: [PATCH 27/48] Discard changes to package-lock.json --- package-lock.json | 101 ++++++++++++++++++++-------------------------- 1 file changed, 44 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index 82f789204..d3eb6917a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,22 @@ { "name": "fantasy-map-generator", - "version": "1.122.0", + "version": "1.120.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fantasy-map-generator", - "version": "1.122.0", + "version": "1.120.5", "license": "MIT", "dependencies": { "alea": "^1.0.1", "d3": "^7.9.0", "delaunator": "^5.0.1", - "driver.js": "^1.4.0", "lineclip": "^2.0.0", "polylabel": "^2.0.1" }, "devDependencies": { - "@biomejs/biome": "^2.4.13", + "@biomejs/biome": "2.4.6", "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", @@ -36,9 +35,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz", - "integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.6.tgz", + "integrity": "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -52,20 +51,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.13", - "@biomejs/cli-darwin-x64": "2.4.13", - "@biomejs/cli-linux-arm64": "2.4.13", - "@biomejs/cli-linux-arm64-musl": "2.4.13", - "@biomejs/cli-linux-x64": "2.4.13", - "@biomejs/cli-linux-x64-musl": "2.4.13", - "@biomejs/cli-win32-arm64": "2.4.13", - "@biomejs/cli-win32-x64": "2.4.13" + "@biomejs/cli-darwin-arm64": "2.4.6", + "@biomejs/cli-darwin-x64": "2.4.6", + "@biomejs/cli-linux-arm64": "2.4.6", + "@biomejs/cli-linux-arm64-musl": "2.4.6", + "@biomejs/cli-linux-x64": "2.4.6", + "@biomejs/cli-linux-x64-musl": "2.4.6", + "@biomejs/cli-win32-arm64": "2.4.6", + "@biomejs/cli-win32-x64": "2.4.6" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz", - "integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.6.tgz", + "integrity": "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==", "cpu": [ "arm64" ], @@ -80,9 +79,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.13.tgz", - "integrity": "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.6.tgz", + "integrity": "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==", "cpu": [ "x64" ], @@ -97,16 +96,13 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz", - "integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.6.tgz", + "integrity": "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -117,16 +113,13 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.13.tgz", - "integrity": "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.6.tgz", + "integrity": "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -137,16 +130,13 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz", - "integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.6.tgz", + "integrity": "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -157,16 +147,13 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.13.tgz", - "integrity": "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.6.tgz", + "integrity": "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -177,9 +164,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.13.tgz", - "integrity": "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.6.tgz", + "integrity": "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==", "cpu": [ "arm64" ], @@ -194,9 +181,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz", - "integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.6.tgz", + "integrity": "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==", "cpu": [ "x64" ], @@ -1382,6 +1369,7 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1422,6 +1410,7 @@ "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/browser": "4.1.5", "@vitest/mocker": "4.1.5", @@ -1912,6 +1901,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -2005,12 +1995,6 @@ "robust-predicates": "^3.0.2" } }, - "node_modules/driver.js": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", - "integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==", - "license": "MIT" - }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -2210,6 +2194,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2223,6 +2208,7 @@ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.59.1" }, @@ -2583,6 +2569,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", From f9259d3e4c34a4ba6ee445ddb84b44707259c253 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 09:23:03 +0200 Subject: [PATCH 28/48] Refactor journey normalization logic to prioritize `stops[]` over legacy `stopIds` and `waypoints`. Update tests to reflect changes in behavior, ensuring that legacy identifiers are no longer inferred and are removed from the journey object. Clean up unused functions related to legacy stop handling. --- src/modules/journey-model.test.ts | 12 +++--- src/modules/journey-model.ts | 72 +++++++------------------------ 2 files changed, 22 insertions(+), 62 deletions(-) diff --git a/src/modules/journey-model.test.ts b/src/modules/journey-model.test.ts index 14bb9c327..f3ebc3262 100644 --- a/src/modules/journey-model.test.ts +++ b/src/modules/journey-model.test.ts @@ -55,16 +55,15 @@ describe("normalizePackJourney", () => { expect(j.stops[0]).toEqual({ kind: "burg", id: 1 }); }); - it("migrates legacy stopIds burg/marker strings only", () => { + it("does not infer stops from legacy stopIds (only stops[] is authoritative)", () => { const j: Record = { stopIds: ["wp_skip", burgJourneyStopRef(3), markerJourneyStopRef(7)], waypoints: [{ id: "wp_skip", name: "A", x: 1, y: 2 }], }; normalizePackJourney(j); - expect((j as unknown as PackJourney).stops).toEqual([ - { kind: "burg", id: 3 }, - { kind: "marker", id: 7 }, - ]); + expect((j as unknown as PackJourney).stops).toEqual([]); + expect(j.stopIds).toBeUndefined(); + expect(j.waypoints).toBeUndefined(); }); it("drops legs when pack says missing burg/marker", () => { @@ -81,13 +80,14 @@ describe("normalizePackJourney", () => { expect(j.stops).toEqual([{ kind: "burg", id: 5 }]); }); - it("prefers stops[] over legacy stopIds when both present", () => { + it("keeps stops[] when stray stopIds also present", () => { const j: Record = { stops: [{ kind: "marker" as const, id: 1 }], stopIds: [burgJourneyStopRef(9)], }; normalizePackJourney(j); expect((j as unknown as PackJourney).stops).toEqual([{ kind: "marker", id: 1 }]); + expect(j.stopIds).toBeUndefined(); }); }); diff --git a/src/modules/journey-model.ts b/src/modules/journey-model.ts index 1d92d6081..67ccd2b9a 100644 --- a/src/modules/journey-model.ts +++ b/src/modules/journey-model.ts @@ -1,6 +1,5 @@ /** - * Journey path: ordered burg / marker references only (positions live on pack). - * Legacy `{ stopIds, waypoints }` is migrated on normalize (waypoint legs dropped). + * Journey path: ordered burg / marker legs (`stops`); coordinates resolved from `pack`. */ /** One leg in the journey sequence (linked-list style via array order). */ @@ -138,47 +137,6 @@ function sanitizeStopsArray(raw: unknown[]): JourneyStopLeg[] { return out; } -function legacyStopIdsToLegs(stopIds: string[]): JourneyStopLeg[] { - const out: JourneyStopLeg[] = []; - for (const sid of stopIds) { - const p = parseJourneyStopRef(sid); - if (!p) continue; - out.push( - p.kind === "burg" - ? { kind: "burg", id: p.id } - : { kind: "marker", id: p.id }, - ); - } - return out; -} - -function burgExistsInPack( - pack: JourneyNormalizePackContext, - id: number, -): boolean { - const burgs = pack.burgs; - if (!Array.isArray(burgs)) return false; - return burgs.some((b) => b.i === id && !b.removed); -} - -function markerExistsInPack(pack: JourneyNormalizePackContext, id: number): boolean { - const markers = pack.markers; - if (!Array.isArray(markers)) return false; - return markers.some((m) => m.i === id); -} - -function legIsAllowed( - leg: JourneyStopLeg, - pack?: JourneyNormalizePackContext, -): boolean { - if (leg.kind === "burg") { - if (!pack) return true; - return burgExistsInPack(pack, leg.id); - } - if (!pack) return true; - return markerExistsInPack(pack, leg.id); -} - /** Pack slice used when ensuring `pack.journey` exists and is sanitized. */ export type PackWithOptionalJourney = JourneyNormalizePackContext & { journey?: unknown; @@ -200,9 +158,8 @@ export function ensurePackJourneyNormalized(pack: PackWithOptionalJourney): void } /** - * Mutates journey object into canonical `{ stops }`. - * Also strips stray keys (`points`, old `stopIds` / `waypoints`) from edited or - * hand-merged JSON so draw/load never sees invalid shapes. + * Mutates journey object into canonical `{ stops }` only. + * Drops unknown keys (`points`, `stopIds`, `waypoints`, …) from edited or merged JSON. */ export function normalizePackJourney( j: unknown, @@ -216,14 +173,17 @@ export function normalizePackJourney( Array.isArray(obj.stops) ? (obj.stops as unknown[]) : [], ); - if (!stops.length && Array.isArray(obj.stopIds)) { - const ids = (obj.stopIds as unknown[]).filter( - (id): id is string => typeof id === "string", - ); - stops = legacyStopIdsToLegs(ids); - } - - stops = stops.filter((leg) => legIsAllowed(leg, pack)); + stops = stops.filter((leg) => { + if (!pack) return true; + if (leg.kind === "burg") { + const burgs = pack.burgs; + if (!Array.isArray(burgs)) return false; + return burgs.some((b) => b.i === leg.id && !b.removed); + } + const markers = pack.markers; + if (!Array.isArray(markers)) return false; + return markers.some((m) => m.i === leg.id); + }); delete obj.points; delete obj.stopIds; @@ -319,7 +279,7 @@ export function journeyResolvedCoordinates( return out; } -/** Ref strings for legs in the journey (for vertex hints). */ -export function referencedStopIds(j: PackJourney): Set { +/** `burg:n` / `marker:n` ref strings for current legs (e.g. vertex attribution). */ +export function referencedJourneyStopRefs(j: PackJourney): Set { return new Set(j.stops.map(journeyLegToRefString)); } From 9242292b5ab6fd847b3bda623138021e0e81b5fc Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 11:49:29 +0200 Subject: [PATCH 29/48] Update package-lock.json and package.json; refactor journey model to remove legacy stop handling and simplify normalization logic. Adjust tests to reflect changes in journey behavior. --- package-lock.json | 14 +------ package.json | 2 +- src/modules/journey-draw.ts | 30 +------------- src/modules/journey-model.test.ts | 65 +++++++++++++++---------------- src/modules/journey-model.ts | 50 +++--------------------- tests/e2e/journey-layer.spec.ts | 6 +-- 6 files changed, 42 insertions(+), 125 deletions(-) diff --git a/package-lock.json b/package-lock.json index 82f789204..75ad21d92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "polylabel": "^2.0.1" }, "devDependencies": { - "@biomejs/biome": "^2.4.13", + "@biomejs/biome": "^2.4.6", "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", @@ -104,9 +104,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -124,9 +121,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -144,9 +138,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -164,9 +155,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 4bdc86ae7..b2918f6b0 100644 --- a/package.json +++ b/package.json @@ -49,4 +49,4 @@ "engines": { "node": ">=24.0.0" } -} +} \ No newline at end of file diff --git a/src/modules/journey-draw.ts b/src/modules/journey-draw.ts index 6e2062b68..c07fd4974 100644 --- a/src/modules/journey-draw.ts +++ b/src/modules/journey-draw.ts @@ -1,22 +1,14 @@ /** - * Journey SVG rendering (#journeys): delegates geometry/style to sibling modules; - * exposes Routes-like `window.Journey` API for legacy scripts. + * Journey SVG rendering (#journeys): delegates geometry/style to sibling modules. */ import type { Selection } from "d3"; import type { JourneyResolvedStopEntry, PackJourney } from "./journey-model"; import { buildJourneyResolutionContext, - burgJourneyStopRef, - emptyPackJourney, ensurePackJourneyNormalized, journeyLegToRefString, - journeyRefStringToLeg, journeyResolvedCoordinates, journeyResolvedStopEntries, - markerJourneyStopRef, - normalizePackJourney, - resolveJourneyLeg, - resolveJourneyStopPosition, } from "./journey-model"; import { arrowPositionsAlongPolyline, @@ -405,19 +397,10 @@ export class JourneyDrawModule { } } -/** Routes-like facade: pack helpers + style defaults for legacy UI scripts. */ +/** Minimal facade consumed by legacy JS modules. */ export type JourneyGlobalApi = { STYLE_DEFAULTS: typeof JOURNEY_STYLE_DEFAULTS; ensurePackJourneyNormalized: typeof ensurePackJourneyNormalized; - normalizePackJourney: typeof normalizePackJourney; - journeyResolvedCoordinates: typeof journeyResolvedCoordinates; - resolveJourneyStopPosition: typeof resolveJourneyStopPosition; - resolveJourneyLeg: typeof resolveJourneyLeg; - journeyRefStringToLeg: typeof journeyRefStringToLeg; - emptyPackJourney: typeof emptyPackJourney; - burgJourneyStopRef: typeof burgJourneyStopRef; - markerJourneyStopRef: typeof markerJourneyStopRef; - journeyLegToRefString: typeof journeyLegToRefString; }; if (typeof window !== "undefined") { @@ -425,15 +408,6 @@ if (typeof window !== "undefined") { const journeyApi: JourneyGlobalApi = { STYLE_DEFAULTS: JOURNEY_STYLE_DEFAULTS, ensurePackJourneyNormalized, - normalizePackJourney, - journeyResolvedCoordinates, - resolveJourneyStopPosition, - resolveJourneyLeg, - journeyRefStringToLeg, - emptyPackJourney, - burgJourneyStopRef, - markerJourneyStopRef, - journeyLegToRefString, }; window.Journey = journeyApi; window.JourneyPack = journeyApi; diff --git a/src/modules/journey-model.test.ts b/src/modules/journey-model.test.ts index f3ebc3262..e9744cc4b 100644 --- a/src/modules/journey-model.test.ts +++ b/src/modules/journey-model.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildJourneyResolutionContext, - burgJourneyStopRef, ensurePackJourneyNormalized, journeyLegToRefString, journeyRefStringToLeg, @@ -9,7 +8,6 @@ import { journeyResolvedStopEntries, markerJourneyStopRef, normalizePackJourney, - parseJourneyStopRef, resolveJourneyLeg, resolveJourneyStopPosition, type JourneyResolutionContext, @@ -20,82 +18,81 @@ import { describe("ensurePackJourneyNormalized", () => { it("creates pack.journey when absent and normalizes", () => { - const pack: PackWithOptionalJourney = { burgs: [], markers: [] }; + const pack: PackWithOptionalJourney = {}; ensurePackJourneyNormalized(pack); expect(pack.journey).toEqual({ stops: [] }); }); }); describe("normalizePackJourney", () => { - it("strips stray points/stopIds/waypoints and yields empty stops", () => { + it("keeps unknown keys and yields empty stops when stops[] is absent", () => { const j: Record = { points: [[10, 20], [30, 40]], stopIds: [], waypoints: [], }; normalizePackJourney(j); - expect(j.points).toBeUndefined(); - expect(j.stopIds).toBeUndefined(); - expect(j.waypoints).toBeUndefined(); + expect(j.points).toEqual([[10, 20], [30, 40]]); + expect(j.stopIds).toEqual([]); + expect(j.waypoints).toEqual([]); const normalized = j as unknown as PackJourney; expect(normalized.stops).toEqual([]); expect(journeyResolvedCoordinates(normalized)).toEqual([]); }); - it("keeps stops array and strips legacy keys", () => { + it("uses only stops[] as source of truth", () => { const j = { stops: [{ kind: "burg" as const, id: 1 }], stopIds: ["burg:99"], waypoints: [{ id: "x" }], }; normalizePackJourney(j); - expect(j.stopIds).toBeUndefined(); - expect(j.waypoints).toBeUndefined(); expect(j.stops.length).toBe(1); expect(j.stops[0]).toEqual({ kind: "burg", id: 1 }); + expect(j.stopIds).toEqual(["burg:99"]); + expect(j.waypoints).toEqual([{ id: "x" }]); }); - it("does not infer stops from legacy stopIds (only stops[] is authoritative)", () => { + it("does not infer stops from stopIds / waypoints", () => { const j: Record = { - stopIds: ["wp_skip", burgJourneyStopRef(3), markerJourneyStopRef(7)], + stopIds: ["wp_skip", "burg:3", "marker:7"], waypoints: [{ id: "wp_skip", name: "A", x: 1, y: 2 }], }; normalizePackJourney(j); expect((j as unknown as PackJourney).stops).toEqual([]); - expect(j.stopIds).toBeUndefined(); - expect(j.waypoints).toBeUndefined(); + expect(j.stopIds).toEqual(["wp_skip", "burg:3", "marker:7"]); + expect(j.waypoints).toEqual([{ id: "wp_skip", name: "A", x: 1, y: 2 }]); }); - it("drops legs when pack says missing burg/marker", () => { - const j: PackJourney = { + it("filters malformed stops entries only", () => { + const j: Record = { stops: [ { kind: "burg", id: 5 }, { kind: "marker", id: 2 }, + { kind: "burg", id: -1 }, + { kind: "wp", id: 9 }, + { kind: "marker", id: "x" }, ], }; - normalizePackJourney(j, { - burgs: [{ i: 5, removed: false }], - markers: [], - }); - expect(j.stops).toEqual([{ kind: "burg", id: 5 }]); + normalizePackJourney(j); + expect((j as unknown as PackJourney).stops).toEqual([ + { kind: "burg", id: 5 }, + { kind: "marker", id: 2 }, + ]); }); - it("keeps stops[] when stray stopIds also present", () => { + it("does not prune missing legs from pack context", () => { const j: Record = { - stops: [{ kind: "marker" as const, id: 1 }], - stopIds: [burgJourneyStopRef(9)], + stops: [ + { kind: "burg", id: 5 }, + { kind: "marker", id: 2 }, + ], }; normalizePackJourney(j); - expect((j as unknown as PackJourney).stops).toEqual([{ kind: "marker", id: 1 }]); - expect(j.stopIds).toBeUndefined(); - }); -}); - -describe("parseJourneyStopRef", () => { - it("parses burg and marker prefixes", () => { - expect(parseJourneyStopRef("burg:12")).toEqual({ kind: "burg", id: 12 }); - expect(parseJourneyStopRef("marker:3")).toEqual({ kind: "marker", id: 3 }); - expect(parseJourneyStopRef("wp_x")).toBeNull(); + expect((j as unknown as PackJourney).stops).toEqual([ + { kind: "burg", id: 5 }, + { kind: "marker", id: 2 }, + ]); }); }); diff --git a/src/modules/journey-model.ts b/src/modules/journey-model.ts index 67ccd2b9a..ab9238cb2 100644 --- a/src/modules/journey-model.ts +++ b/src/modules/journey-model.ts @@ -67,12 +67,6 @@ export function buildJourneyResolutionContext( }; } -/** Optional pack slice for pruning dead burg/marker refs during normalize. */ -export interface JourneyNormalizePackContext { - burgs?: Array<{ i?: number; removed?: boolean }>; - markers?: Array<{ i?: number }>; -} - const BURG_REF_RE = /^burg:(\d+)$/; const MARKER_REF_RE = /^marker:(\d+)$/; @@ -84,7 +78,7 @@ export function markerJourneyStopRef(i: number): string { return `marker:${i}`; } -export type ParsedJourneyStopRef = +type ParsedJourneyStopRef = | { kind: "burg"; id: number } | { kind: "marker"; id: number }; @@ -94,7 +88,7 @@ export function journeyLegToRefString(leg: JourneyStopLeg): string { : markerJourneyStopRef(leg.id); } -export function parseJourneyStopRef(stopId: string): ParsedJourneyStopRef | null { +function parseJourneyStopRef(stopId: string): ParsedJourneyStopRef | null { const bm = BURG_REF_RE.exec(stopId); if (bm) return { kind: "burg", id: +bm[1] }; const mm = MARKER_REF_RE.exec(stopId); @@ -111,18 +105,6 @@ export function journeyRefStringToLeg(ref: string): JourneyStopLeg | null { : { kind: "marker", id: p.id }; } -export function isWellFormedBurgStopRef(id: string): boolean { - return BURG_REF_RE.test(id); -} - -export function isWellFormedMarkerStopRef(id: string): boolean { - return MARKER_REF_RE.test(id); -} - -export function emptyPackJourney(): PackJourney { - return { stops: [] }; -} - function sanitizeStopsArray(raw: unknown[]): JourneyStopLeg[] { const out: JourneyStopLeg[] = []; for (const item of raw) { @@ -138,7 +120,7 @@ function sanitizeStopsArray(raw: unknown[]): JourneyStopLeg[] { } /** Pack slice used when ensuring `pack.journey` exists and is sanitized. */ -export type PackWithOptionalJourney = JourneyNormalizePackContext & { +export type PackWithOptionalJourney = { journey?: unknown; }; @@ -154,40 +136,22 @@ export function ensurePackJourneyNormalized(pack: PackWithOptionalJourney): void ) { pack.journey = { stops: [] }; } - normalizePackJourney(pack.journey, pack); + normalizePackJourney(pack.journey); } /** * Mutates journey object into canonical `{ stops }` only. - * Drops unknown keys (`points`, `stopIds`, `waypoints`, …) from edited or merged JSON. */ export function normalizePackJourney( j: unknown, - pack?: JourneyNormalizePackContext, ): void { if (!j || typeof j !== "object" || Array.isArray(j)) return; const obj = j as Record; - let stops = sanitizeStopsArray( + const stops = sanitizeStopsArray( Array.isArray(obj.stops) ? (obj.stops as unknown[]) : [], ); - - stops = stops.filter((leg) => { - if (!pack) return true; - if (leg.kind === "burg") { - const burgs = pack.burgs; - if (!Array.isArray(burgs)) return false; - return burgs.some((b) => b.i === leg.id && !b.removed); - } - const markers = pack.markers; - if (!Array.isArray(markers)) return false; - return markers.some((m) => m.i === leg.id); - }); - - delete obj.points; - delete obj.stopIds; - delete obj.waypoints; obj.stops = stops; } @@ -279,7 +243,3 @@ export function journeyResolvedCoordinates( return out; } -/** `burg:n` / `marker:n` ref strings for current legs (e.g. vertex attribution). */ -export function referencedJourneyStopRefs(j: PackJourney): Set { - return new Set(j.stops.map(journeyLegToRefString)); -} diff --git a/tests/e2e/journey-layer.spec.ts b/tests/e2e/journey-layer.spec.ts index a3b72c1b2..bd99dbd57 100644 --- a/tests/e2e/journey-layer.spec.ts +++ b/tests/e2e/journey-layer.spec.ts @@ -68,7 +68,7 @@ test.describe("Journey layer", () => { expect(disp).toBe("none"); }); - test("drawJourney strips stray points key and leaves empty journey", async ({ page }) => { + test("drawJourney ignores stray points key and leaves empty stops", async ({ page }) => { const snap = await page.evaluate((pts) => { const w = window as unknown as { layerIsOn: (id: string) => boolean; @@ -83,13 +83,11 @@ test.describe("Journey layer", () => { return { pointsPresent: Object.prototype.hasOwnProperty.call(j, "points"), stopsLen: Array.isArray(j.stops) ? j.stops.length : -1, - legacyStopIds: Object.prototype.hasOwnProperty.call(j, "stopIds"), }; }, BACKTRACK_JOURNEY_POINTS); - expect(snap.pointsPresent).toBe(false); + expect(snap.pointsPresent).toBe(true); expect(snap.stopsLen).toBe(0); - expect(snap.legacyStopIds).toBe(false); }); test("drawJourney resolves a burg stop ref using pack.burgs positions", async ({ page }) => { From fbbbe38a97457942228f72eed832b737125b8674 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 12:20:35 +0200 Subject: [PATCH 30/48] Discard changes to package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b2918f6b0..4bdc86ae7 100644 --- a/package.json +++ b/package.json @@ -49,4 +49,4 @@ "engines": { "node": ">=24.0.0" } -} \ No newline at end of file +} From f2dcf5a8124a957eea518190264420cd0f474340 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 12:20:43 +0200 Subject: [PATCH 31/48] Discard changes to package-lock.json --- package-lock.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 75ad21d92..82f789204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "polylabel": "^2.0.1" }, "devDependencies": { - "@biomejs/biome": "^2.4.6", + "@biomejs/biome": "^2.4.13", "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", @@ -104,6 +104,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -121,6 +124,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -138,6 +144,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -155,6 +164,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ From 5ead5b59e957588fbad8ad363752bc0bc1ce331e Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 12:27:35 +0200 Subject: [PATCH 32/48] Refactor journey model tests to remove legacy handling of `stopIds` and `waypoints`. Update type definitions and normalize test cases to focus on `stops[]`. Clean up unused assertions and improve clarity in test descriptions. --- src/modules/journey-model.test.ts | 30 +++++------------------------- src/modules/journey-model.ts | 3 +-- tests/e2e/journey-layer.spec.ts | 22 ---------------------- 3 files changed, 6 insertions(+), 49 deletions(-) diff --git a/src/modules/journey-model.test.ts b/src/modules/journey-model.test.ts index e9744cc4b..726ef3712 100644 --- a/src/modules/journey-model.test.ts +++ b/src/modules/journey-model.test.ts @@ -13,55 +13,35 @@ import { type JourneyResolutionContext, type JourneyStopLeg, type PackJourney, - type PackWithOptionalJourney, } from "./journey-model"; describe("ensurePackJourneyNormalized", () => { it("creates pack.journey when absent and normalizes", () => { - const pack: PackWithOptionalJourney = {}; + const pack: { journey?: unknown } = {}; ensurePackJourneyNormalized(pack); expect(pack.journey).toEqual({ stops: [] }); }); }); describe("normalizePackJourney", () => { - it("keeps unknown keys and yields empty stops when stops[] is absent", () => { + it("yields empty stops when stops[] is absent", () => { const j: Record = { - points: [[10, 20], [30, 40]], - stopIds: [], - waypoints: [], + foo: "bar", }; normalizePackJourney(j); - expect(j.points).toEqual([[10, 20], [30, 40]]); - expect(j.stopIds).toEqual([]); - expect(j.waypoints).toEqual([]); const normalized = j as unknown as PackJourney; expect(normalized.stops).toEqual([]); expect(journeyResolvedCoordinates(normalized)).toEqual([]); }); - it("uses only stops[] as source of truth", () => { + it("uses stops[] as source of truth", () => { const j = { stops: [{ kind: "burg" as const, id: 1 }], - stopIds: ["burg:99"], - waypoints: [{ id: "x" }], + foo: "bar", }; normalizePackJourney(j); expect(j.stops.length).toBe(1); expect(j.stops[0]).toEqual({ kind: "burg", id: 1 }); - expect(j.stopIds).toEqual(["burg:99"]); - expect(j.waypoints).toEqual([{ id: "x" }]); - }); - - it("does not infer stops from stopIds / waypoints", () => { - const j: Record = { - stopIds: ["wp_skip", "burg:3", "marker:7"], - waypoints: [{ id: "wp_skip", name: "A", x: 1, y: 2 }], - }; - normalizePackJourney(j); - expect((j as unknown as PackJourney).stops).toEqual([]); - expect(j.stopIds).toEqual(["wp_skip", "burg:3", "marker:7"]); - expect(j.waypoints).toEqual([{ id: "wp_skip", name: "A", x: 1, y: 2 }]); }); it("filters malformed stops entries only", () => { diff --git a/src/modules/journey-model.ts b/src/modules/journey-model.ts index ab9238cb2..88a21ff13 100644 --- a/src/modules/journey-model.ts +++ b/src/modules/journey-model.ts @@ -119,8 +119,7 @@ function sanitizeStopsArray(raw: unknown[]): JourneyStopLeg[] { return out; } -/** Pack slice used when ensuring `pack.journey` exists and is sanitized. */ -export type PackWithOptionalJourney = { +type PackWithOptionalJourney = { journey?: unknown; }; diff --git a/tests/e2e/journey-layer.spec.ts b/tests/e2e/journey-layer.spec.ts index bd99dbd57..1d78f9c6a 100644 --- a/tests/e2e/journey-layer.spec.ts +++ b/tests/e2e/journey-layer.spec.ts @@ -68,28 +68,6 @@ test.describe("Journey layer", () => { expect(disp).toBe("none"); }); - test("drawJourney ignores stray points key and leaves empty stops", async ({ page }) => { - const snap = await page.evaluate((pts) => { - const w = window as unknown as { - layerIsOn: (id: string) => boolean; - toggleJourney: () => void; - pack: { journey: Record }; - drawJourney: () => void; - }; - if (!w.layerIsOn("toggleJourney")) w.toggleJourney(); - w.pack.journey = { points: pts }; - w.drawJourney(); - const j = w.pack.journey; - return { - pointsPresent: Object.prototype.hasOwnProperty.call(j, "points"), - stopsLen: Array.isArray(j.stops) ? j.stops.length : -1, - }; - }, BACKTRACK_JOURNEY_POINTS); - - expect(snap.pointsPresent).toBe(true); - expect(snap.stopsLen).toBe(0); - }); - test("drawJourney resolves a burg stop ref using pack.burgs positions", async ({ page }) => { await page.evaluate(() => { const w = window as unknown as { From 94e442ff828d7406e507dfa806b1a7cd046dc594 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 12:33:43 +0200 Subject: [PATCH 33/48] Refactor journey-draw and journey-model modules to simplify type definitions by removing unnecessary exports. Update mapping logic in journey-draw to enhance clarity and maintainability. --- src/modules/journey-draw.ts | 4 ++-- src/modules/journey-model.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/modules/journey-draw.ts b/src/modules/journey-draw.ts index c07fd4974..dba773ca3 100644 --- a/src/modules/journey-draw.ts +++ b/src/modules/journey-draw.ts @@ -2,7 +2,7 @@ * Journey SVG rendering (#journeys): delegates geometry/style to sibling modules. */ import type { Selection } from "d3"; -import type { JourneyResolvedStopEntry, PackJourney } from "./journey-model"; +import type { PackJourney } from "./journey-model"; import { buildJourneyResolutionContext, ensurePackJourneyNormalized, @@ -146,7 +146,7 @@ export class JourneyDrawModule { this.lastLodTier = null; return; } - const points = resolvedStops.map((r: JourneyResolvedStopEntry) => r.coord); + const points = resolvedStops.map((r) => r.coord); const zs = zoomScale; const zm = zoomMinForLod; diff --git a/src/modules/journey-model.ts b/src/modules/journey-model.ts index 88a21ff13..6aed289f2 100644 --- a/src/modules/journey-model.ts +++ b/src/modules/journey-model.ts @@ -3,12 +3,12 @@ */ /** One leg in the journey sequence (linked-list style via array order). */ -export interface JourneyBurgLeg { +interface JourneyBurgLeg { kind: "burg"; id: number; } -export interface JourneyMarkerLeg { +interface JourneyMarkerLeg { kind: "marker"; id: number; } @@ -210,7 +210,7 @@ export function resolveJourneyStopPosition( } /** One resolved leg and its map coordinate (same order as `journeyResolvedCoordinates` points). */ -export interface JourneyResolvedStopEntry { +interface JourneyResolvedStopEntry { leg: JourneyStopLeg; coord: [number, number]; } From b46d32b6d7bdd4fde898b98b25d01522aa1c9fc6 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 13:06:38 +0200 Subject: [PATCH 34/48] simplify --- src/modules/index.ts | 3 +- src/modules/journey-draw.test.ts | 228 ----- src/modules/journey-draw.ts | 425 ---------- src/modules/journey-editor.ts | 252 ------ src/modules/journey-model.test.ts | 175 ---- src/modules/journey-model.ts | 244 ------ src/modules/journey-path-geometry.ts | 251 ------ src/modules/journey-style-config.ts | 150 ---- src/modules/journey.ts | 1167 ++++++++++++++++++++++++++ 9 files changed, 1168 insertions(+), 1727 deletions(-) delete mode 100644 src/modules/journey-draw.test.ts delete mode 100644 src/modules/journey-draw.ts delete mode 100644 src/modules/journey-editor.ts delete mode 100644 src/modules/journey-model.test.ts delete mode 100644 src/modules/journey-model.ts delete mode 100644 src/modules/journey-path-geometry.ts delete mode 100644 src/modules/journey-style-config.ts create mode 100644 src/modules/journey.ts diff --git a/src/modules/index.ts b/src/modules/index.ts index f5343c6ed..3ea497514 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -9,8 +9,7 @@ import "./burgs-generator"; import "./biomes"; import "./cultures-generator"; import "./routes-generator"; -import "./journey-draw"; -import "./journey-editor"; +import "./journey"; import "./states-generator"; import "./zones-generator"; import "./religions-generator"; diff --git a/src/modules/journey-draw.test.ts b/src/modules/journey-draw.test.ts deleted file mode 100644 index e340d7aa4..000000000 --- a/src/modules/journey-draw.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - arrowPositionsAlongPolyline, - bendSegmentChord, - chordGradientT, - chordKey, - directedChordOccurrenceIndex, - journeyArrowSpacingMapUnits, - journeyArrowSpacingMulForTier, - journeyLodTier, - journeyPolylineSamplesForTier, - journeyRampColor, - journeyRampSamplerForConfig, - JOURNEY_DEFAULT_SOLID_STROKE, - laneMultipliersForSegments, - parseJourneyRainbowStops, - readJourneyStyleConfig, - segmentUInterval, -} from "./journey-draw"; - -describe("journeyLodTier", () => { - it("is 0 at zoomMin and increases when scale doubles relative to zoomMin", () => { - expect(journeyLodTier(0.05, 0.05)).toBe(0); - expect(journeyLodTier(0.1, 0.05)).toBe(1); - expect(journeyLodTier(0.2, 0.05)).toBe(2); - }); - - it("clamps to max tier", () => { - expect(journeyLodTier(1e9, 0.05)).toBe(6); - }); -}); - -describe("journeyPolylineSamplesForTier", () => { - it("rises with tier then caps", () => { - expect(journeyPolylineSamplesForTier(0)).toBeLessThan( - journeyPolylineSamplesForTier(4), - ); - expect(journeyPolylineSamplesForTier(6)).toBe(journeyPolylineSamplesForTier(10)); - }); -}); - -describe("journeyArrowSpacingMulForTier", () => { - it("is higher when zoomed out (low tier)", () => { - expect(journeyArrowSpacingMulForTier(0)).toBeGreaterThan( - journeyArrowSpacingMulForTier(6), - ); - }); -}); - -describe("journeyArrowSpacingMapUnits", () => { - it("shrinks map spacing when scale increases (same tier)", () => { - const tier = 3; - const a = journeyArrowSpacingMapUnits(1, tier); - const b = journeyArrowSpacingMapUnits(4, tier); - expect(b).toBeLessThan(a); - }); -}); - -describe("chordGradientT", () => { - const a: [number, number] = [0, 0]; - const b: [number, number] = [100, 0]; - - it("is 0 at A, 1 at B, ~0.5 at the midpoint", () => { - expect(chordGradientT(a, b, 0, 0)).toBe(0); - expect(chordGradientT(a, b, 100, 0)).toBe(1); - expect(chordGradientT(a, b, 50, 0)).toBeCloseTo(0.5); - }); - - it("matches gradient axis (offset perpendicular does not change t)", () => { - expect(chordGradientT(a, b, 40, 99)).toBeCloseTo(0.4); - }); - - it("clamps outside the segment", () => { - expect(chordGradientT(a, b, -50, 0)).toBe(0); - expect(chordGradientT(a, b, 200, 0)).toBe(1); - }); -}); - -describe("segmentUInterval", () => { - it("slices the unified ramp into equal spans", () => { - expect(segmentUInterval(4, 0)).toEqual([0, 0.25]); - expect(segmentUInterval(4, 3)).toEqual([0.75, 1]); - }); - - it("returns zeros when there are no segments", () => { - expect(segmentUInterval(0, 0)).toEqual([0, 0]); - }); -}); - -describe("journeyRampColor", () => { - it("returns CSS color strings for clamped parameters", () => { - expect(journeyRampColor(0)).toMatch(/^(#|rgb|rgba)/); - expect(journeyRampColor(1)).toMatch(/^(#|rgb|rgba)/); - expect(journeyRampColor(-5)).toBe(journeyRampColor(0)); - expect(journeyRampColor(5)).toBe(journeyRampColor(1)); - }); -}); - -describe("directedChordOccurrenceIndex", () => { - it("counts 0,1,2 for identical directed chords in order", () => { - const pts: [number, number][] = [ - [0, 0], - [10, 0], - [10, 10], - [0, 0], - [10, 0], - [0, 0], - [10, 0], - ]; - const occ = directedChordOccurrenceIndex(pts); - expect(occ).toHaveLength(6); - expect(chordKey([0, 0], [10, 0])).toBeTruthy(); - expect(occ[0]).toBe(0); - expect(occ[1]).toBe(0); - expect(occ[2]).toBe(0); - expect(occ[3]).toBe(1); - expect(occ[4]).toBe(0); - expect(occ[5]).toBe(2); - }); -}); - -describe("bendSegmentChord", () => { - it("scales with chord length and repeat index", () => { - const len = 100; - const b0 = bendSegmentChord(len, 0); - const b1 = bendSegmentChord(len, 1); - expect(b1).toBeGreaterThan(b0); - expect(b0).toBeCloseTo(14, 5); - }); -}); - -describe("laneMultipliersForSegments", () => { - it("separates duplicate directed chords", () => { - const pts: [number, number][] = [ - [0, 0], - [10, 0], - [10, 10], - [0, 0], - [10, 0], - ]; - const lanes = laneMultipliersForSegments(pts); - expect(lanes).toHaveLength(4); - expect(chordKey([0, 0], [10, 0])).toBe("0,0->10,0"); - expect(lanes[0]).not.toBe(0); - expect(lanes[3]).not.toBe(0); - expect(lanes[0]).not.toBe(lanes[3]); - }); -}); - -describe("parseJourneyRainbowStops", () => { - it("returns null for empty or single token", () => { - expect(parseJourneyRainbowStops(null)).toBeNull(); - expect(parseJourneyRainbowStops("")).toBeNull(); - expect(parseJourneyRainbowStops("#ff0000")).toBeNull(); - }); - - it("parses comma-separated colors", () => { - expect(parseJourneyRainbowStops("#ff0000, #00ff00")).toEqual([ - "#ff0000", - "#00ff00", - ]); - }); -}); - -describe("readJourneyStyleConfig", () => { - it("uses defaults when element is null", () => { - const c = readJourneyStyleConfig(null); - expect(c.colorMode).toBe("rainbow"); - expect(c.solidStroke).toBe(JOURNEY_DEFAULT_SOLID_STROKE); - expect(c.lineScreenPx).toBe(6); - expect(c.waypointFill).toBe("#ffffff"); - expect(c.outlineColor).toBe("#000000"); - }); - - it("reads data-color-mode solid and custom attrs", () => { - const attrs: Record = { - "data-color-mode": "solid", - "data-solid-stroke": "#abc", - "data-line-screen-px": "12", - }; - const el = { - getAttribute(name: string) { - return attrs[name] ?? null; - }, - } as unknown as Element; - const c = readJourneyStyleConfig(el); - expect(c.colorMode).toBe("solid"); - expect(c.solidStroke).toBe("#abc"); - expect(c.lineScreenPx).toBe(12); - }); - - it("keeps data-outline-screen-px 0 (does not fall back to default)", () => { - const el = { - getAttribute(name: string) { - return name === "data-outline-screen-px" ? "0" : null; - }, - } as unknown as Element; - const c = readJourneyStyleConfig(el); - expect(c.outlineScreenPx).toBe(0); - }); -}); - -describe("journeyRampSamplerForConfig", () => { - it("returns constant color in solid mode", () => { - const cfg = readJourneyStyleConfig(null); - const solidCfg = { ...cfg, colorMode: "solid" as const, solidStroke: "#112233" }; - const f = journeyRampSamplerForConfig(solidCfg); - expect(f(0)).toBe("#112233"); - expect(f(1)).toBe("#112233"); - }); - - it("varies along u in rainbow mode", () => { - const cfg = readJourneyStyleConfig(null); - const f = journeyRampSamplerForConfig(cfg); - expect(f(0)).not.toBe(f(1)); - }); -}); - -describe("arrowPositionsAlongPolyline", () => { - it("spaces arrows along length", () => { - const pts: [number, number][] = [ - [0, 0], - [100, 0], - ]; - const arrows = arrowPositionsAlongPolyline(pts, 30); - expect(arrows.length).toBeGreaterThanOrEqual(3); - }); -}); diff --git a/src/modules/journey-draw.ts b/src/modules/journey-draw.ts deleted file mode 100644 index dba773ca3..000000000 --- a/src/modules/journey-draw.ts +++ /dev/null @@ -1,425 +0,0 @@ -/** - * Journey SVG rendering (#journeys): delegates geometry/style to sibling modules. - */ -import type { Selection } from "d3"; -import type { PackJourney } from "./journey-model"; -import { - buildJourneyResolutionContext, - ensurePackJourneyNormalized, - journeyLegToRefString, - journeyResolvedCoordinates, - journeyResolvedStopEntries, -} from "./journey-model"; -import { - arrowPositionsAlongPolyline, - bendSegmentChord, - chordGradientT, - directedChordOccurrenceIndex, - journeyArrowSpacingMapUnits, - journeyLodTier, - journeyPolylineSamplesForTier, - laneMultipliersForSegments, - MIN_SEG_LEN, - polylineLength, - polylinePath, - quadraticSamples, - segmentUInterval, -} from "./journey-path-geometry"; -import { - journeyRampSamplerForConfig, - JOURNEY_STYLE_DEFAULTS, - readJourneyStyleConfig, -} from "./journey-style-config"; -import { rn } from "../utils/numberUtils"; - -export { - JOURNEY_DEFAULT_SOLID_STROKE, - JOURNEY_RAINBOW_STOPS, - JOURNEY_STYLE_DEFAULTS, - journeyRampColor, - parseJourneyRainbowStops, - readJourneyStyleConfig, - journeyRampSamplerForConfig, - type JourneyColorMode, - type JourneyStyleConfig, -} from "./journey-style-config"; - -export { - arrowPositionsAlongPolyline, - bendSegmentChord, - chordGradientT, - chordKey, - directedChordOccurrenceIndex, - journeyArrowSpacingMapUnits, - journeyArrowSpacingMulForTier, - journeyLodTier, - journeyPolylineSamplesForTier, - laneMultipliersForSegments, - segmentUInterval, - type ArrowSample, -} from "./journey-path-geometry"; - -/** Arrowhead path (local coords before translate/rotate); 2× prior triangle size. */ -const ARROW_PATH_D = "M0,-8.4 L22.5,0 L0,8.4 Z"; - -const JOURNEY_OUTLINE_FILTER_ID = "journeyUnifiedOutline"; - -function mapMetricScreenToWorld( - screenPx: number, - zoomScale: number, - lo: number, - hi: number, -): number { - const k = Math.max(zoomScale, 1e-9); - return Math.min(hi, Math.max(lo, screenPx / k)); -} - -function arrowTransform( - x: number, - y: number, - angleDeg: number, - zoomScale: number, -): string { - const inv = rn(1 / Math.max(zoomScale, 1e-9), 6); - return `translate(${rn(x, 2)},${rn(y, 2)}) rotate(${rn(angleDeg, 2)}) scale(${inv})`; -} - -function ensureJourneyOutlineFilter( - defs: Selection, - morphologyRadiusMap: number, - floodColor: string, -): void { - defs.select(`filter#${JOURNEY_OUTLINE_FILTER_ID}`).remove(); - const f = defs - .append("filter") - .attr("id", JOURNEY_OUTLINE_FILTER_ID) - .attr("class", "journey-outline-filter") - .attr("color-interpolation-filters", "sRGB") - .attr("x", "-50%") - .attr("y", "-50%") - .attr("width", "200%") - .attr("height", "200%"); - - f.append("feMorphology") - .attr("in", "SourceAlpha") - .attr("operator", "dilate") - .attr("radius", rn(morphologyRadiusMap, 3)) - .attr("result", "dilatedAlpha"); - - f.append("feFlood") - .attr("flood-color", floodColor) - .attr("result", "outlineFlood"); - - f.append("feComposite") - .attr("in", "outlineFlood") - .attr("in2", "dilatedAlpha") - .attr("operator", "in") - .attr("result", "outlineShape"); - - const merge = f.append("feMerge"); - merge.append("feMergeNode").attr("in", "outlineShape"); - merge.append("feMergeNode").attr("in", "SourceGraphic"); -} - -export class JourneyDrawModule { - private lastLodTier: number | null = null; - - redraw( - defs: Selection, - journeys: Selection, - zoomScale = 1, - zoomMinForLod = 0.05, - ): void { - journeys.selectAll("*").remove(); - defs.selectAll("linearGradient.journey-def").remove(); - defs.select(`filter#${JOURNEY_OUTLINE_FILTER_ID}`).remove(); - - if (!pack.journey) { - this.lastLodTier = null; - return; - } - ensurePackJourneyNormalized(pack); - const journeyData = pack.journey as PackJourney; - const resCtx = buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []); - const resolvedStops = journeyResolvedStopEntries(journeyData, resCtx); - if (!resolvedStops.length) { - this.lastLodTier = null; - return; - } - const points = resolvedStops.map((r) => r.coord); - - const zs = zoomScale; - const zm = zoomMinForLod; - - const styleCfg = readJourneyStyleConfig(journeys.node()); - const rampAt = journeyRampSamplerForConfig(styleCfg); - - const verts = journeys.append("g").attr("class", "journey-vertices"); - - const waypointR = mapMetricScreenToWorld( - styleCfg.waypointRScreenPx, - zs, - 0.15, - 80, - ); - const waypointSw = mapMetricScreenToWorld( - styleCfg.waypointRingScreenPx, - zs, - 0.03, - 24, - ); - - const idsAtCoord = new Map(); - for (const { leg, coord } of resolvedStops) { - const sid = journeyLegToRefString(leg); - const ck = `${rn(coord[0], 2)},${rn(coord[1], 2)}`; - const arr = idsAtCoord.get(ck) ?? []; - if (!arr.includes(sid)) arr.push(sid); - idsAtCoord.set(ck, arr); - } - - const seen = new Set(); - for (const [x, y] of points) { - const k = `${rn(x, 2)},${rn(y, 2)}`; - if (seen.has(k)) continue; - seen.add(k); - const jidList = idsAtCoord.get(k); - const circle = verts - .append("circle") - .attr("class", "journey-waypoint") - .attr("data-jx", rn(x, 2)) - .attr("data-jy", rn(y, 2)) - .attr("cx", rn(x, 2)) - .attr("cy", rn(y, 2)) - .attr("r", rn(waypointR, 3)) - .attr("fill", styleCfg.waypointFill) - .attr("stroke", styleCfg.waypointStroke) - .attr("stroke-width", rn(waypointSw, 3)) - .style("cursor", "pointer"); - if (jidList?.length === 1) { - circle.attr("data-journey-stop-ref", jidList[0]); - } - } - - const S = Math.max(0, points.length - 1); - if (S < 1) { - this.lastLodTier = journeyLodTier(zs, zm); - return; - } - - const tier = journeyLodTier(zs, zm); - const samples = journeyPolylineSamplesForTier(tier); - const arrowSpacing = journeyArrowSpacingMapUnits(zs, tier); - const morphR = mapMetricScreenToWorld( - styleCfg.outlineScreenPx, - zs, - 0.35, - 40, - ); - - ensureJourneyOutlineFilter(defs, morphR, styleCfg.outlineColor); - const segmentsRoot = journeys.append("g").attr("class", "journey-segments"); - - const lanes = laneMultipliersForSegments(points); - const repeats = directedChordOccurrenceIndex(points); - - const strokeW = mapMetricScreenToWorld( - styleCfg.lineScreenPx, - zs, - 0.06, - 24, - ); - - for (let i = 0; i < S; i++) { - const a = points[i]; - const b = points[i + 1]; - const segLen = Math.hypot(b[0] - a[0], b[1] - a[1]); - if (segLen < MIN_SEG_LEN) continue; - - const lane = lanes[i] ?? 0; - const k = repeats[i] ?? 0; - const bendAmount = bendSegmentChord(segLen, k); - - const samp = quadraticSamples(a, b, bendAmount, lane, samples); - const d = polylinePath(samp); - if (!d) continue; - - const [u0, u1] = segmentUInterval(S, i); - const c0 = rampAt(u0); - const c1 = rampAt(u1); - - const seg = segmentsRoot - .append("g") - .attr("class", "journey-segment") - .attr("filter", `url(#${JOURNEY_OUTLINE_FILTER_ID})`); - - let strokeAttr: string; - if (styleCfg.colorMode === "solid") { - strokeAttr = styleCfg.solidStroke; - } else { - const gid = `journeyGrad_${i}`; - const grad = defs - .append("linearGradient") - .attr("id", gid) - .attr("class", "journey-def") - .attr("gradientUnits", "userSpaceOnUse") - .attr("x1", a[0]) - .attr("y1", a[1]) - .attr("x2", b[0]) - .attr("y2", b[1]); - - grad.append("stop").attr("offset", "0%").attr("stop-color", c0); - grad.append("stop").attr("offset", "100%").attr("stop-color", c1); - strokeAttr = `url(#${gid})`; - } - - seg - .append("path") - .attr("class", "journey-segment-stroke") - .attr("d", d) - .attr("fill", "none") - .attr("stroke", strokeAttr) - .attr("stroke-width", rn(strokeW, 3)) - .attr("stroke-linecap", "round") - .attr("stroke-linejoin", "round"); - - let arrPts = arrowPositionsAlongPolyline(samp, arrowSpacing); - if (!arrPts.length && polylineLength(samp) > MIN_SEG_LEN) { - const mid = Math.max(1, Math.floor(samp.length / 2)); - const prev = mid - 1; - const angleDeg = - (Math.atan2( - samp[mid][1] - samp[prev][1], - samp[mid][0] - samp[prev][0], - ) * - 180) / - Math.PI; - arrPts = [{ x: samp[mid][0], y: samp[mid][1], angleDeg }]; - } - for (const ar of arrPts) { - const gt = chordGradientT(a, b, ar.x, ar.y); - const arrowColor = rampAt(u0 + gt * (u1 - u0)); - seg - .append("path") - .attr("class", "journey-arrow") - .attr("d", ARROW_PATH_D) - .attr("fill", arrowColor) - .attr("data-ar-x", rn(ar.x, 2)) - .attr("data-ar-y", rn(ar.y, 2)) - .attr("data-ar-ang", rn(ar.angleDeg, 2)) - .attr("transform", arrowTransform(ar.x, ar.y, ar.angleDeg, zs)); - } - } - - this.lastLodTier = tier; - } - - syncZoom( - defs: Selection, - journeys: Selection, - zoomScale = 1, - zoomMinForLod = 0.05, - ): void { - if (!pack.journey) return; - ensurePackJourneyNormalized(pack); - const points = journeyResolvedCoordinates( - pack.journey as PackJourney, - buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []), - ); - if (!points.length) return; - - const zs = zoomScale; - const zm = zoomMinForLod; - const tier = journeyLodTier(zs, zm); - - const S = Math.max(0, points.length - 1); - if (S >= 1 && this.lastLodTier !== tier) { - this.redraw(defs, journeys, zs, zm); - return; - } - - this.applyZoomSizing(defs, journeys, zs); - } - - private applyZoomSizing( - defs: Selection, - journeys: Selection, - zoomScale: number, - ): void { - const zs = Math.max(zoomScale, 1e-9); - const styleCfg = readJourneyStyleConfig(journeys.node()); - - const strokeW = mapMetricScreenToWorld( - styleCfg.lineScreenPx, - zs, - 0.06, - 24, - ); - journeys - .selectAll(".journey-segment-stroke") - .attr("stroke-width", rn(strokeW, 3)); - - const waypointR = mapMetricScreenToWorld( - styleCfg.waypointRScreenPx, - zs, - 0.15, - 80, - ); - const waypointSw = mapMetricScreenToWorld( - styleCfg.waypointRingScreenPx, - zs, - 0.03, - 24, - ); - journeys - .selectAll(".journey-waypoint") - .attr("r", rn(waypointR, 3)) - .attr("stroke-width", rn(waypointSw, 3)); - - journeys.selectAll(".journey-arrow").each(function () { - const el = this as SVGPathElement; - const x = el.getAttribute("data-ar-x"); - const y = el.getAttribute("data-ar-y"); - const ang = el.getAttribute("data-ar-ang"); - if (x == null || y == null || ang == null) return; - el.setAttribute("transform", arrowTransform(+x, +y, +ang, zoomScale)); - }); - - const morphR = mapMetricScreenToWorld( - styleCfg.outlineScreenPx, - zs, - 0.35, - 40, - ); - const filt = defs.select(`filter#${JOURNEY_OUTLINE_FILTER_ID}`); - filt.select("feMorphology").attr("radius", rn(morphR, 3)); - filt.select("feFlood").attr("flood-color", styleCfg.outlineColor); - } -} - -/** Minimal facade consumed by legacy JS modules. */ -export type JourneyGlobalApi = { - STYLE_DEFAULTS: typeof JOURNEY_STYLE_DEFAULTS; - ensurePackJourneyNormalized: typeof ensurePackJourneyNormalized; -}; - -if (typeof window !== "undefined") { - window.JourneyDraw = new JourneyDrawModule(); - const journeyApi: JourneyGlobalApi = { - STYLE_DEFAULTS: JOURNEY_STYLE_DEFAULTS, - ensurePackJourneyNormalized, - }; - window.Journey = journeyApi; - window.JourneyPack = journeyApi; -} - -declare global { - var pack: import("../types/PackedGraph").PackedGraph; - interface Window { - JourneyDraw?: JourneyDrawModule; - /** Journey pack helpers + defaults (Routes-style); prefer over `JourneyPack`. */ - Journey?: JourneyGlobalApi; - /** @deprecated Use `Journey` */ - JourneyPack?: JourneyGlobalApi; - } -} diff --git a/src/modules/journey-editor.ts b/src/modules/journey-editor.ts deleted file mode 100644 index 026efaaec..000000000 --- a/src/modules/journey-editor.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Journey editor dialog — bundled TS mirroring routes/markers editor patterns. - */ -import { rn } from "../utils/numberUtils"; -import { - buildJourneyResolutionContext, - burgJourneyStopRef, - ensurePackJourneyNormalized, - journeyLegToRefString, - journeyRefStringToLeg, - markerJourneyStopRef, - resolveJourneyStopPosition, -} from "./journey-model"; - -function escapeAttr(s: string): string { - return String(s).replace(/&/g, "&").replace(/"/g, """); -} - -function escapeText(s: string): string { - return String(s) - .replace(/&/g, "&") - .replace(//g, ">"); -} - -function journeyStopSelectOptions(currentRef: string): string { - ensurePackJourneyNormalized(pack); - let html = ""; - const known = new Set(); - - if (!currentRef) { - html += - ''; - } - - html += ''; - for (const m of pack.markers || []) { - if (m.i == null || !Number.isFinite(m.x) || !Number.isFinite(m.y)) continue; - const ref = markerJourneyStopRef(m.i); - known.add(ref); - const sel = ref === currentRef ? " selected" : ""; - const typeLabel = m.type ? String(m.type) : "Marker"; - const label = `${typeLabel} #${m.i} (${rn(m.x, 2)}, ${rn(m.y, 2)})`; - html += ``; - } - html += ""; - - html += ''; - for (const b of pack.burgs || []) { - if (b.removed || b.i == null || !Number.isFinite(b.x) || !Number.isFinite(b.y)) - continue; - const ref = burgJourneyStopRef(b.i); - known.add(ref); - const sel = ref === currentRef ? " selected" : ""; - const nm = - b.name && String(b.name).trim() !== "" ? String(b.name) : `Burg ${b.i}`; - const label = `${nm} (${rn(b.x, 2)}, ${rn(b.y, 2)})`; - html += ``; - } - html += ""; - - if (currentRef && !known.has(currentRef)) { - html += ``; - } - return html; -} - -function journeyRenderStopRows(container: HTMLElement): void { - ensurePackJourneyNormalized(pack); - const stops = pack.journey!.stops; - const rows = stops.length === 0 ? [null] : stops; - rows.forEach((leg, i) => { - const currentRef = leg ? journeyLegToRefString(leg) : ""; - const showRemove = stops.length > 0; - const removeStyle = showRemove - ? "" - : "visibility:hidden;pointer-events:none"; - container.insertAdjacentHTML( - "beforeend", - /* html */ `
    - #${i + 1} - - -
    `, - ); - }); -} - -function journeyEditorRefreshBody(): void { - const stBody = ensureEl("journeyEditorStopsBody"); - stBody.innerHTML = ""; - ensurePackJourneyNormalized(pack); - journeyRenderStopRows(stBody); -} - -function journeyEditorRootChange(ev: Event): void { - const t = ev.target as HTMLElement; - - if (t.classList.contains("journey-stop-select")) { - const row = t.closest("[data-stop-index]"); - if (!row) return; - const idx = +(row as HTMLElement).dataset.stopIndex!; - if (!Number.isFinite(idx)) return; - const val = (t as HTMLSelectElement).value; - ensurePackJourneyNormalized(pack); - if (!val) return; - const leg = journeyRefStringToLeg(val); - if (!leg) return; - - const stops = pack.journey!.stops; - if (stops.length === 0) { - stops.push(leg); - } else { - stops[idx] = leg; - } - journeyEditorRefreshBody(); - drawJourney(); - } -} - -function journeyEditorRootClick(ev: Event): void { - const t = ev.target as HTMLElement; - - if (t.classList.contains("journey-stop-remove")) { - const row = t.closest("[data-stop-index]"); - if (!row) return; - const idx = +(row as HTMLElement).dataset.stopIndex!; - if (!Number.isFinite(idx)) return; - ensurePackJourneyNormalized(pack); - pack.journey!.stops.splice(idx, 1); - journeyEditorRefreshBody(); - drawJourney(); - } -} - -function journeyAppendStopRef(stopRef: string): void { - ensurePackJourneyNormalized(pack); - const ctx = buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []); - if (!resolveJourneyStopPosition(stopRef, ctx)) return; - const leg = journeyRefStringToLeg(stopRef); - if (!leg) return; - pack.journey!.stops.push(leg); - journeyEditorRefreshBody(); - drawJourney(); -} - -function journeyEditorAddLegClick(): void { - ensurePackJourneyNormalized(pack); - const stops = pack.journey!.stops; - if (!stops.length) { - tip( - "Choose the first stop in the Journey row (marker or burg), then use + to add legs.", - false, - "warn", - ); - return; - } - stops.push(stops[stops.length - 1]); - journeyEditorRefreshBody(); - drawJourney(); -} - -function journeyEditorOnClick(): void { - const d3g = globalThis as typeof globalThis & { - d3?: { event?: { sourceEvent?: Event } }; - }; - const evt = - d3g.d3?.event?.sourceEvent ?? window.event; - const target = evt?.target as HTMLElement | undefined; - - let circleEl: Element | null = null; - if (target?.classList?.contains("journey-waypoint")) circleEl = target; - else if (target?.closest?.(".journey-waypoint")) - circleEl = target.closest(".journey-waypoint"); - - if (circleEl) { - const stopRef = circleEl.getAttribute("data-journey-stop-ref"); - if (stopRef) { - journeyAppendStopRef(stopRef); - return; - } - return; - } - - tip("Add stops from the Journey dropdown (markers and burgs only).", false, "info"); -} - -function closeJourneyEditor(): void { - ensureEl("journeyEditorStopsBody").innerHTML = ""; - viewbox.on("click.journey", null).style("cursor", null); - clearMainTip(); - restoreDefaultEvents(); -} - -function editJourney(): void { - if (customization) return; - closeDialogs("#journeyEditor, .stable"); - ensurePackJourneyNormalized(pack); - - if (!layerIsOn("toggleJourney")) toggleJourney(); - - tip( - "Build the path with markers and burgs only—each leg follows live map positions. Use + to repeat the last stop. Click a journey circle to append that stop again. Undo / Clear affect the path only.", - true, - ); - viewbox.style("cursor", "default").on("click.journey", journeyEditorOnClick); - - $("#journeyEditor").dialog({ - title: "Journey editor", - resizable: false, - position: { my: "left top", at: "left+10 top+10", of: "#map" }, - close: closeJourneyEditor, - }); - - if (modules.editJourney) { - journeyEditorRefreshBody(); - return; - } - modules.editJourney = true; - - $("#journeyEditorRoot") - .on("change.journeyEd", journeyEditorRootChange) - .on("click.journeyEd", journeyEditorRootClick); - - $("#journeyEditorAddLeg").on("click.journeyEd", journeyEditorAddLegClick); - - $("#journeyEditorUndo").on("click.journeyEd", () => { - ensurePackJourneyNormalized(pack); - pack.journey!.stops.pop(); - journeyEditorRefreshBody(); - drawJourney(); - }); - - $("#journeyEditorClear").on("click.journeyEd", () => { - ensurePackJourneyNormalized(pack); - pack.journey!.stops = []; - journeyEditorRefreshBody(); - drawJourney(); - }); - - $("#journeyEditorDone").on("click.journeyEd", () => - $("#journeyEditor").dialog("close"), - ); - - journeyEditorRefreshBody(); -} - -if (typeof window !== "undefined") { - window.editJourney = editJourney; -} - -export { editJourney }; diff --git a/src/modules/journey-model.test.ts b/src/modules/journey-model.test.ts deleted file mode 100644 index 726ef3712..000000000 --- a/src/modules/journey-model.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildJourneyResolutionContext, - ensurePackJourneyNormalized, - journeyLegToRefString, - journeyRefStringToLeg, - journeyResolvedCoordinates, - journeyResolvedStopEntries, - markerJourneyStopRef, - normalizePackJourney, - resolveJourneyLeg, - resolveJourneyStopPosition, - type JourneyResolutionContext, - type JourneyStopLeg, - type PackJourney, -} from "./journey-model"; - -describe("ensurePackJourneyNormalized", () => { - it("creates pack.journey when absent and normalizes", () => { - const pack: { journey?: unknown } = {}; - ensurePackJourneyNormalized(pack); - expect(pack.journey).toEqual({ stops: [] }); - }); -}); - -describe("normalizePackJourney", () => { - it("yields empty stops when stops[] is absent", () => { - const j: Record = { - foo: "bar", - }; - normalizePackJourney(j); - const normalized = j as unknown as PackJourney; - expect(normalized.stops).toEqual([]); - expect(journeyResolvedCoordinates(normalized)).toEqual([]); - }); - - it("uses stops[] as source of truth", () => { - const j = { - stops: [{ kind: "burg" as const, id: 1 }], - foo: "bar", - }; - normalizePackJourney(j); - expect(j.stops.length).toBe(1); - expect(j.stops[0]).toEqual({ kind: "burg", id: 1 }); - }); - - it("filters malformed stops entries only", () => { - const j: Record = { - stops: [ - { kind: "burg", id: 5 }, - { kind: "marker", id: 2 }, - { kind: "burg", id: -1 }, - { kind: "wp", id: 9 }, - { kind: "marker", id: "x" }, - ], - }; - normalizePackJourney(j); - expect((j as unknown as PackJourney).stops).toEqual([ - { kind: "burg", id: 5 }, - { kind: "marker", id: 2 }, - ]); - }); - - it("does not prune missing legs from pack context", () => { - const j: Record = { - stops: [ - { kind: "burg", id: 5 }, - { kind: "marker", id: 2 }, - ], - }; - normalizePackJourney(j); - expect((j as unknown as PackJourney).stops).toEqual([ - { kind: "burg", id: 5 }, - { kind: "marker", id: 2 }, - ]); - }); -}); - -describe("journeyLegToRefString / journeyRefStringToLeg", () => { - it("roundtrips", () => { - const leg: JourneyStopLeg = { kind: "burg", id: 4 }; - expect(journeyRefStringToLeg(journeyLegToRefString(leg))).toEqual(leg); - }); -}); - -describe("journeyResolvedCoordinates", () => { - const j: PackJourney = { - stops: [ - { kind: "burg", id: 10 }, - { kind: "marker", id: 2 }, - ], - }; - const ctx: JourneyResolutionContext = { - burgs: [{ i: 10, x: 10, y: 20, removed: false }], - markers: [{ i: 2, x: 30, y: 40 }], - }; - - it("resolves burg then marker", () => { - expect(journeyResolvedCoordinates(j, ctx)).toEqual([ - [10, 20], - [30, 40], - ]); - }); - - it("skips missing burg", () => { - expect(journeyResolvedCoordinates({ stops: [{ kind: "burg", id: 999 }] }, ctx)).toEqual([]); - }); -}); - -describe("buildJourneyResolutionContext", () => { - it("matches linear resolve for burgs and markers", () => { - const burgs = [ - { i: 1, x: 10, y: 20, removed: true }, - { i: 1, x: 11, y: 21, removed: false }, - { i: 2, x: 30, y: 40, removed: false }, - ]; - const markers = [ - { i: 0, x: 0, y: 1 }, - { i: 3, x: 50, y: 60 }, - ]; - const plain: JourneyResolutionContext = { burgs, markers }; - const indexed = buildJourneyResolutionContext(burgs, markers); - expect(resolveJourneyLeg({ kind: "burg", id: 1 }, indexed)).toEqual( - resolveJourneyLeg({ kind: "burg", id: 1 }, plain), - ); - expect(resolveJourneyLeg({ kind: "burg", id: 2 }, indexed)).toEqual( - resolveJourneyLeg({ kind: "burg", id: 2 }, plain), - ); - expect(resolveJourneyLeg({ kind: "marker", id: 3 }, indexed)).toEqual( - resolveJourneyLeg({ kind: "marker", id: 3 }, plain), - ); - expect(resolveJourneyLeg({ kind: "marker", id: 999 }, indexed)).toEqual( - resolveJourneyLeg({ kind: "marker", id: 999 }, plain), - ); - }); -}); - -describe("journeyResolvedStopEntries", () => { - const j: PackJourney = { - stops: [ - { kind: "burg", id: 10 }, - { kind: "marker", id: 2 }, - ], - }; - const ctx: JourneyResolutionContext = { - burgs: [{ i: 10, x: 10, y: 20, removed: false }], - markers: [{ i: 2, x: 30, y: 40 }], - }; - - it("matches journeyResolvedCoordinates coords and carries legs", () => { - const rows = journeyResolvedStopEntries(j, ctx); - expect(rows.map((r) => r.coord)).toEqual(journeyResolvedCoordinates(j, ctx)); - expect(rows.map((r) => journeyLegToRefString(r.leg))).toEqual(["burg:10", "marker:2"]); - }); -}); - -describe("resolveJourneyLeg", () => { - it("returns null for removed burg", () => { - const ctx: JourneyResolutionContext = { - burgs: [{ i: 1, x: 1, y: 2, removed: true }], - markers: [], - }; - expect(resolveJourneyLeg({ kind: "burg", id: 1 }, ctx)).toBeNull(); - }); -}); - -describe("resolveJourneyStopPosition", () => { - it("resolves ref string", () => { - const ctx: JourneyResolutionContext = { - burgs: [], - markers: [{ i: 3, x: 5, y: 6 }], - }; - expect(resolveJourneyStopPosition(markerJourneyStopRef(3), ctx)).toEqual([5, 6]); - }); -}); diff --git a/src/modules/journey-model.ts b/src/modules/journey-model.ts deleted file mode 100644 index 6aed289f2..000000000 --- a/src/modules/journey-model.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Journey path: ordered burg / marker legs (`stops`); coordinates resolved from `pack`. - */ - -/** One leg in the journey sequence (linked-list style via array order). */ -interface JourneyBurgLeg { - kind: "burg"; - id: number; -} - -interface JourneyMarkerLeg { - kind: "marker"; - id: number; -} - -export type JourneyStopLeg = JourneyBurgLeg | JourneyMarkerLeg; - -export interface PackJourney { - stops: JourneyStopLeg[]; -} - -/** Minimal pack slice for resolving burg/marker positions (avoids importing PackedGraph). */ -export interface JourneyResolutionContext { - burgs: Array<{ i?: number; x?: number; y?: number; removed?: boolean }>; - markers: Array<{ i?: number; x?: number; y?: number }>; - /** First matching non-removed burg per id (same semantics as linear find). When set, `resolveJourneyLeg` uses O(1) lookup. */ - burgById?: Map; - /** First marker per id (same semantics as linear find). */ - markerById?: Map; -} - -function indexBurgsById( - burgs: JourneyResolutionContext["burgs"], -): Map { - const m = new Map(); - for (const b of burgs) { - if (b.removed) continue; - const id = b.i; - if (id === undefined || typeof id !== "number") continue; - if (!m.has(id)) m.set(id, b); - } - return m; -} - -function indexMarkersById( - markers: JourneyResolutionContext["markers"], -): Map { - const m = new Map(); - for (const mk of markers) { - const id = mk.i; - if (id === undefined || typeof id !== "number") continue; - if (!m.has(id)) m.set(id, mk); - } - return m; -} - -/** Build resolution context with id indexes (preferred for redraw / many stops). */ -export function buildJourneyResolutionContext( - burgs: JourneyResolutionContext["burgs"], - markers: JourneyResolutionContext["markers"], -): JourneyResolutionContext { - return { - burgs, - markers, - burgById: indexBurgsById(burgs), - markerById: indexMarkersById(markers), - }; -} - -const BURG_REF_RE = /^burg:(\d+)$/; -const MARKER_REF_RE = /^marker:(\d+)$/; - -export function burgJourneyStopRef(i: number): string { - return `burg:${i}`; -} - -export function markerJourneyStopRef(i: number): string { - return `marker:${i}`; -} - -type ParsedJourneyStopRef = - | { kind: "burg"; id: number } - | { kind: "marker"; id: number }; - -export function journeyLegToRefString(leg: JourneyStopLeg): string { - return leg.kind === "burg" - ? burgJourneyStopRef(leg.id) - : markerJourneyStopRef(leg.id); -} - -function parseJourneyStopRef(stopId: string): ParsedJourneyStopRef | null { - const bm = BURG_REF_RE.exec(stopId); - if (bm) return { kind: "burg", id: +bm[1] }; - const mm = MARKER_REF_RE.exec(stopId); - if (mm) return { kind: "marker", id: +mm[1] }; - return null; -} - -/** UI / DOM string → stored leg (burg or marker only). */ -export function journeyRefStringToLeg(ref: string): JourneyStopLeg | null { - const p = parseJourneyStopRef(ref); - if (!p) return null; - return p.kind === "burg" - ? { kind: "burg", id: p.id } - : { kind: "marker", id: p.id }; -} - -function sanitizeStopsArray(raw: unknown[]): JourneyStopLeg[] { - const out: JourneyStopLeg[] = []; - for (const item of raw) { - if (!item || typeof item !== "object") continue; - const o = item as Record; - const kind = o.kind; - const id = Number(o.id); - if (!Number.isInteger(id) || id < 0) continue; - if (kind === "burg") out.push({ kind: "burg", id }); - else if (kind === "marker") out.push({ kind: "marker", id }); - } - return out; -} - -type PackWithOptionalJourney = { - journey?: unknown; -}; - -/** - * Ensures `pack.journey` exists and mutates it to canonical `{ stops }` via - * {@link normalizePackJourney}. Single entry point for layers / editor / load. - */ -export function ensurePackJourneyNormalized(pack: PackWithOptionalJourney): void { - if ( - !pack.journey || - typeof pack.journey !== "object" || - Array.isArray(pack.journey) - ) { - pack.journey = { stops: [] }; - } - normalizePackJourney(pack.journey); -} - -/** - * Mutates journey object into canonical `{ stops }` only. - */ -export function normalizePackJourney( - j: unknown, -): void { - if (!j || typeof j !== "object" || Array.isArray(j)) return; - - const obj = j as Record; - - const stops = sanitizeStopsArray( - Array.isArray(obj.stops) ? (obj.stops as unknown[]) : [], - ); - obj.stops = stops; -} - -function finiteCoord(x: unknown, y: unknown): [number, number] | null { - const nx = Number(x); - const ny = Number(y); - if (!Number.isFinite(nx) || !Number.isFinite(ny)) return null; - return [nx, ny]; -} - -function tryWarnMissing(msg: string): void { - try { - const w = typeof globalThis !== "undefined" && (globalThis as { WARN?: boolean }).WARN; - if (w) console.warn(msg); - } catch { - /* noop */ - } -} - -/** Resolve one leg to map coordinates, or null if missing. */ -export function resolveJourneyLeg( - leg: JourneyStopLeg, - ctx: JourneyResolutionContext, -): [number, number] | null { - if (leg.kind === "burg") { - const burg = - ctx.burgById?.get(leg.id) ?? - ctx.burgs.find((b) => b.i === leg.id && !b.removed); - if (!burg) { - tryWarnMissing(`journey: missing burg ${leg.id}`); - return null; - } - const p = finiteCoord(burg.x, burg.y); - if (!p) tryWarnMissing(`journey: burg ${leg.id} has invalid x/y`); - return p; - } - - const marker = - ctx.markerById?.get(leg.id) ?? ctx.markers.find((m) => m.i === leg.id); - if (!marker) { - tryWarnMissing(`journey: missing marker ${leg.id}`); - return null; - } - const p = finiteCoord(marker.x, marker.y); - if (!p) tryWarnMissing(`journey: marker ${leg.id} has invalid x/y`); - return p; -} - -/** Resolve `burg:n` / `marker:n` string (editor / DOM). */ -export function resolveJourneyStopPosition( - stopRef: string, - ctx: JourneyResolutionContext, -): [number, number] | null { - const leg = journeyRefStringToLeg(stopRef); - if (!leg) return null; - return resolveJourneyLeg(leg, ctx); -} - -/** One resolved leg and its map coordinate (same order as `journeyResolvedCoordinates` points). */ -interface JourneyResolvedStopEntry { - leg: JourneyStopLeg; - coord: [number, number]; -} - -/** - * Resolve each leg once: coordinates for polyline + waypoint attribution. - * Omits legs that fail to resolve (same sequence as `journeyResolvedCoordinates`). - */ -export function journeyResolvedStopEntries( - j: PackJourney, - ctx: JourneyResolutionContext = { burgs: [], markers: [] }, -): JourneyResolvedStopEntry[] { - const out: JourneyResolvedStopEntry[] = []; - for (const leg of j.stops) { - const p = resolveJourneyLeg(leg, ctx); - if (!p) continue; - out.push({ leg, coord: [p[0], p[1]] }); - } - return out; -} - -export function journeyResolvedCoordinates( - j: PackJourney, - ctx: JourneyResolutionContext = { burgs: [], markers: [] }, -): [number, number][] { - const rows = journeyResolvedStopEntries(j, ctx); - const out: [number, number][] = new Array(rows.length); - for (let i = 0; i < rows.length; i++) out[i] = rows[i].coord; - return out; -} - diff --git a/src/modules/journey-path-geometry.ts b/src/modules/journey-path-geometry.ts deleted file mode 100644 index d4d20489c..000000000 --- a/src/modules/journey-path-geometry.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { rn } from "../utils/numberUtils"; - -/** Quantized directed chord id for lane stacking (A→B ≠ B→A). */ -export function chordKey(a: [number, number], b: [number, number]): string { - return `${rn(a[0], 2)},${rn(a[1], 2)}->${rn(b[0], 2)},${rn(b[1], 2)}`; -} - -/** - * Fraction along chord A→B for point P (clamped to [0, 1]), matching `linearGradient` - * `userSpaceOnUse` with axis from A to B (constant perpendicular to that axis). - */ -export function chordGradientT( - a: [number, number], - b: [number, number], - px: number, - py: number, -): number { - const vx = b[0] - a[0]; - const vy = b[1] - a[1]; - const len2 = vx * vx + vy * vy; - if (len2 < 1e-18) return 0; - const t = ((px - a[0]) * vx + (py - a[1]) * vy) / len2; - return Math.max(0, Math.min(1, t)); -} - -export const MIN_SEG_LEN = 0.05; -const LANE_WIDTH = 3.5; - -/** Baseline arrow spacing at scale 1 (screen-ish); multiplied by tier below. */ -const JOURNEY_ARROW_SPACING_BASE_PX = 38; - -const LOD_TIER_MAX = 6; - -const LOD_ARROW_SPACING_MUL: readonly number[] = [ - 2.25, 1.9, 1.6, 1.35, 1.2, 1.08, 1, -]; - -/** - * Discrete LOD tier from zoom `scale` vs minimum zoom extent (both > 0). - * Tier rises as the user zooms in relative to `zoomMin`. - */ -export function journeyLodTier(scale: number, zoomMin: number): number { - const s = Math.max(scale, 1e-9); - const zmin = Math.max(zoomMin, 1e-9); - const raw = Math.floor(Math.log2(s)) - Math.floor(Math.log2(zmin)); - return Math.max(0, Math.min(LOD_TIER_MAX, raw)); -} - -/** Polyline subdivisions for quadratic sampling; higher tier ⇒ smoother curve. */ -export function journeyPolylineSamplesForTier(tier: number): number { - const t = Math.max(0, Math.min(LOD_TIER_MAX, tier)); - return Math.min(44, Math.max(12, 14 + t * 5)); -} - -/** Fewer arrows when tier is low (zoomed out). */ -export function journeyArrowSpacingMulForTier(tier: number): number { - const t = Math.max(0, Math.min(LOD_TIER_MAX, tier)); - return LOD_ARROW_SPACING_MUL[t] ?? 1; -} - -/** Arrow spacing in map units (~constant screen spacing × LOD density). */ -export function journeyArrowSpacingMapUnits( - scale: number, - tier: number, - spacingPx = JOURNEY_ARROW_SPACING_BASE_PX, -): number { - const k = Math.max(scale, 1e-9); - return (spacingPx * journeyArrowSpacingMulForTier(tier)) / k; -} - -/** Max perpendicular lift at chord midpoint (fraction of chord length), scale ~ repeat index below. */ -const BEND_BASE = 0.14; -/** Extra curvature per reuse count `k` of the same directed chord: bend *= (1 + k * CURVATURE_REPEAT_GAIN). */ -const CURVATURE_REPEAT_GAIN = 0.45; - -/** - * SVG maps use Y-down; negate CCW left normal so traveler‑relative “left” matches screen intuition. - */ -const LEFT_NORMAL_SCREEN_SIGN = -1; - -/** 0-based reuse index per directed chord (first traversal → 0, second identical chord → 1, …). */ -export function directedChordOccurrenceIndex( - points: [number, number][], -): number[] { - const indices: number[] = []; - const counters = new Map(); - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]; - const p1 = points[i + 1]; - if (Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) < MIN_SEG_LEN) { - indices.push(0); - continue; - } - const key = chordKey(p0, p1); - const k = counters.get(key) ?? 0; - indices.push(k); - counters.set(key, k + 1); - } - return indices; -} - -/** Chord midpoint perpendicular bend magnitude after applying repeat scaling. */ -export function bendSegmentChord(len: number, repeatIndex: number): number { - return ( - BEND_BASE * len * (1 + Math.max(0, repeatIndex) * CURVATURE_REPEAT_GAIN) - ); -} - -/** Lane multiplier per segment (centered around 0) for duplicate directed chords. */ -export function laneMultipliersForSegments( - points: [number, number][], -): number[] { - const keys: string[] = []; - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]; - const p1 = points[i + 1]; - if (Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) < MIN_SEG_LEN) { - keys.push(""); - continue; - } - keys.push(chordKey(p0, p1)); - } - - const counts = new Map(); - for (const k of keys) { - if (!k) continue; - counts.set(k, (counts.get(k) ?? 0) + 1); - } - - const idx = new Map(); - const lanes: number[] = []; - for (const k of keys) { - if (!k || (counts.get(k) ?? 0) <= 1) { - lanes.push(0); - continue; - } - const c = counts.get(k)!; - const slot = idx.get(k) ?? 0; - idx.set(k, slot + 1); - lanes.push(slot - (c - 1) / 2); - } - return lanes; -} - -export function quadraticSamples( - a: [number, number], - b: [number, number], - bendAmount: number, - lane: number, - samples: number, -): [number, number][] { - const dx = b[0] - a[0]; - const dy = b[1] - a[1]; - const len = Math.hypot(dx, dy) || 1; - const nx = -dy / len; - const ny = dx / len; - const mx = (a[0] + b[0]) / 2; - const my = (a[1] + b[1]) / 2; - const cx = mx + nx * bendAmount * LEFT_NORMAL_SCREEN_SIGN; - const cy = my + ny * bendAmount * LEFT_NORMAL_SCREEN_SIGN; - - const pts: [number, number][] = []; - for (let i = 0; i <= samples; i++) { - const t = i / samples; - const omt = 1 - t; - let x = omt * omt * a[0] + 2 * omt * t * cx + t * t * b[0]; - let y = omt * omt * a[1] + 2 * omt * t * cy + t * t * b[1]; - const tx = 2 * omt * (cx - a[0]) + 2 * t * (b[0] - cx); - const ty = 2 * omt * (cy - a[1]) + 2 * t * (b[1] - cy); - const tlen = Math.hypot(tx, ty) || 1; - const px = -ty / tlen; - const py = tx / tlen; - const fade = Math.sin(Math.PI * t); - const off = lane * LANE_WIDTH * fade; - x += px * off; - y += py * off; - pts.push([x, y]); - } - return pts; -} - -export function polylinePath(pts: [number, number][]): string { - if (pts.length === 0) return ""; - let d = `M${rn(pts[0][0], 2)},${rn(pts[0][1], 2)}`; - for (let i = 1; i < pts.length; i++) { - d += `L${rn(pts[i][0], 2)},${rn(pts[i][1], 2)}`; - } - return d; -} - -export interface ArrowSample { - x: number; - y: number; - angleDeg: number; -} - -/** Arrow markers spaced along a polyline (map coords). */ -export function arrowPositionsAlongPolyline( - pts: [number, number][], - spacing: number, -): ArrowSample[] { - const result: ArrowSample[] = []; - if (pts.length < 2 || spacing <= 0) return result; - - let cumulative = 0; - let nextAt = spacing; - - for (let i = 0; i < pts.length - 1; i++) { - const x0 = pts[i][0]; - const y0 = pts[i][1]; - const x1 = pts[i + 1][0]; - const y1 = pts[i + 1][1]; - const segLen = Math.hypot(x1 - x0, y1 - y0); - if (segLen < 1e-9) continue; - const angleDeg = (Math.atan2(y1 - y0, x1 - x0) * 180) / Math.PI; - - const segEnd = cumulative + segLen; - while (nextAt <= segEnd + 1e-9) { - const alongSeg = nextAt - cumulative; - const t = alongSeg / segLen; - result.push({ - x: x0 + t * (x1 - x0), - y: y0 + t * (y1 - y0), - angleDeg, - }); - nextAt += spacing; - } - cumulative = segEnd; - } - - return result; -} - -export function polylineLength(pts: [number, number][]): number { - let len = 0; - for (let i = 0; i < pts.length - 1; i++) { - len += Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]); - } - return len; -} - -/** Inclusive interval along the ramp for segment `segmentIndex` of `segmentCount` edges. */ -export function segmentUInterval( - segmentCount: number, - segmentIndex: number, -): [number, number] { - if (segmentCount <= 0) return [0, 0]; - const u0 = segmentIndex / segmentCount; - const u1 = (segmentIndex + 1) / segmentCount; - return [u0, u1]; -} diff --git a/src/modules/journey-style-config.ts b/src/modules/journey-style-config.ts deleted file mode 100644 index 43833ce8c..000000000 --- a/src/modules/journey-style-config.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { interpolateRgbBasis } from "d3"; - -/** Rainbow ramp endpoints used as one continuous gradient sliced per segment. */ -export const JOURNEY_RAINBOW_STOPS = [ - "#e81416", - "#ff7518", - "#ffdc00", - "#32cd32", - "#00bfff", - "#4e529a", - "#70389d", -]; - -/** Default path/arrows color when `data-color-mode` is solid and no `data-solid-stroke`. */ -export const JOURNEY_DEFAULT_SOLID_STROKE = "#5c5c70"; - -/** Single source for Style tab + `readJourneyStyleConfig` (also on `window.Journey.STYLE_DEFAULTS`). */ -export const JOURNEY_STYLE_DEFAULTS = { - lineScreenPx: 6, - waypointRScreenPx: 9, - waypointRingScreenPx: 4.5, - outlineScreenPx: 2, - solidStroke: JOURNEY_DEFAULT_SOLID_STROKE, - waypointFill: "#ffffff", - waypointStroke: "#000000", - outlineColor: "#000000", - /** Gradient picker defaults when `data-rainbow-stops` is unset (match ramp ends). */ - gradientFromHex: "#e81416", - gradientToHex: "#70389d", -} as const; - -export type JourneyColorMode = "rainbow" | "solid"; - -/** Resolved presentation for `#journeys` (from `data-*` + defaults). */ -export interface JourneyStyleConfig { - colorMode: JourneyColorMode; - solidStroke: string; - rainbowStops: readonly string[]; - lineScreenPx: number; - waypointFill: string; - waypointStroke: string; - waypointRScreenPx: number; - waypointRingScreenPx: number; - outlineColor: string; - outlineScreenPx: number; -} - -function clamp(n: number, lo: number, hi: number): number { - return Math.min(hi, Math.max(lo, n)); -} - -const builtinRampInterpolator = interpolateRgbBasis(JOURNEY_RAINBOW_STOPS); - -/** Parse comma-separated hex/color tokens; returns null if fewer than two usable stops. */ -export function parseJourneyRainbowStops(raw: string | null | undefined): string[] | null { - if (raw == null || !String(raw).trim()) return null; - const parts = String(raw) - .split(",") - .map(s => s.trim()) - .filter(Boolean); - return parts.length >= 2 ? parts : null; -} - -/** - * Read journey style from `#journeys` SVG attributes (`data-*`). - * Safe with `null` / missing element (uses {@link JOURNEY_STYLE_DEFAULTS}). - */ -export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { - const get = (name: string): string | null => - el && typeof el.getAttribute === "function" ? el.getAttribute(name) : null; - - const attrPx = (name: string, fallback: number): number => { - const v = Number.parseFloat(get(name) ?? ""); - return Number.isFinite(v) ? v : fallback; - }; - - const modeRaw = (get("data-color-mode") || "rainbow").toLowerCase().trim(); - const colorMode: JourneyColorMode = modeRaw === "solid" ? "solid" : "rainbow"; - - const parsedStops = parseJourneyRainbowStops(get("data-rainbow-stops")); - const rainbowStops = - parsedStops && parsedStops.length >= 2 ? parsedStops : [...JOURNEY_RAINBOW_STOPS]; - - const solidStroke = - get("data-solid-stroke")?.trim() || JOURNEY_STYLE_DEFAULTS.solidStroke; - - const lineScreenPx = clamp( - attrPx("data-line-screen-px", JOURNEY_STYLE_DEFAULTS.lineScreenPx), - 0.5, - 96, - ); - - const waypointFill = - get("data-waypoint-fill")?.trim() || JOURNEY_STYLE_DEFAULTS.waypointFill; - const waypointStroke = - get("data-waypoint-stroke")?.trim() || JOURNEY_STYLE_DEFAULTS.waypointStroke; - - const waypointRScreenPx = clamp( - attrPx("data-waypoint-r-screen-px", JOURNEY_STYLE_DEFAULTS.waypointRScreenPx), - 2, - 120, - ); - - const waypointRingScreenPx = clamp( - attrPx( - "data-waypoint-ring-screen-px", - JOURNEY_STYLE_DEFAULTS.waypointRingScreenPx, - ), - 0, - 48, - ); - - const outlineColor = - get("data-outline-color")?.trim() || JOURNEY_STYLE_DEFAULTS.outlineColor; - - const outlineScreenPx = clamp( - attrPx("data-outline-screen-px", JOURNEY_STYLE_DEFAULTS.outlineScreenPx), - 0, - 32, - ); - - return { - colorMode, - solidStroke, - rainbowStops, - lineScreenPx, - waypointFill, - waypointStroke, - waypointRScreenPx, - waypointRingScreenPx, - outlineColor, - outlineScreenPx, - }; -} - -/** Uniform ramp sampler along one logical journey (same contract as `journeyRampColor`). */ -export function journeyRampSamplerForConfig(cfg: JourneyStyleConfig): (u: number) => string { - if (cfg.colorMode === "solid") { - const c = cfg.solidStroke; - return (_u: number) => c; - } - const stops = cfg.rainbowStops.length >= 2 ? cfg.rainbowStops : JOURNEY_RAINBOW_STOPS; - const interp = interpolateRgbBasis([...stops]); - return (u: number) => interp(Math.max(0, Math.min(1, u))); -} - -/** Parameter `u` in [0, 1] along the whole journey ramp (built-in rainbow). */ -export function journeyRampColor(u: number): string { - return builtinRampInterpolator(Math.max(0, Math.min(1, u))); -} diff --git a/src/modules/journey.ts b/src/modules/journey.ts new file mode 100644 index 000000000..6eb1604d0 --- /dev/null +++ b/src/modules/journey.ts @@ -0,0 +1,1167 @@ +/** + * Journey feature module (merged). + * + * Contains model, style config, geometry, draw, and editor logic. + */ +import type { Selection } from "d3"; +import { interpolateRgbBasis } from "d3"; +import { rn } from "../utils/numberUtils"; + +/** One leg in the journey sequence (linked-list style via array order). */ +interface JourneyBurgLeg { + kind: "burg"; + id: number; +} + +interface JourneyMarkerLeg { + kind: "marker"; + id: number; +} + +export type JourneyStopLeg = JourneyBurgLeg | JourneyMarkerLeg; + +export interface PackJourney { + stops: JourneyStopLeg[]; +} + +/** Minimal pack slice for resolving burg/marker positions (avoids importing PackedGraph). */ +export interface JourneyResolutionContext { + burgs: Array<{ i?: number; x?: number; y?: number; removed?: boolean }>; + markers: Array<{ i?: number; x?: number; y?: number }>; + /** First matching non-removed burg per id (same semantics as linear find). When set, `resolveJourneyLeg` uses O(1) lookup. */ + burgById?: Map; + /** First marker per id (same semantics as linear find). */ + markerById?: Map; +} + +function indexBurgsById( + burgs: JourneyResolutionContext["burgs"], +): Map { + const m = new Map(); + for (const b of burgs) { + if (b.removed) continue; + const id = b.i; + if (id === undefined || typeof id !== "number") continue; + if (!m.has(id)) m.set(id, b); + } + return m; +} + +function indexMarkersById( + markers: JourneyResolutionContext["markers"], +): Map { + const m = new Map(); + for (const mk of markers) { + const id = mk.i; + if (id === undefined || typeof id !== "number") continue; + if (!m.has(id)) m.set(id, mk); + } + return m; +} + +/** Build resolution context with id indexes (preferred for redraw / many stops). */ +export function buildJourneyResolutionContext( + burgs: JourneyResolutionContext["burgs"], + markers: JourneyResolutionContext["markers"], +): JourneyResolutionContext { + return { + burgs, + markers, + burgById: indexBurgsById(burgs), + markerById: indexMarkersById(markers), + }; +} + +const BURG_REF_RE = /^burg:(\d+)$/; +const MARKER_REF_RE = /^marker:(\d+)$/; + +function burgJourneyStopRef(i: number): string { + return `burg:${i}`; +} + +export function markerJourneyStopRef(i: number): string { + return `marker:${i}`; +} + +type ParsedJourneyStopRef = + | { kind: "burg"; id: number } + | { kind: "marker"; id: number }; + +export function journeyLegToRefString(leg: JourneyStopLeg): string { + return leg.kind === "burg" + ? burgJourneyStopRef(leg.id) + : markerJourneyStopRef(leg.id); +} + +function parseJourneyStopRef(stopId: string): ParsedJourneyStopRef | null { + const bm = BURG_REF_RE.exec(stopId); + if (bm) return { kind: "burg", id: +bm[1] }; + const mm = MARKER_REF_RE.exec(stopId); + if (mm) return { kind: "marker", id: +mm[1] }; + return null; +} + +/** UI / DOM string → stored leg (burg or marker only). */ +export function journeyRefStringToLeg(ref: string): JourneyStopLeg | null { + const p = parseJourneyStopRef(ref); + if (!p) return null; + return p.kind === "burg" + ? { kind: "burg", id: p.id } + : { kind: "marker", id: p.id }; +} + +function sanitizeStopsArray(raw: unknown[]): JourneyStopLeg[] { + const out: JourneyStopLeg[] = []; + for (const item of raw) { + if (!item || typeof item !== "object") continue; + const o = item as Record; + const kind = o.kind; + const id = Number(o.id); + if (!Number.isInteger(id) || id < 0) continue; + if (kind === "burg") out.push({ kind: "burg", id }); + else if (kind === "marker") out.push({ kind: "marker", id }); + } + return out; +} + +type PackWithOptionalJourney = { + journey?: unknown; +}; + +/** + * Ensures `pack.journey` exists and mutates it to canonical `{ stops }` via + * {@link normalizePackJourney}. Single entry point for layers / editor / load. + */ +export function ensurePackJourneyNormalized( + pack: PackWithOptionalJourney, +): void { + if ( + !pack.journey || + typeof pack.journey !== "object" || + Array.isArray(pack.journey) + ) { + pack.journey = { stops: [] }; + } + normalizePackJourney(pack.journey); +} + +/** + * Mutates journey object into canonical `{ stops }` only. + */ +export function normalizePackJourney(j: unknown): void { + if (!j || typeof j !== "object" || Array.isArray(j)) return; + + const obj = j as Record; + + const stops = sanitizeStopsArray( + Array.isArray(obj.stops) ? (obj.stops as unknown[]) : [], + ); + obj.stops = stops; +} + +function finiteCoord(x: unknown, y: unknown): [number, number] | null { + const nx = Number(x); + const ny = Number(y); + if (!Number.isFinite(nx) || !Number.isFinite(ny)) return null; + return [nx, ny]; +} + +function tryWarnMissing(msg: string): void { + try { + const w = + typeof globalThis !== "undefined" && + (globalThis as { WARN?: boolean }).WARN; + if (w) console.warn(msg); + } catch { + /* noop */ + } +} + +/** Resolve one leg to map coordinates, or null if missing. */ +export function resolveJourneyLeg( + leg: JourneyStopLeg, + ctx: JourneyResolutionContext, +): [number, number] | null { + if (leg.kind === "burg") { + const burg = + ctx.burgById?.get(leg.id) ?? + ctx.burgs.find((b) => b.i === leg.id && !b.removed); + if (!burg) { + tryWarnMissing(`journey: missing burg ${leg.id}`); + return null; + } + const p = finiteCoord(burg.x, burg.y); + if (!p) tryWarnMissing(`journey: burg ${leg.id} has invalid x/y`); + return p; + } + + const marker = + ctx.markerById?.get(leg.id) ?? ctx.markers.find((m) => m.i === leg.id); + if (!marker) { + tryWarnMissing(`journey: missing marker ${leg.id}`); + return null; + } + const p = finiteCoord(marker.x, marker.y); + if (!p) tryWarnMissing(`journey: marker ${leg.id} has invalid x/y`); + return p; +} + +/** Resolve `burg:n` / `marker:n` string (editor / DOM). */ +export function resolveJourneyStopPosition( + stopRef: string, + ctx: JourneyResolutionContext, +): [number, number] | null { + const leg = journeyRefStringToLeg(stopRef); + if (!leg) return null; + return resolveJourneyLeg(leg, ctx); +} + +/** One resolved leg and its map coordinate (same order as `journeyResolvedCoordinates` points). */ +interface JourneyResolvedStopEntry { + leg: JourneyStopLeg; + coord: [number, number]; +} + +/** + * Resolve each leg once: coordinates for polyline + waypoint attribution. + * Omits legs that fail to resolve (same sequence as `journeyResolvedCoordinates`). + */ +export function journeyResolvedStopEntries( + j: PackJourney, + ctx: JourneyResolutionContext = { burgs: [], markers: [] }, +): JourneyResolvedStopEntry[] { + const out: JourneyResolvedStopEntry[] = []; + for (const leg of j.stops) { + const p = resolveJourneyLeg(leg, ctx); + if (!p) continue; + out.push({ leg, coord: [p[0], p[1]] }); + } + return out; +} + +export function journeyResolvedCoordinates( + j: PackJourney, + ctx: JourneyResolutionContext = { burgs: [], markers: [] }, +): [number, number][] { + const rows = journeyResolvedStopEntries(j, ctx); + const out: [number, number][] = new Array(rows.length); + for (let i = 0; i < rows.length; i++) out[i] = rows[i].coord; + return out; +} + +/** Rainbow ramp endpoints used as one continuous gradient sliced per segment. */ +const JOURNEY_RAINBOW_STOPS = [ + "#e81416", + "#ff7518", + "#ffdc00", + "#32cd32", + "#00bfff", + "#4e529a", + "#70389d", +]; + +/** Default path/arrows color when `data-color-mode` is solid and no `data-solid-stroke`. */ +export const JOURNEY_DEFAULT_SOLID_STROKE = "#5c5c70"; + +/** Single source for Style tab + `readJourneyStyleConfig` (also on `window.Journey.STYLE_DEFAULTS`). */ +const JOURNEY_STYLE_DEFAULTS = { + lineScreenPx: 6, + waypointRScreenPx: 9, + waypointRingScreenPx: 4.5, + outlineScreenPx: 2, + solidStroke: JOURNEY_DEFAULT_SOLID_STROKE, + waypointFill: "#ffffff", + waypointStroke: "#000000", + outlineColor: "#000000", + /** Gradient picker defaults when `data-rainbow-stops` is unset (match ramp ends). */ + gradientFromHex: "#e81416", + gradientToHex: "#70389d", +} as const; + +type JourneyColorMode = "rainbow" | "solid"; + +/** Resolved presentation for `#journeys` (from `data-*` + defaults). */ +interface JourneyStyleConfig { + colorMode: JourneyColorMode; + solidStroke: string; + rainbowStops: readonly string[]; + lineScreenPx: number; + waypointFill: string; + waypointStroke: string; + waypointRScreenPx: number; + waypointRingScreenPx: number; + outlineColor: string; + outlineScreenPx: number; +} + +function clamp(n: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, n)); +} + +const builtinRampInterpolator = interpolateRgbBasis(JOURNEY_RAINBOW_STOPS); + +/** Parse comma-separated hex/color tokens; returns null if fewer than two usable stops. */ +export function parseJourneyRainbowStops( + raw: string | null | undefined, +): string[] | null { + if (raw == null || !String(raw).trim()) return null; + const parts = String(raw) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return parts.length >= 2 ? parts : null; +} + +/** + * Read journey style from `#journeys` SVG attributes (`data-*`). + * Safe with `null` / missing element (uses {@link JOURNEY_STYLE_DEFAULTS}). + */ +export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { + const get = (name: string): string | null => + el && typeof el.getAttribute === "function" ? el.getAttribute(name) : null; + + const attrPx = (name: string, fallback: number): number => { + const v = Number.parseFloat(get(name) ?? ""); + return Number.isFinite(v) ? v : fallback; + }; + + const modeRaw = (get("data-color-mode") || "rainbow").toLowerCase().trim(); + const colorMode: JourneyColorMode = modeRaw === "solid" ? "solid" : "rainbow"; + + const parsedStops = parseJourneyRainbowStops(get("data-rainbow-stops")); + const rainbowStops = + parsedStops && parsedStops.length >= 2 + ? parsedStops + : [...JOURNEY_RAINBOW_STOPS]; + + const solidStroke = + get("data-solid-stroke")?.trim() || JOURNEY_STYLE_DEFAULTS.solidStroke; + + const lineScreenPx = clamp( + attrPx("data-line-screen-px", JOURNEY_STYLE_DEFAULTS.lineScreenPx), + 0.5, + 96, + ); + + const waypointFill = + get("data-waypoint-fill")?.trim() || JOURNEY_STYLE_DEFAULTS.waypointFill; + const waypointStroke = + get("data-waypoint-stroke")?.trim() || + JOURNEY_STYLE_DEFAULTS.waypointStroke; + + const waypointRScreenPx = clamp( + attrPx( + "data-waypoint-r-screen-px", + JOURNEY_STYLE_DEFAULTS.waypointRScreenPx, + ), + 2, + 120, + ); + + const waypointRingScreenPx = clamp( + attrPx( + "data-waypoint-ring-screen-px", + JOURNEY_STYLE_DEFAULTS.waypointRingScreenPx, + ), + 0, + 48, + ); + + const outlineColor = + get("data-outline-color")?.trim() || JOURNEY_STYLE_DEFAULTS.outlineColor; + + const outlineScreenPx = clamp( + attrPx("data-outline-screen-px", JOURNEY_STYLE_DEFAULTS.outlineScreenPx), + 0, + 32, + ); + + return { + colorMode, + solidStroke, + rainbowStops, + lineScreenPx, + waypointFill, + waypointStroke, + waypointRScreenPx, + waypointRingScreenPx, + outlineColor, + outlineScreenPx, + }; +} + +/** Uniform ramp sampler along one logical journey (same contract as `journeyRampColor`). */ +export function journeyRampSamplerForConfig( + cfg: JourneyStyleConfig, +): (u: number) => string { + if (cfg.colorMode === "solid") { + const c = cfg.solidStroke; + return (_u: number) => c; + } + const stops = + cfg.rainbowStops.length >= 2 ? cfg.rainbowStops : JOURNEY_RAINBOW_STOPS; + const interp = interpolateRgbBasis([...stops]); + return (u: number) => interp(Math.max(0, Math.min(1, u))); +} + +/** Parameter `u` in [0, 1] along the whole journey ramp (built-in rainbow). */ +export function journeyRampColor(u: number): string { + return builtinRampInterpolator(Math.max(0, Math.min(1, u))); +} + +/** Quantized directed chord id for lane stacking (A→B ≠ B→A). */ +export function chordKey(a: [number, number], b: [number, number]): string { + return `${rn(a[0], 2)},${rn(a[1], 2)}->${rn(b[0], 2)},${rn(b[1], 2)}`; +} + +/** + * Fraction along chord A→B for point P (clamped to [0, 1]), matching `linearGradient` + * `userSpaceOnUse` with axis from A to B (constant perpendicular to that axis). + */ +export function chordGradientT( + a: [number, number], + b: [number, number], + px: number, + py: number, +): number { + const vx = b[0] - a[0]; + const vy = b[1] - a[1]; + const len2 = vx * vx + vy * vy; + if (len2 < 1e-18) return 0; + const t = ((px - a[0]) * vx + (py - a[1]) * vy) / len2; + return Math.max(0, Math.min(1, t)); +} + +const MIN_SEG_LEN = 0.05; +const LANE_WIDTH = 3.5; +const JOURNEY_ARROW_SPACING_BASE_PX = 38; +const LOD_TIER_MAX = 6; +const LOD_ARROW_SPACING_MUL: readonly number[] = [ + 2.25, 1.9, 1.6, 1.35, 1.2, 1.08, 1, +]; + +export function journeyLodTier(scale: number, zoomMin: number): number { + const s = Math.max(scale, 1e-9); + const zmin = Math.max(zoomMin, 1e-9); + const raw = Math.floor(Math.log2(s)) - Math.floor(Math.log2(zmin)); + return Math.max(0, Math.min(LOD_TIER_MAX, raw)); +} + +export function journeyPolylineSamplesForTier(tier: number): number { + const t = Math.max(0, Math.min(LOD_TIER_MAX, tier)); + return Math.min(44, Math.max(12, 14 + t * 5)); +} + +export function journeyArrowSpacingMulForTier(tier: number): number { + const t = Math.max(0, Math.min(LOD_TIER_MAX, tier)); + return LOD_ARROW_SPACING_MUL[t] ?? 1; +} + +export function journeyArrowSpacingMapUnits( + scale: number, + tier: number, + spacingPx = JOURNEY_ARROW_SPACING_BASE_PX, +): number { + const k = Math.max(scale, 1e-9); + return (spacingPx * journeyArrowSpacingMulForTier(tier)) / k; +} + +const BEND_BASE = 0.14; +const CURVATURE_REPEAT_GAIN = 0.45; +const LEFT_NORMAL_SCREEN_SIGN = -1; + +export function directedChordOccurrenceIndex( + points: [number, number][], +): number[] { + const indices: number[] = []; + const counters = new Map(); + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]; + const p1 = points[i + 1]; + if (Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) < MIN_SEG_LEN) { + indices.push(0); + continue; + } + const key = chordKey(p0, p1); + const k = counters.get(key) ?? 0; + indices.push(k); + counters.set(key, k + 1); + } + return indices; +} + +export function bendSegmentChord(len: number, repeatIndex: number): number { + return ( + BEND_BASE * len * (1 + Math.max(0, repeatIndex) * CURVATURE_REPEAT_GAIN) + ); +} + +export function laneMultipliersForSegments( + points: [number, number][], +): number[] { + const keys: string[] = []; + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]; + const p1 = points[i + 1]; + if (Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) < MIN_SEG_LEN) { + keys.push(""); + continue; + } + keys.push(chordKey(p0, p1)); + } + const counts = new Map(); + for (const k of keys) { + if (!k) continue; + counts.set(k, (counts.get(k) ?? 0) + 1); + } + const idx = new Map(); + const lanes: number[] = []; + for (const k of keys) { + if (!k || (counts.get(k) ?? 0) <= 1) { + lanes.push(0); + continue; + } + const c = counts.get(k)!; + const slot = idx.get(k) ?? 0; + idx.set(k, slot + 1); + lanes.push(slot - (c - 1) / 2); + } + return lanes; +} + +function quadraticSamples( + a: [number, number], + b: [number, number], + bendAmount: number, + lane: number, + samples: number, +): [number, number][] { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len; + const ny = dx / len; + const mx = (a[0] + b[0]) / 2; + const my = (a[1] + b[1]) / 2; + const cx = mx + nx * bendAmount * LEFT_NORMAL_SCREEN_SIGN; + const cy = my + ny * bendAmount * LEFT_NORMAL_SCREEN_SIGN; + const pts: [number, number][] = []; + for (let i = 0; i <= samples; i++) { + const t = i / samples; + const omt = 1 - t; + let x = omt * omt * a[0] + 2 * omt * t * cx + t * t * b[0]; + let y = omt * omt * a[1] + 2 * omt * t * cy + t * t * b[1]; + const tx = 2 * omt * (cx - a[0]) + 2 * t * (b[0] - cx); + const ty = 2 * omt * (cy - a[1]) + 2 * t * (b[1] - cy); + const tlen = Math.hypot(tx, ty) || 1; + const px = -ty / tlen; + const py = tx / tlen; + const fade = Math.sin(Math.PI * t); + const off = lane * LANE_WIDTH * fade; + x += px * off; + y += py * off; + pts.push([x, y]); + } + return pts; +} + +function polylinePath(pts: [number, number][]): string { + if (pts.length === 0) return ""; + let d = `M${rn(pts[0][0], 2)},${rn(pts[0][1], 2)}`; + for (let i = 1; i < pts.length; i++) + d += `L${rn(pts[i][0], 2)},${rn(pts[i][1], 2)}`; + return d; +} + +interface ArrowSample { + x: number; + y: number; + angleDeg: number; +} + +export function arrowPositionsAlongPolyline( + pts: [number, number][], + spacing: number, +): ArrowSample[] { + const result: ArrowSample[] = []; + if (pts.length < 2 || spacing <= 0) return result; + let cumulative = 0; + let nextAt = spacing; + for (let i = 0; i < pts.length - 1; i++) { + const x0 = pts[i][0]; + const y0 = pts[i][1]; + const x1 = pts[i + 1][0]; + const y1 = pts[i + 1][1]; + const segLen = Math.hypot(x1 - x0, y1 - y0); + if (segLen < 1e-9) continue; + const angleDeg = (Math.atan2(y1 - y0, x1 - x0) * 180) / Math.PI; + const segEnd = cumulative + segLen; + while (nextAt <= segEnd + 1e-9) { + const alongSeg = nextAt - cumulative; + const t = alongSeg / segLen; + result.push({ + x: x0 + t * (x1 - x0), + y: y0 + t * (y1 - y0), + angleDeg, + }); + nextAt += spacing; + } + cumulative = segEnd; + } + return result; +} + +function polylineLength(pts: [number, number][]): number { + let len = 0; + for (let i = 0; i < pts.length - 1; i++) { + len += Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]); + } + return len; +} + +export function segmentUInterval( + segmentCount: number, + segmentIndex: number, +): [number, number] { + if (segmentCount <= 0) return [0, 0]; + const u0 = segmentIndex / segmentCount; + const u1 = (segmentIndex + 1) / segmentCount; + return [u0, u1]; +} + +const ARROW_PATH_D = "M0,-8.4 L22.5,0 L0,8.4 Z"; +const JOURNEY_OUTLINE_FILTER_ID = "journeyUnifiedOutline"; + +function mapMetricScreenToWorld( + screenPx: number, + zoomScale: number, + lo: number, + hi: number, +): number { + const k = Math.max(zoomScale, 1e-9); + return Math.min(hi, Math.max(lo, screenPx / k)); +} + +function arrowTransform( + x: number, + y: number, + angleDeg: number, + zoomScale: number, +): string { + const inv = rn(1 / Math.max(zoomScale, 1e-9), 6); + return `translate(${rn(x, 2)},${rn(y, 2)}) rotate(${rn(angleDeg, 2)}) scale(${inv})`; +} + +function ensureJourneyOutlineFilter( + defs: Selection, + morphologyRadiusMap: number, + floodColor: string, +): void { + defs.select(`filter#${JOURNEY_OUTLINE_FILTER_ID}`).remove(); + const f = defs + .append("filter") + .attr("id", JOURNEY_OUTLINE_FILTER_ID) + .attr("class", "journey-outline-filter") + .attr("color-interpolation-filters", "sRGB") + .attr("x", "-50%") + .attr("y", "-50%") + .attr("width", "200%") + .attr("height", "200%"); + f.append("feMorphology") + .attr("in", "SourceAlpha") + .attr("operator", "dilate") + .attr("radius", rn(morphologyRadiusMap, 3)) + .attr("result", "dilatedAlpha"); + f.append("feFlood") + .attr("flood-color", floodColor) + .attr("result", "outlineFlood"); + f.append("feComposite") + .attr("in", "outlineFlood") + .attr("in2", "dilatedAlpha") + .attr("operator", "in") + .attr("result", "outlineShape"); + const merge = f.append("feMerge"); + merge.append("feMergeNode").attr("in", "outlineShape"); + merge.append("feMergeNode").attr("in", "SourceGraphic"); +} + +class JourneyDrawModule { + private lastLodTier: number | null = null; + + redraw( + defs: Selection, + journeys: Selection, + zoomScale = 1, + zoomMinForLod = 0.05, + ): void { + journeys.selectAll("*").remove(); + defs.selectAll("linearGradient.journey-def").remove(); + defs.select(`filter#${JOURNEY_OUTLINE_FILTER_ID}`).remove(); + if (!pack.journey) { + this.lastLodTier = null; + return; + } + ensurePackJourneyNormalized(pack); + const journeyData = pack.journey as PackJourney; + const resCtx = buildJourneyResolutionContext( + pack.burgs ?? [], + pack.markers ?? [], + ); + const resolvedStops = journeyResolvedStopEntries(journeyData, resCtx); + if (!resolvedStops.length) { + this.lastLodTier = null; + return; + } + const points = resolvedStops.map((r) => r.coord); + const zs = zoomScale; + const zm = zoomMinForLod; + const styleCfg = readJourneyStyleConfig(journeys.node()); + const rampAt = journeyRampSamplerForConfig(styleCfg); + const verts = journeys.append("g").attr("class", "journey-vertices"); + const waypointR = mapMetricScreenToWorld( + styleCfg.waypointRScreenPx, + zs, + 0.15, + 80, + ); + const waypointSw = mapMetricScreenToWorld( + styleCfg.waypointRingScreenPx, + zs, + 0.03, + 24, + ); + const idsAtCoord = new Map(); + for (const { leg, coord } of resolvedStops) { + const sid = journeyLegToRefString(leg); + const ck = `${rn(coord[0], 2)},${rn(coord[1], 2)}`; + const arr = idsAtCoord.get(ck) ?? []; + if (!arr.includes(sid)) arr.push(sid); + idsAtCoord.set(ck, arr); + } + const seen = new Set(); + for (const [x, y] of points) { + const k = `${rn(x, 2)},${rn(y, 2)}`; + if (seen.has(k)) continue; + seen.add(k); + const jidList = idsAtCoord.get(k); + const circle = verts + .append("circle") + .attr("class", "journey-waypoint") + .attr("data-jx", rn(x, 2)) + .attr("data-jy", rn(y, 2)) + .attr("cx", rn(x, 2)) + .attr("cy", rn(y, 2)) + .attr("r", rn(waypointR, 3)) + .attr("fill", styleCfg.waypointFill) + .attr("stroke", styleCfg.waypointStroke) + .attr("stroke-width", rn(waypointSw, 3)) + .style("cursor", "pointer"); + if (jidList?.length === 1) + circle.attr("data-journey-stop-ref", jidList[0]); + } + const S = Math.max(0, points.length - 1); + if (S < 1) { + this.lastLodTier = journeyLodTier(zs, zm); + return; + } + const tier = journeyLodTier(zs, zm); + const samples = journeyPolylineSamplesForTier(tier); + const arrowSpacing = journeyArrowSpacingMapUnits(zs, tier); + const morphR = mapMetricScreenToWorld( + styleCfg.outlineScreenPx, + zs, + 0.35, + 40, + ); + ensureJourneyOutlineFilter(defs, morphR, styleCfg.outlineColor); + const segmentsRoot = journeys.append("g").attr("class", "journey-segments"); + const lanes = laneMultipliersForSegments(points); + const repeats = directedChordOccurrenceIndex(points); + const strokeW = mapMetricScreenToWorld(styleCfg.lineScreenPx, zs, 0.06, 24); + for (let i = 0; i < S; i++) { + const a = points[i]; + const b = points[i + 1]; + const segLen = Math.hypot(b[0] - a[0], b[1] - a[1]); + if (segLen < MIN_SEG_LEN) continue; + const lane = lanes[i] ?? 0; + const k = repeats[i] ?? 0; + const bendAmount = bendSegmentChord(segLen, k); + const samp = quadraticSamples(a, b, bendAmount, lane, samples); + const d = polylinePath(samp); + if (!d) continue; + const [u0, u1] = segmentUInterval(S, i); + const c0 = rampAt(u0); + const c1 = rampAt(u1); + const seg = segmentsRoot + .append("g") + .attr("class", "journey-segment") + .attr("filter", `url(#${JOURNEY_OUTLINE_FILTER_ID})`); + let strokeAttr: string; + if (styleCfg.colorMode === "solid") strokeAttr = styleCfg.solidStroke; + else { + const gid = `journeyGrad_${i}`; + const grad = defs + .append("linearGradient") + .attr("id", gid) + .attr("class", "journey-def") + .attr("gradientUnits", "userSpaceOnUse") + .attr("x1", a[0]) + .attr("y1", a[1]) + .attr("x2", b[0]) + .attr("y2", b[1]); + grad.append("stop").attr("offset", "0%").attr("stop-color", c0); + grad.append("stop").attr("offset", "100%").attr("stop-color", c1); + strokeAttr = `url(#${gid})`; + } + seg + .append("path") + .attr("class", "journey-segment-stroke") + .attr("d", d) + .attr("fill", "none") + .attr("stroke", strokeAttr) + .attr("stroke-width", rn(strokeW, 3)) + .attr("stroke-linecap", "round") + .attr("stroke-linejoin", "round"); + let arrPts = arrowPositionsAlongPolyline(samp, arrowSpacing); + if (!arrPts.length && polylineLength(samp) > MIN_SEG_LEN) { + const mid = Math.max(1, Math.floor(samp.length / 2)); + const prev = mid - 1; + const angleDeg = + (Math.atan2( + samp[mid][1] - samp[prev][1], + samp[mid][0] - samp[prev][0], + ) * + 180) / + Math.PI; + arrPts = [{ x: samp[mid][0], y: samp[mid][1], angleDeg }]; + } + for (const ar of arrPts) { + const gt = chordGradientT(a, b, ar.x, ar.y); + const arrowColor = rampAt(u0 + gt * (u1 - u0)); + seg + .append("path") + .attr("class", "journey-arrow") + .attr("d", ARROW_PATH_D) + .attr("fill", arrowColor) + .attr("data-ar-x", rn(ar.x, 2)) + .attr("data-ar-y", rn(ar.y, 2)) + .attr("data-ar-ang", rn(ar.angleDeg, 2)) + .attr("transform", arrowTransform(ar.x, ar.y, ar.angleDeg, zs)); + } + } + this.lastLodTier = tier; + } + + syncZoom( + defs: Selection, + journeys: Selection, + zoomScale = 1, + zoomMinForLod = 0.05, + ): void { + if (!pack.journey) return; + ensurePackJourneyNormalized(pack); + const points = journeyResolvedCoordinates( + pack.journey as PackJourney, + buildJourneyResolutionContext(pack.burgs ?? [], pack.markers ?? []), + ); + if (!points.length) return; + const zs = zoomScale; + const zm = zoomMinForLod; + const tier = journeyLodTier(zs, zm); + const S = Math.max(0, points.length - 1); + if (S >= 1 && this.lastLodTier !== tier) { + this.redraw(defs, journeys, zs, zm); + return; + } + this.applyZoomSizing(defs, journeys, zs); + } + + private applyZoomSizing( + defs: Selection, + journeys: Selection, + zoomScale: number, + ): void { + const zs = Math.max(zoomScale, 1e-9); + const styleCfg = readJourneyStyleConfig(journeys.node()); + const strokeW = mapMetricScreenToWorld(styleCfg.lineScreenPx, zs, 0.06, 24); + journeys + .selectAll(".journey-segment-stroke") + .attr("stroke-width", rn(strokeW, 3)); + const waypointR = mapMetricScreenToWorld( + styleCfg.waypointRScreenPx, + zs, + 0.15, + 80, + ); + const waypointSw = mapMetricScreenToWorld( + styleCfg.waypointRingScreenPx, + zs, + 0.03, + 24, + ); + journeys + .selectAll(".journey-waypoint") + .attr("r", rn(waypointR, 3)) + .attr("stroke-width", rn(waypointSw, 3)); + journeys.selectAll(".journey-arrow").each(function () { + const el = this as SVGPathElement; + const x = el.getAttribute("data-ar-x"); + const y = el.getAttribute("data-ar-y"); + const ang = el.getAttribute("data-ar-ang"); + if (x == null || y == null || ang == null) return; + el.setAttribute("transform", arrowTransform(+x, +y, +ang, zoomScale)); + }); + const morphR = mapMetricScreenToWorld( + styleCfg.outlineScreenPx, + zs, + 0.35, + 40, + ); + const filt = defs.select(`filter#${JOURNEY_OUTLINE_FILTER_ID}`); + filt.select("feMorphology").attr("radius", rn(morphR, 3)); + filt.select("feFlood").attr("flood-color", styleCfg.outlineColor); + } +} + +/** Minimal facade consumed by legacy JS modules. */ +type JourneyGlobalApi = { + STYLE_DEFAULTS: typeof JOURNEY_STYLE_DEFAULTS; + ensurePackJourneyNormalized: typeof ensurePackJourneyNormalized; +}; + +function escapeAttr(s: string): string { + return String(s).replace(/&/g, "&").replace(/"/g, """); +} + +function escapeText(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function journeyStopSelectOptions(currentRef: string): string { + ensurePackJourneyNormalized(pack); + let html = ""; + const known = new Set(); + if (!currentRef) + html += + ''; + html += ''; + for (const m of pack.markers || []) { + if (m.i == null || !Number.isFinite(m.x) || !Number.isFinite(m.y)) continue; + const ref = markerJourneyStopRef(m.i); + known.add(ref); + const sel = ref === currentRef ? " selected" : ""; + const typeLabel = m.type ? String(m.type) : "Marker"; + const label = `${typeLabel} #${m.i} (${rn(m.x, 2)}, ${rn(m.y, 2)})`; + html += ``; + } + html += ''; + for (const b of pack.burgs || []) { + if ( + b.removed || + b.i == null || + !Number.isFinite(b.x) || + !Number.isFinite(b.y) + ) + continue; + const ref = burgJourneyStopRef(b.i); + known.add(ref); + const sel = ref === currentRef ? " selected" : ""; + const nm = + b.name && String(b.name).trim() !== "" ? String(b.name) : `Burg ${b.i}`; + const label = `${nm} (${rn(b.x, 2)}, ${rn(b.y, 2)})`; + html += ``; + } + html += ""; + if (currentRef && !known.has(currentRef)) { + html += ``; + } + return html; +} + +function journeyRenderStopRows(container: HTMLElement): void { + ensurePackJourneyNormalized(pack); + const stops = pack.journey!.stops; + const rows = stops.length === 0 ? [null] : stops; + rows.forEach((leg, i) => { + const currentRef = leg ? journeyLegToRefString(leg) : ""; + const showRemove = stops.length > 0; + const removeStyle = showRemove + ? "" + : "visibility:hidden;pointer-events:none"; + container.insertAdjacentHTML( + "beforeend", + `
    + #${i + 1} + + +
    `, + ); + }); +} + +function journeyEditorRefreshBody(): void { + const stBody = ensureEl("journeyEditorStopsBody"); + stBody.innerHTML = ""; + ensurePackJourneyNormalized(pack); + journeyRenderStopRows(stBody); +} + +function journeyEditorRootChange(ev: Event): void { + const t = ev.target as HTMLElement; + if (t.classList.contains("journey-stop-select")) { + const row = t.closest("[data-stop-index]"); + if (!row) return; + const idx = +(row as HTMLElement).dataset.stopIndex!; + if (!Number.isFinite(idx)) return; + const val = (t as HTMLSelectElement).value; + ensurePackJourneyNormalized(pack); + if (!val) return; + const leg = journeyRefStringToLeg(val); + if (!leg) return; + const stops = pack.journey!.stops; + if (stops.length === 0) stops.push(leg); + else stops[idx] = leg; + journeyEditorRefreshBody(); + drawJourney(); + } +} + +function journeyEditorRootClick(ev: Event): void { + const t = ev.target as HTMLElement; + if (t.classList.contains("journey-stop-remove")) { + const row = t.closest("[data-stop-index]"); + if (!row) return; + const idx = +(row as HTMLElement).dataset.stopIndex!; + if (!Number.isFinite(idx)) return; + ensurePackJourneyNormalized(pack); + pack.journey!.stops.splice(idx, 1); + journeyEditorRefreshBody(); + drawJourney(); + } +} + +function journeyAppendStopRef(stopRef: string): void { + ensurePackJourneyNormalized(pack); + const ctx = buildJourneyResolutionContext( + pack.burgs ?? [], + pack.markers ?? [], + ); + if (!resolveJourneyStopPosition(stopRef, ctx)) return; + const leg = journeyRefStringToLeg(stopRef); + if (!leg) return; + pack.journey!.stops.push(leg); + journeyEditorRefreshBody(); + drawJourney(); +} + +function journeyEditorAddLegClick(): void { + ensurePackJourneyNormalized(pack); + const stops = pack.journey!.stops; + if (!stops.length) { + tip( + "Choose the first stop in the Journey row (marker or burg), then use + to add legs.", + false, + "warn", + ); + return; + } + stops.push(stops[stops.length - 1]); + journeyEditorRefreshBody(); + drawJourney(); +} + +function journeyEditorOnClick(): void { + const d3g = globalThis as typeof globalThis & { + d3?: { event?: { sourceEvent?: Event } }; + }; + const evt = d3g.d3?.event?.sourceEvent ?? window.event; + const target = evt?.target as HTMLElement | undefined; + let circleEl: Element | null = null; + if (target?.classList?.contains("journey-waypoint")) circleEl = target; + else if (target?.closest?.(".journey-waypoint")) + circleEl = target.closest(".journey-waypoint"); + if (circleEl) { + const stopRef = circleEl.getAttribute("data-journey-stop-ref"); + if (stopRef) { + journeyAppendStopRef(stopRef); + return; + } + return; + } + tip( + "Add stops from the Journey dropdown (markers and burgs only).", + false, + "info", + ); +} + +function closeJourneyEditor(): void { + ensureEl("journeyEditorStopsBody").innerHTML = ""; + viewbox.on("click.journey", null).style("cursor", null); + clearMainTip(); + restoreDefaultEvents(); +} + +function editJourney(): void { + if (customization) return; + closeDialogs("#journeyEditor, .stable"); + ensurePackJourneyNormalized(pack); + if (!layerIsOn("toggleJourney")) toggleJourney(); + tip( + "Build the path with markers and burgs only—each leg follows live map positions. Use + to repeat the last stop. Click a journey circle to append that stop again. Undo / Clear affect the path only.", + true, + ); + viewbox.style("cursor", "default").on("click.journey", journeyEditorOnClick); + $("#journeyEditor").dialog({ + title: "Journey editor", + resizable: false, + position: { my: "left top", at: "left+10 top+10", of: "#map" }, + close: closeJourneyEditor, + }); + if (modules.editJourney) { + journeyEditorRefreshBody(); + return; + } + modules.editJourney = true; + $("#journeyEditorRoot") + .on("change.journeyEd", journeyEditorRootChange) + .on("click.journeyEd", journeyEditorRootClick); + $("#journeyEditorAddLeg").on("click.journeyEd", journeyEditorAddLegClick); + $("#journeyEditorUndo").on("click.journeyEd", () => { + ensurePackJourneyNormalized(pack); + pack.journey!.stops.pop(); + journeyEditorRefreshBody(); + drawJourney(); + }); + $("#journeyEditorClear").on("click.journeyEd", () => { + ensurePackJourneyNormalized(pack); + pack.journey!.stops = []; + journeyEditorRefreshBody(); + drawJourney(); + }); + $("#journeyEditorDone").on("click.journeyEd", () => + $("#journeyEditor").dialog("close"), + ); + journeyEditorRefreshBody(); +} + +if (typeof window !== "undefined") { + window.JourneyDraw = new JourneyDrawModule(); + const journeyApi: JourneyGlobalApi = { + STYLE_DEFAULTS: JOURNEY_STYLE_DEFAULTS, + ensurePackJourneyNormalized, + }; + window.Journey = journeyApi; + window.editJourney = editJourney; +} + +declare global { + var pack: import("../types/PackedGraph").PackedGraph; + interface Window { + JourneyDraw?: JourneyDrawModule; + Journey?: JourneyGlobalApi; + } +} From 3594878a71f2b1d31f911b1e8d634454e61f8751 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 13:23:00 +0200 Subject: [PATCH 35/48] Refactor namesbase-editor and journey modules to utilize escapeHtml function for HTML string safety. Replace clamp function with minmax for improved range handling in journey module. Clean up unused escape functions and enhance clarity in journeyStopSelectOptions. --- src/controllers/namesbase-editor.ts | 9 +----- src/modules/journey.ts | 48 ++++++++++------------------- src/utils/stringUtils.ts | 14 +++++++++ 3 files changed, 32 insertions(+), 39 deletions(-) diff --git a/src/controllers/namesbase-editor.ts b/src/controllers/namesbase-editor.ts index e426c4d22..427507578 100644 --- a/src/controllers/namesbase-editor.ts +++ b/src/controllers/namesbase-editor.ts @@ -1,5 +1,6 @@ import { max as d3max, min as d3min, mean, median } from "d3"; import { ensureEl, openURL, rn, unique } from "../utils"; +import { escapeHtml } from "../utils/stringUtils"; addListeners(); @@ -397,14 +398,6 @@ function namesbaseUpload(dataLoaded: string, override = true): void { const unsafe = /[|/]/g; -const escapeHtml = (str: string): string => - str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - interface ParseError { id: number; line: string; diff --git a/src/modules/journey.ts b/src/modules/journey.ts index 6eb1604d0..125bf060c 100644 --- a/src/modules/journey.ts +++ b/src/modules/journey.ts @@ -5,7 +5,8 @@ */ import type { Selection } from "d3"; import { interpolateRgbBasis } from "d3"; -import { rn } from "../utils/numberUtils"; +import { minmax, rn } from "../utils/numberUtils"; +import { escapeHtml } from "../utils/stringUtils"; /** One leg in the journey sequence (linked-list style via array order). */ interface JourneyBurgLeg { @@ -294,10 +295,6 @@ interface JourneyStyleConfig { outlineScreenPx: number; } -function clamp(n: number, lo: number, hi: number): number { - return Math.min(hi, Math.max(lo, n)); -} - const builtinRampInterpolator = interpolateRgbBasis(JOURNEY_RAINBOW_STOPS); /** Parse comma-separated hex/color tokens; returns null if fewer than two usable stops. */ @@ -337,7 +334,7 @@ export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { const solidStroke = get("data-solid-stroke")?.trim() || JOURNEY_STYLE_DEFAULTS.solidStroke; - const lineScreenPx = clamp( + const lineScreenPx = minmax( attrPx("data-line-screen-px", JOURNEY_STYLE_DEFAULTS.lineScreenPx), 0.5, 96, @@ -349,7 +346,7 @@ export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { get("data-waypoint-stroke")?.trim() || JOURNEY_STYLE_DEFAULTS.waypointStroke; - const waypointRScreenPx = clamp( + const waypointRScreenPx = minmax( attrPx( "data-waypoint-r-screen-px", JOURNEY_STYLE_DEFAULTS.waypointRScreenPx, @@ -358,7 +355,7 @@ export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { 120, ); - const waypointRingScreenPx = clamp( + const waypointRingScreenPx = minmax( attrPx( "data-waypoint-ring-screen-px", JOURNEY_STYLE_DEFAULTS.waypointRingScreenPx, @@ -370,7 +367,7 @@ export function readJourneyStyleConfig(el: Element | null): JourneyStyleConfig { const outlineColor = get("data-outline-color")?.trim() || JOURNEY_STYLE_DEFAULTS.outlineColor; - const outlineScreenPx = clamp( + const outlineScreenPx = minmax( attrPx("data-outline-screen-px", JOURNEY_STYLE_DEFAULTS.outlineScreenPx), 0, 32, @@ -401,12 +398,12 @@ export function journeyRampSamplerForConfig( const stops = cfg.rainbowStops.length >= 2 ? cfg.rainbowStops : JOURNEY_RAINBOW_STOPS; const interp = interpolateRgbBasis([...stops]); - return (u: number) => interp(Math.max(0, Math.min(1, u))); + return (u: number) => interp(minmax(u, 0, 1)); } /** Parameter `u` in [0, 1] along the whole journey ramp (built-in rainbow). */ export function journeyRampColor(u: number): string { - return builtinRampInterpolator(Math.max(0, Math.min(1, u))); + return builtinRampInterpolator(minmax(u, 0, 1)); } /** Quantized directed chord id for lane stacking (A→B ≠ B→A). */ @@ -429,7 +426,7 @@ export function chordGradientT( const len2 = vx * vx + vy * vy; if (len2 < 1e-18) return 0; const t = ((px - a[0]) * vx + (py - a[1]) * vy) / len2; - return Math.max(0, Math.min(1, t)); + return minmax(t, 0, 1); } const MIN_SEG_LEN = 0.05; @@ -444,16 +441,16 @@ export function journeyLodTier(scale: number, zoomMin: number): number { const s = Math.max(scale, 1e-9); const zmin = Math.max(zoomMin, 1e-9); const raw = Math.floor(Math.log2(s)) - Math.floor(Math.log2(zmin)); - return Math.max(0, Math.min(LOD_TIER_MAX, raw)); + return minmax(raw, 0, LOD_TIER_MAX); } export function journeyPolylineSamplesForTier(tier: number): number { - const t = Math.max(0, Math.min(LOD_TIER_MAX, tier)); - return Math.min(44, Math.max(12, 14 + t * 5)); + const t = minmax(tier, 0, LOD_TIER_MAX); + return minmax(14 + t * 5, 12, 44); } export function journeyArrowSpacingMulForTier(tier: number): number { - const t = Math.max(0, Math.min(LOD_TIER_MAX, tier)); + const t = minmax(tier, 0, LOD_TIER_MAX); return LOD_ARROW_SPACING_MUL[t] ?? 1; } @@ -639,7 +636,7 @@ function mapMetricScreenToWorld( hi: number, ): number { const k = Math.max(zoomScale, 1e-9); - return Math.min(hi, Math.max(lo, screenPx / k)); + return minmax(screenPx / k, lo, hi); } function arrowTransform( @@ -929,17 +926,6 @@ type JourneyGlobalApi = { ensurePackJourneyNormalized: typeof ensurePackJourneyNormalized; }; -function escapeAttr(s: string): string { - return String(s).replace(/&/g, "&").replace(/"/g, """); -} - -function escapeText(s: string): string { - return String(s) - .replace(/&/g, "&") - .replace(//g, ">"); -} - function journeyStopSelectOptions(currentRef: string): string { ensurePackJourneyNormalized(pack); let html = ""; @@ -955,7 +941,7 @@ function journeyStopSelectOptions(currentRef: string): string { const sel = ref === currentRef ? " selected" : ""; const typeLabel = m.type ? String(m.type) : "Marker"; const label = `${typeLabel} #${m.i} (${rn(m.x, 2)}, ${rn(m.y, 2)})`; - html += ``; + html += ``; } html += ''; for (const b of pack.burgs || []) { @@ -972,11 +958,11 @@ function journeyStopSelectOptions(currentRef: string): string { const nm = b.name && String(b.name).trim() !== "" ? String(b.name) : `Burg ${b.i}`; const label = `${nm} (${rn(b.x, 2)}, ${rn(b.y, 2)})`; - html += ``; + html += ``; } html += ""; if (currentRef && !known.has(currentRef)) { - html += ``; + html += ``; } return html; } diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts index d004d565e..f3a82b010 100644 --- a/src/utils/stringUtils.ts +++ b/src/utils/stringUtils.ts @@ -94,6 +94,20 @@ export const safeParseJSON = (str: string) => { } }; +/** + * Escape a string for safe inclusion in HTML text content or + * double-quoted attribute values. + * @param str - The input value (coerced to string) + * @returns The escaped string + */ +export const escapeHtml = (str: string): string => + String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + /** * Sanitize a string to be used as an ID * @param {string} inputString - The input string From 64ba9e6523a8fe5aab1e647d7c36bf6a70364342 Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 13:33:14 +0200 Subject: [PATCH 36/48] Add editJourney method to global interface and import ensureEl utility in journey module --- src/modules/journey.ts | 2 ++ src/types/global.ts | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/modules/journey.ts b/src/modules/journey.ts index 125bf060c..c870a23cd 100644 --- a/src/modules/journey.ts +++ b/src/modules/journey.ts @@ -5,6 +5,7 @@ */ import type { Selection } from "d3"; import { interpolateRgbBasis } from "d3"; +import { ensureEl } from "../utils"; import { minmax, rn } from "../utils/numberUtils"; import { escapeHtml } from "../utils/stringUtils"; @@ -1149,5 +1150,6 @@ declare global { interface Window { JourneyDraw?: JourneyDrawModule; Journey?: JourneyGlobalApi; + editJourney?: () => void; } } diff --git a/src/types/global.ts b/src/types/global.ts index 08d2253e9..dc5571b0c 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -99,14 +99,11 @@ declare global { var createDefaultRuler: () => void; var showStatistics: () => void; var closeDialogs: (except?: string) => void; - var ensureEl: (id: string) => HTMLElement; /** Journey layer redraw (public/modules/ui/layers.js). */ var drawJourney: () => void; var toggleJourney: (event?: Event) => void; var clearMainTip: () => void; var restoreDefaultEvents: () => void; - /** Tools / hotkeys entry for journey editor (src/modules/journey-editor.ts). */ - var editJourney: () => void; var customization: number; var editWorld: () => void; var showExportPane: () => void; From 112d4d5a4fba5d9be571125307c9a9ffd9ea3cee Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 15:21:32 +0200 Subject: [PATCH 37/48] Enhance type safety in journey module by explicitly defining the type of rows in journeyRenderStopRows function. Update import path for PackJourney type to align with module structure. --- src/modules/journey.ts | 3 ++- src/types/PackedGraph.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/modules/journey.ts b/src/modules/journey.ts index c870a23cd..128472727 100644 --- a/src/modules/journey.ts +++ b/src/modules/journey.ts @@ -971,7 +971,8 @@ function journeyStopSelectOptions(currentRef: string): string { function journeyRenderStopRows(container: HTMLElement): void { ensurePackJourneyNormalized(pack); const stops = pack.journey!.stops; - const rows = stops.length === 0 ? [null] : stops; + const rows: (JourneyStopLeg | null)[] = + stops.length === 0 ? [null] : stops; rows.forEach((leg, i) => { const currentRef = leg ? journeyLegToRefString(leg) : ""; const showRemove = stops.length > 0; diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 30351e021..1edf7200b 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -6,7 +6,7 @@ import type { Province } from "../modules/provinces-generator"; import type { River } from "../modules/river-generator"; import type { Route } from "../modules/routes-generator"; import type { State } from "../modules/states-generator"; -import type { PackJourney } from "../modules/journey-model"; +import type { PackJourney } from "../modules/journey"; import type { Zone } from "../modules/zones-generator"; export type TypedArray = From 30c115cf4471bc1231f4cff8b5a7a90ab545e00c Mon Sep 17 00:00:00 2001 From: Norbert de Langen Date: Fri, 8 May 2026 16:39:57 +0200 Subject: [PATCH 38/48] allow for many Journeys --- public/main.js | 2 +- public/modules/io/load.js | 16 +- public/modules/io/save.js | 4 +- public/modules/ui/style-presets.js | 3 - public/modules/ui/style.js | 86 --- src/index.html | 105 ++-- src/modules/journey.ts | 963 ++++++++++++++++++++++------- src/types/PackedGraph.ts | 6 +- tests/e2e/journey-layer.spec.ts | 59 +- 9 files changed, 887 insertions(+), 357 deletions(-) diff --git a/public/main.js b/public/main.js index 1a6bf398e..9f3e52fd8 100644 --- a/public/main.js +++ b/public/main.js @@ -655,7 +655,7 @@ async function generate(options) { else delete grid.cells.h; grid.cells.h = await HeightmapGenerator.generate(grid); pack = {}; // reset pack - pack.journey = {stops: []}; + pack.journeys = []; Features.markupGrid(); addLakesInDeepDepressions(); diff --git a/public/modules/io/load.js b/public/modules/io/load.js index d2ea4a537..3ac303f75 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -427,15 +427,21 @@ async function parseLoadedData(data, mapVersion) { parsedJourney = null; } } - const j = + if (Array.isArray(parsedJourney)) { + pack.journeys = parsedJourney; + delete pack.journey; + } else if ( parsedJourney && typeof parsedJourney === "object" && parsedJourney !== null && !Array.isArray(parsedJourney) - ? parsedJourney - : {stops: []}; - pack.journey = j; - if (window.Journey) window.Journey.ensurePackJourneyNormalized(pack); + ) { + pack.journey = parsedJourney; + } + if (window.Journey) + window.Journey.ensurePackJourneysNormalized + ? window.Journey.ensurePackJourneysNormalized(pack) + : window.Journey.ensurePackJourneyNormalized(pack); } if (data[31]) { diff --git a/public/modules/io/save.js b/public/modules/io/save.js index ca5b93a52..f6402a415 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -103,7 +103,9 @@ function prepareMapData() { const routes = JSON.stringify(pack.routes); const zones = JSON.stringify(pack.zones); const ice = JSON.stringify(pack.ice); - const journey = JSON.stringify(pack.journey || {stops: []}); + const journey = JSON.stringify( + Array.isArray(pack.journeys) ? pack.journeys : [], + ); // store name array only if not the same as default const defaultNB = Names.getNameBases(); diff --git a/public/modules/ui/style-presets.js b/public/modules/ui/style-presets.js index fc2a1464f..33570a205 100644 --- a/public/modules/ui/style-presets.js +++ b/public/modules/ui/style-presets.js @@ -258,9 +258,6 @@ function addStylePreset() { "#journeys": [ "opacity", "filter", - "data-color-mode", - "data-solid-stroke", - "data-rainbow-stops", "data-line-screen-px", "data-waypoint-fill", "data-waypoint-stroke", diff --git a/public/modules/ui/style.js b/public/modules/ui/style.js index 35251ded1..b7e2fa26a 100644 --- a/public/modules/ui/style.js +++ b/public/modules/ui/style.js @@ -107,53 +107,6 @@ function journeyUiDefaults() { }; } -function journeyParseStopsList(raw) { - if (raw == null || !String(raw).trim()) return null; - const parts = String(raw) - .split(",") - .map(s => s.trim()) - .filter(Boolean); - return parts.length >= 2 ? parts : null; -} - -function journeyPopulateRainbowUi(j) { - const raw = j.attr("data-rainbow-stops") || ""; - ensureEl("styleJourneyRainbowStops").value = raw.trim() === "" ? "" : raw; - - const parsed = journeyParseStopsList(raw); - let fromHex; - let toHex; - const def = journeyUiDefaults(); - if (parsed && parsed.length >= 2) { - fromHex = journeyStyleHexForPicker(parsed[0], def.gradientFromHex); - toHex = journeyStyleHexForPicker(parsed[parsed.length - 1], def.gradientToHex); - } else { - fromHex = def.gradientFromHex; - toHex = def.gradientToHex; - } - ensureEl("styleJourneyGradientFrom").value = fromHex; - ensureEl("styleJourneyGradientFromOutput").value = fromHex; - ensureEl("styleJourneyGradientTo").value = toHex; - ensureEl("styleJourneyGradientToOutput").value = toHex; -} - -function journeyStyleSyncRowVisibility() { - const solid = ensureEl("styleJourneyColorMode").value === "solid"; - ensureEl("styleJourneySolidStroke").closest("tr").style.display = solid ? "" : "none"; - const showGrad = !solid; - ensureEl("styleJourneyGradientFrom").closest("tr").style.display = showGrad ? "" : "none"; - ensureEl("styleJourneyGradientTo").closest("tr").style.display = showGrad ? "" : "none"; - ensureEl("styleJourneyRainbowStops").closest("tr").style.display = showGrad ? "" : "none"; -} - -function journeyWriteRainbowFromPickers() { - ensureEl("styleJourneyRainbowStops").value = ""; - const from = ensureEl("styleJourneyGradientFrom").value; - const to = ensureEl("styleJourneyGradientTo").value; - svg.select("#journeys").attr("data-rainbow-stops", `${from},${to}`); - redrawJourneyIfVisible(); -} - function redrawJourneyIfVisible() { if (typeof drawJourney === "function" && layerIsOn("toggleJourney")) drawJourney(); } @@ -295,12 +248,6 @@ function selectStyleElement() { const v = parseFloat(j.attr(name)); return Number.isFinite(v) ? v : fb; }; - const modeRaw = (j.attr("data-color-mode") || "rainbow").toLowerCase(); - ensureEl("styleJourneyColorMode").value = modeRaw === "solid" ? "solid" : "rainbow"; - const solidHex = journeyStyleHexForPicker(j.attr("data-solid-stroke"), jd.solidStroke); - ensureEl("styleJourneySolidStroke").value = solidHex; - ensureEl("styleJourneySolidStrokeOutput").value = solidHex; - journeyPopulateRainbowUi(j); ensureEl("styleJourneyLineScreenPx").value = numAttr("data-line-screen-px", jd.lineScreenPx); const wpf = journeyStyleHexForPicker(j.attr("data-waypoint-fill"), jd.waypointFill); ensureEl("styleJourneyWaypointFill").value = wpf; @@ -317,7 +264,6 @@ function selectStyleElement() { ensureEl("styleJourneyOutlineColor").value = oc; ensureEl("styleJourneyOutlineColorOutput").value = oc; ensureEl("styleJourneyOutlineScreenPx").value = numAttr("data-outline-screen-px", jd.outlineScreenPx); - journeyStyleSyncRowVisibility(); } if (styleElement === "gridOverlay") { @@ -674,38 +620,6 @@ styleRescaleMarkers.on("change", function () { invokeActiveZooming(); }); -d3.select("#styleJourneyColorMode").on("change", function () { - svg.select("#journeys").attr("data-color-mode", this.value); - journeyStyleSyncRowVisibility(); - redrawJourneyIfVisible(); -}); - -d3.select("#styleJourneySolidStroke").on("input", function () { - ensureEl("styleJourneySolidStrokeOutput").value = this.value; - svg.select("#journeys").attr("data-solid-stroke", this.value); - redrawJourneyIfVisible(); -}); - -d3.select("#styleJourneyGradientFrom").on("input", function () { - ensureEl("styleJourneyGradientFromOutput").value = this.value; - journeyWriteRainbowFromPickers(); -}); - -d3.select("#styleJourneyGradientTo").on("input", function () { - ensureEl("styleJourneyGradientToOutput").value = this.value; - journeyWriteRainbowFromPickers(); -}); - -d3.select("#styleJourneyRainbowStops").on("input", function () { - const v = this.value.trim(); - const jn = svg.select("#journeys"); - if (v === "") jn.attr("data-rainbow-stops", null); - else jn.attr("data-rainbow-stops", v); - journeyPopulateRainbowUi(jn); - journeyStyleSyncRowVisibility(); - redrawJourneyIfVisible(); -}); - d3.select("#styleJourneyLineScreenPx").on("input", function () { svg.select("#journeys").attr("data-line-screen-px", this.value); redrawJourneyIfVisible(); diff --git a/src/index.html b/src/index.html index 36544fccf..c26e1991f 100644 --- a/src/index.html +++ b/src/index.html @@ -1489,47 +1489,7 @@ - - Color mode - - - - - - Solid color - - - - - - - Gradient from - - - - - - - Gradient to - - - - - - - Gradient stops - - - - + Path outline color @@ -3210,10 +3170,69 @@