From 6e635320e5a8311fe6ba0fe0191bba014b0b0f64 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Fri, 9 Oct 2026 08:23:16 +0200 Subject: [PATCH] Keep several Ziele and fill the Cache from Einstellungen. A Koordinate adds another place, and Hessen fetches only missing Kacheln without marking a Gebiet. Co-authored-by: Cursor --- src/adapters/marks-db.ts | 19 +- src/karte/fakes.ts | 4 +- src/karte/koordinate.ts | 12 +- src/karte/session.test.ts | 516 +++++++++++++++++++++++++------------- src/karte/session.ts | 206 +++++++-------- src/karte/types.ts | 35 +-- src/style.css | 38 ++- src/ui/map.ts | 24 +- src/ui/screen.ts | 134 +++++----- 9 files changed, 601 insertions(+), 387 deletions(-) diff --git a/src/adapters/marks-db.ts b/src/adapters/marks-db.ts index 5426393..c291801 100644 --- a/src/adapters/marks-db.ts +++ b/src/adapters/marks-db.ts @@ -21,6 +21,18 @@ function stringsFrom(value: unknown): string[] { return value.filter((item): item is string => typeof item === "string"); } +function rememberedFrom(value: unknown): { heruntergeladen: string[]; ziele: string[] } { + if (Array.isArray(value)) return { heruntergeladen: stringsFrom(value), ziele: [] }; + if (value && typeof value === "object") { + const record = value as { heruntergeladen?: unknown; ziele?: unknown }; + return { + heruntergeladen: stringsFrom(record.heruntergeladen), + ziele: stringsFrom(record.ziele), + }; + } + return { heruntergeladen: [], ziele: [] }; +} + function settle(tx: IDBTransaction, value: () => unknown): Promise { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(value()); @@ -37,7 +49,7 @@ export function createMarksDb(): MarksPort { const tx = db.transaction(STORE, "readonly"); const request = tx.objectStore(STORE).get(KEY); const value = await settle(tx, () => request.result); - return { heruntergeladen: stringsFrom(value) }; + return rememberedFrom(value); } finally { db.close(); } @@ -47,7 +59,10 @@ export function createMarksDb(): MarksPort { const db = await openDatabase(); try { const tx = db.transaction(STORE, "readwrite"); - tx.objectStore(STORE).put(state.heruntergeladen, KEY); + tx.objectStore(STORE).put( + { heruntergeladen: state.heruntergeladen, ziele: state.ziele }, + KEY, + ); await settle(tx, () => undefined); } finally { db.close(); diff --git a/src/karte/fakes.ts b/src/karte/fakes.ts index 37629cf..deae44d 100644 --- a/src/karte/fakes.ts +++ b/src/karte/fakes.ts @@ -17,12 +17,14 @@ export function createMemoryCache(initial: string[] = []): CachePort { export function createMemoryMarks(initial: string[] = []): MarksPort { let heruntergeladen = [...initial]; + let ziele: string[] = []; return { async read() { - return { heruntergeladen: [...heruntergeladen] }; + return { heruntergeladen: [...heruntergeladen], ziele: [...ziele] }; }, async write(state) { heruntergeladen = [...state.heruntergeladen]; + ziele = [...state.ziele]; }, }; } diff --git a/src/karte/koordinate.ts b/src/karte/koordinate.ts index 83beee1..c6cd0a2 100644 --- a/src/karte/koordinate.ts +++ b/src/karte/koordinate.ts @@ -17,13 +17,14 @@ const GITTER_PATTERN = /^(\d{1,2})([C-HJ-NP-X])([A-HJ-NP-Z]{2})$/; export function parseKoordinate( input: { gitter: string; rechtswert: string; hochwert: string }, blattCovers: (point: Point) => boolean, -): { ok: true; point: Point } | { ok: false; notice: string } { +): { ok: true; point: Point; koordinate: string } | { ok: false; notice: string } { if (!/^\d{5}$/.test(input.rechtswert) || !/^\d{5}$/.test(input.hochwert)) { return { ok: false, notice: DIGITS }; } const compact = input.gitter.trim().toUpperCase().replace(/\s+/g, ""); - if (!GITTER_PATTERN.test(compact)) { + const gitter = GITTER_PATTERN.exec(compact); + if (!gitter) { return { ok: false, notice: GITTER_NOTICE }; } @@ -37,5 +38,10 @@ export function parseKoordinate( const projected = proj4("EPSG:4326", "EPSG:25832", geographic); const point = { easting: projected[0], northing: projected[1] }; if (!blattCovers(point)) return { ok: false, notice: BLATT }; - return { ok: true, point }; + const canonicalGitter = `${gitter[1]}${gitter[2]} ${gitter[3]}`; + return { + ok: true, + point, + koordinate: `${canonicalGitter} ${input.rechtswert} ${input.hochwert}`, + }; } diff --git a/src/karte/session.test.ts b/src/karte/session.test.ts index ca0fbd6..df7839e 100644 --- a/src/karte/session.test.ts +++ b/src/karte/session.test.ts @@ -1,7 +1,7 @@ import { shippedConfig } from "../config"; import { createFetchClock, createMemoryCache, createMemoryMarks } from "./fakes"; import { openKarte } from "./session"; -import type { KarteSession, KarteSnapshot, Point, Uebersicht, Viewport } from "./types"; +import type { KarteSession, Point, Uebersicht, Viewport } from "./types"; const sheet: Uebersicht = { tileSizePx: 10, @@ -71,12 +71,6 @@ function appearance(karte: KarteSession, id: string) { return kachel.appearance; } -function hessen(snapshot: KarteSnapshot) { - const gebiet = snapshot.gebiete.find((item) => item.name === "Hessen"); - if (!gebiet) throw new Error("Hessen missing"); - return gebiet; -} - const sample = { gitter: "32U MA", rechtswert: "67030", hochwert: "78109" }; describe("opening the Karte", () => { @@ -87,7 +81,7 @@ describe("opening the Karte", () => { expect(snapshot.resolution).toBe(7); expect(snapshot.center).toEqual({ easting: 150, northing: 50 }); expect(snapshot.koordinate.gitter).toBe("32U MA"); - expect(snapshot.ziel).toBeNull(); + expect(snapshot.ziele).toEqual([]); expect(snapshot.standort).toBeNull(); }); @@ -243,18 +237,6 @@ describe("fetching a missing Kachel", () => { expect(appearance(opened.karte, "1/0/0")).toBe("blank-downloading"); }); - test("leaving the app pauses the fetch, and opening online continues it", async () => { - const { karte, clock } = await viewTile(250); - karte.setOnline(true); - karte.startGebiet("Hessen"); - karte.setAppOpen(false); - await clock.tick(); - expect(appearance(karte, TILE_C)).toBe("blank-not-on-device"); - karte.setAppOpen(true); - await clock.tick(); - expect(appearance(karte, TILE_C)).toBe("drawn"); - }); - test("a finished Kachel is still drawn after the app is left and opened again", async () => { const cache = createMemoryCache(); const marks = createMemoryMarks(); @@ -270,7 +252,7 @@ describe("fetching a missing Kachel", () => { second.karte.zoomTo(10); second.karte.panTo({ easting: 250, northing: 50 }); expect(appearance(second.karte, TILE_C)).toBe("drawn"); - expect(second.karte.snapshot().ziel).toBeNull(); + expect(second.karte.snapshot().ziele).toEqual([]); }); }); @@ -281,24 +263,29 @@ describe("naming a Ziel", () => { const snapshot = karte.snapshot(); expect(snapshot.koordinate).toEqual({ gitter: "32u ma", rechtswert: "67030", hochwert: "78109" }); expect(snapshot.resolution).toBe(7); - expect(snapshot.ziel).not.toBeNull(); - expect(Math.abs(snapshot.ziel!.easting - 467_030)).toBeLessThan(1); - expect(Math.abs(snapshot.ziel!.northing - 5_578_109)).toBeLessThan(1); + expect(snapshot.ziele).toHaveLength(1); + expect(snapshot.ziele[0].koordinate).toBe("32U MA 67030 78109"); + expect(Math.abs(snapshot.ziele[0].point.easting - 467_030)).toBeLessThan(1); + expect(Math.abs(snapshot.ziele[0].point.northing - 5_578_109)).toBeLessThan(1); + expect(snapshot.ziele[0].entfernung).toBeNull(); + expect(snapshot.center).toEqual(snapshot.ziele[0].point); expect(snapshot.notice).toBe("Nicht auf dem Gerät."); }); test("treats 32UMA like 32U MA", async () => { const { karte } = await openSheet({ uebersicht: wideSquare }); karte.enterKoordinate({ gitter: "32UMA", rechtswert: "67030", hochwert: "78109" }); - expect(Math.abs(karte.snapshot().ziel!.easting - 467_030)).toBeLessThan(1); - expect(karte.snapshot().koordinate.gitter).toBe("32UMA"); + const snapshot = karte.snapshot(); + expect(snapshot.ziele[0].koordinate).toBe("32U MA 67030 78109"); + expect(Math.abs(snapshot.ziele[0].point.easting - 467_030)).toBeLessThan(1); + expect(snapshot.koordinate.gitter).toBe("32UMA"); }); test("accepts a leading zero as five digits, and works with the device offline", async () => { const { karte } = await openSheet({ uebersicht: wideSquare, online: false }); karte.enterKoordinate({ gitter: "32U MA", rechtswert: "06703", hochwert: "78109" }); - expect(karte.snapshot().ziel).not.toBeNull(); - expect(Math.abs(karte.snapshot().ziel!.easting - 406_703)).toBeLessThan(1); + expect(karte.snapshot().ziele[0].koordinate).toBe("32U MA 06703 78109"); + expect(Math.abs(karte.snapshot().ziele[0].point.easting - 406_703)).toBeLessThan(1); }); test("rejects a Rechtswert or Hochwert that is not exactly five digits", async () => { @@ -308,13 +295,14 @@ describe("naming a Ziel", () => { karte.panTo({ easting: 420_000, northing: 5_520_000 }); karte.enterKoordinate({ gitter: "32U MA", rechtswert: "6703", hochwert: "78109" }); expect(karte.snapshot().notice).toBe("Rechtswert und Hochwert müssen genau fünf Ziffern haben."); - expect(karte.snapshot().ziel).toEqual(kept.ziel); + expect(karte.snapshot().ziele).toEqual(kept.ziele); expect(karte.snapshot().center).toEqual({ easting: 420_000, northing: 5_520_000 }); expect(karte.snapshot().koordinate).toEqual(kept.koordinate); karte.enterKoordinate({ gitter: "32U MA", rechtswert: "670300", hochwert: "78109" }); expect(karte.snapshot().notice).toBe("Rechtswert und Hochwert müssen genau fünf Ziffern haben."); - expect(karte.snapshot().ziel).toEqual(kept.ziel); + expect(karte.snapshot().ziele).toEqual(kept.ziele); + expect(karte.snapshot().koordinate).toEqual(kept.koordinate); }); test("rejects an impossible Gitter", async () => { @@ -323,31 +311,62 @@ describe("naming a Ziel", () => { const kept = karte.snapshot(); karte.enterKoordinate({ gitter: "32U II", rechtswert: "67030", hochwert: "78109" }); expect(karte.snapshot().notice).toBe("Dieses Gitter ist keine gültige Koordinate."); - expect(karte.snapshot().ziel).toEqual(kept.ziel); + expect(karte.snapshot().ziele).toEqual(kept.ziele); expect(karte.snapshot().center).toEqual(kept.center); + expect(karte.snapshot().koordinate).toEqual(kept.koordinate); }); test("rejects a Koordinate no Blatt covers, including a hole between Blätter", async () => { const { karte } = await openSheet({ uebersicht: withHole }); karte.enterKoordinate(sample); const kept = karte.snapshot(); - expect(kept.ziel).not.toBeNull(); + expect(kept.ziele).toHaveLength(1); karte.panTo({ easting: 465_000, northing: 5_580_000 }); karte.enterKoordinate({ gitter: "32U MA", rechtswert: "75000", hochwert: "78109" }); expect(karte.snapshot().notice).toBe("Kein Blatt deckt diese Koordinate."); - expect(karte.snapshot().ziel).toEqual(kept.ziel); + expect(karte.snapshot().ziele).toEqual(kept.ziele); expect(karte.snapshot().center).toEqual({ easting: 465_000, northing: 5_580_000 }); expect(karte.snapshot().resolution).toBe(kept.resolution); + expect(karte.snapshot().koordinate).toEqual(kept.koordinate); }); - test("a second Koordinate replaces the Ziel, and there is only one", async () => { + test("a valid Koordinate clears the previous refusal", async () => { + const { karte } = await openSheet({ uebersicht: wideSquare, online: true }); + karte.enterKoordinate({ gitter: "32U MA", rechtswert: "6703", hochwert: "78109" }); + expect(karte.snapshot().notice).toBe("Rechtswert und Hochwert müssen genau fünf Ziffern haben."); + karte.enterKoordinate(sample); + expect(karte.snapshot().notice).toBeNull(); + expect(karte.snapshot().ziele).toHaveLength(1); + }); + + test("a repeated Koordinate is refused and changes nothing", async () => { + const { karte } = await openSheet({ uebersicht: wideSquare }); + karte.enterKoordinate({ gitter: "32u ma", rechtswert: "67030", hochwert: "78109" }); + const kept = karte.snapshot(); + karte.panTo({ easting: 420_000, northing: 5_520_000 }); + karte.zoomTo(10); + karte.enterKoordinate({ gitter: "32UMA", rechtswert: "67030", hochwert: "78109" }); + const snapshot = karte.snapshot(); + expect(snapshot.notice).toBe("Diese Koordinate ist schon ein Ziel."); + expect(snapshot.ziele).toEqual(kept.ziele); + expect(snapshot.center).toEqual({ easting: 420_000, northing: 5_520_000 }); + expect(snapshot.resolution).toBe(10); + expect(snapshot.koordinate).toEqual(kept.koordinate); + }); + + test("a second Koordinate adds another Ziel, newest first", async () => { const { karte } = await openSheet({ uebersicht: withHole }); karte.enterKoordinate(sample); karte.enterKoordinate({ gitter: "32U MA", rechtswert: "81000", hochwert: "78109" }); const snapshot = karte.snapshot(); - expect(Math.abs(snapshot.ziel!.easting - 481_000)).toBeLessThan(1); + expect(snapshot.ziele.map((ziel) => ziel.koordinate)).toEqual([ + "32U MA 81000 78109", + "32U MA 67030 78109", + ]); + expect(Math.abs(snapshot.ziele[0].point.easting - 481_000)).toBeLessThan(1); expect(snapshot.resolution).toBe(7); + expect(snapshot.center).toEqual(snapshot.ziele[0].point); expect(snapshot.koordinate).toEqual({ gitter: "32U MA", rechtswert: "81000", @@ -355,21 +374,64 @@ describe("naming a Ziel", () => { }); }); - test("leaving the page drops the Ziel and keeps the Cache marks", async () => { + test("deleteZiel removes that Ziel and leaves the view", async () => { + const { karte } = await openSheet({ uebersicht: withHole }); + karte.enterKoordinate(sample); + karte.enterKoordinate({ gitter: "32U MA", rechtswert: "81000", hochwert: "78109" }); + karte.panTo({ easting: 465_000, northing: 5_580_000 }); + karte.zoomTo(10); + karte.deleteZiel("32U MA 67030 78109"); + const snapshot = karte.snapshot(); + expect(snapshot.ziele.map((ziel) => ziel.koordinate)).toEqual(["32U MA 81000 78109"]); + expect(snapshot.center).toEqual({ easting: 465_000, northing: 5_580_000 }); + expect(snapshot.resolution).toBe(10); + }); + + test("leaving the page keeps the Ziele and the Cache marks", async () => { + const cache = createMemoryCache(); + const marks = createMemoryMarks(); + const first = await openSheet({ uebersicht: wideSquare, cache, marks }); + first.karte.enterKoordinate({ gitter: "32u ma", rechtswert: "67030", hochwert: "78109" }); + first.karte.enterKoordinate({ gitter: "32UMA", rechtswert: "81000", hochwert: "78109" }); + first.karte.reportStandort({ easting: 420_000, northing: 5_520_000 }); + expect(first.karte.snapshot().ziele.map((ziel) => ziel.koordinate)).toEqual([ + "32U MA 81000 78109", + "32U MA 67030 78109", + ]); + + const kept = await openSheet({ uebersicht: wideSquare, cache, marks }); + expect(kept.karte.snapshot().ziele.map((ziel) => ziel.koordinate)).toEqual([ + "32U MA 81000 78109", + "32U MA 67030 78109", + ]); + expect(Math.abs(kept.karte.snapshot().ziele[1].point.easting - 467_030)).toBeLessThan(1); + expect(kept.karte.snapshot().ziele[0].entfernung).toBeNull(); + expect(kept.karte.snapshot().standort).toBeNull(); + expect(kept.karte.snapshot().koordinate).toEqual({ + gitter: "32U MA", + rechtswert: "", + hochwert: "", + }); + }); + + test("deleting the Cache leaves the Ziele in place", async () => { const cache = createMemoryCache(); const marks = createMemoryMarks(); const first = await openSheet({ uebersicht: wideSquare, cache, marks }); first.karte.enterKoordinate(sample); - first.karte.startGebiet("Hessen"); - expect(first.karte.snapshot().ziel).not.toBeNull(); + first.karte.deleteCache(); + expect(first.karte.snapshot().ziele.map((ziel) => ziel.koordinate)).toEqual([ + "32U MA 67030 78109", + ]); const second = await openSheet({ uebersicht: wideSquare, cache, marks }); - expect(second.karte.snapshot().ziel).toBeNull(); - expect(hessen(second.karte.snapshot()).heruntergeladen).toBe(true); + expect(second.karte.snapshot().ziele.map((ziel) => ziel.koordinate)).toEqual([ + "32U MA 67030 78109", + ]); }); }); -describe("Standort and distance", () => { +describe("Standort and Entfernung", () => { test("a late first Standort centers the view once", async () => { const { karte } = await openSheet(); expect(karte.snapshot().center).toEqual({ easting: 150, northing: 50 }); @@ -424,24 +486,26 @@ describe("Standort and distance", () => { expect(karte.snapshot().center).toEqual({ easting: 5, northing: 5 }); }); - test("centers on the Standort and on the Ziel at 1:25,000", async () => { - const { karte } = await openSheet({ uebersicht: wideSquare }); + test("centers on the Standort and on one Ziel at 1:25,000", async () => { + const { karte } = await openSheet({ uebersicht: withHole }); const before = karte.snapshot(); karte.centerOnStandort(); - karte.centerOnZiel(); + karte.centerOnZiel("32U MA 67030 78109"); expect(karte.snapshot().center).toEqual(before.center); expect(karte.snapshot().resolution).toBe(before.resolution); karte.enterKoordinate(sample); + karte.enterKoordinate({ gitter: "32U MA", rechtswert: "81000", hochwert: "78109" }); + const older = karte.snapshot().ziele[1].point; karte.reportStandort({ - easting: karte.snapshot().ziel!.easting + 100, - northing: karte.snapshot().ziel!.northing, + easting: older.easting + 100, + northing: older.northing, }); karte.zoomTo(1.25); - karte.panTo({ easting: 410_000, northing: 5_510_000 }); - karte.centerOnZiel(); + karte.panTo({ easting: 465_000, northing: 5_580_000 }); + karte.centerOnZiel("32U MA 67030 78109"); expect(karte.snapshot().resolution).toBe(7); - expect(karte.snapshot().center).toEqual(karte.snapshot().ziel); + expect(karte.snapshot().center).toEqual(older); karte.zoomTo(1.25); karte.centerOnStandort(); @@ -449,32 +513,64 @@ describe("Standort and distance", () => { expect(karte.snapshot().center).toEqual(karte.snapshot().standort); }); - test("shows metres below 1000 m and kilometres from 1000 m, and hides a missing end", async () => { - const { karte } = await openSheet({ uebersicht: wideSquare, online: false }); - expect(karte.snapshot().distance).toBeNull(); + test("shows metres below 1000 m and kilometres from 1000 m, and hides a missing Standort", async () => { + const { karte } = await openSheet({ uebersicht: withHole, online: false }); karte.enterKoordinate(sample); - expect(karte.snapshot().distance).toBeNull(); + karte.enterKoordinate({ gitter: "32U MA", rechtswert: "81000", hochwert: "78109" }); + expect(karte.snapshot().ziele.every((ziel) => ziel.entfernung === null)).toBe(true); - const ziel = karte.snapshot().ziel!; - karte.reportStandort({ easting: ziel.easting, northing: ziel.northing + 500 }); - expect(karte.snapshot().distance).toEqual({ metres: 500, text: "500 m" }); + const nearer = karte.snapshot().ziele[0].point; + const keptCenter = karte.snapshot().center; + karte.reportStandort({ easting: nearer.easting, northing: nearer.northing + 500 }); + expect(karte.snapshot().ziele[0].entfernung).toEqual({ metres: 500, text: "500 m" }); + expect(karte.snapshot().ziele[1].entfernung?.text).not.toBe("500 m"); + expect(karte.snapshot().center).toEqual(keptCenter); - karte.reportStandort({ easting: ziel.easting, northing: ziel.northing + 999 }); - expect(karte.snapshot().distance?.text).toBe("999 m"); + karte.reportStandort({ easting: nearer.easting, northing: nearer.northing + 999 }); + expect(karte.snapshot().ziele[0].entfernung?.text).toBe("999 m"); - karte.reportStandort({ easting: ziel.easting, northing: ziel.northing + 1000 }); - expect(karte.snapshot().distance?.text).toBe("1 km"); + karte.reportStandort({ easting: nearer.easting, northing: nearer.northing + 1000 }); + expect(karte.snapshot().ziele[0].entfernung?.text).toBe("1 km"); - karte.reportStandort({ easting: ziel.easting, northing: ziel.northing + 1500 }); - expect(karte.snapshot().distance?.text).toBe("1,5 km"); + karte.reportStandort({ easting: nearer.easting, northing: nearer.northing + 1500 }); + expect(karte.snapshot().ziele[0].entfernung?.text).toBe("1,5 km"); - karte.reportStandort({ easting: ziel.easting + 3000, northing: ziel.northing + 4000 }); - expect(karte.snapshot().distance).toEqual({ metres: 5000, text: "5 km" }); - expect(karte.snapshot().center).toEqual(ziel); + karte.reportStandort({ easting: nearer.easting + 3000, northing: nearer.northing + 4000 }); + expect(karte.snapshot().ziele[0].entfernung).toEqual({ metres: 5000, text: "5 km" }); + expect(karte.snapshot().ziele[1].entfernung?.metres).not.toBe(5000); + expect(karte.snapshot().center).toEqual(keptCenter); }); }); -describe("downloading and removing Hessen", () => { +describe("downloading Hessen", () => { + test("reports how many Hessen Kacheln are already in the Cache", async () => { + const { karte } = await openSheet(); + expect(karte.snapshot().hessen).toEqual({ arrived: 0, total: 3 }); + expect(karte.snapshot().visit).toBeNull(); + }); + + test("a stored heruntergeladen name does not fetch Hessen", async () => { + const marks = createMemoryMarks(["Hessen"]); + const { karte, clock } = await openSheet({ viewport: oneTile, marks }); + karte.zoomTo(10); + karte.panTo({ easting: 250, northing: 50 }); + karte.setOnline(true); + expect(karte.snapshot().visit).toBeNull(); + await clock.tick(); + await clock.tick(); + await clock.tick(); + karte.setOnline(false); + karte.panTo({ easting: 50, northing: 50 }); + expect(appearance(karte, TILE_A)).toBe("blank-not-on-device"); + expect(karte.snapshot().hessen).toEqual({ arrived: 1, total: 3 }); + }); + + test("a Kachel already in the Cache counts as arrived Hessen", async () => { + const { karte } = await openSheet({ cache: createMemoryCache([TILE_A]) }); + expect(karte.snapshot().hessen).toEqual({ arrived: 1, total: 3 }); + expect(karte.snapshot().visit).toBeNull(); + }); + async function view(easting: number, viewport: Viewport = oneTile) { const opened = await openSheet({ viewport }); opened.karte.zoomTo(10); @@ -483,141 +579,203 @@ describe("downloading and removing Hessen", () => { return opened; } - test("starting Hessen marks it heruntergeladen and fetches the current view first", async () => { + test("starting Hessen fetches only the missing Kacheln, current view first", async () => { const { karte, clock } = await view(250); - karte.startGebiet("Hessen"); - const started = hessen(karte.snapshot()); - expect(started.heruntergeladen).toBe(true); - expect(started.arrived).toBe(0); - expect(started.total).toBe(3); + karte.startHessen(); + expect(karte.snapshot().hessen).toEqual({ arrived: 0, total: 3 }); + expect(karte.snapshot().visit).toEqual({ arrived: 0, total: 3 }); await clock.tick(); expect(appearance(karte, TILE_C)).toBe("drawn"); - expect(hessen(karte.snapshot()).arrived).toBe(1); + expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 }); + expect(karte.snapshot().hessen).toEqual({ arrived: 1, total: 3 }); karte.panTo({ easting: 50, northing: 50 }); expect(appearance(karte, TILE_A)).not.toBe("drawn"); - - await clock.tick(); - await clock.tick(); - karte.panTo({ easting: 50, northing: 50 }); - expect(appearance(karte, TILE_A)).toBe("drawn"); - expect(hessen(karte.snapshot()).arrived).toBe(3); }); - test("Kacheln already in the Cache stay usable while Hessen is still downloading", async () => { - const { karte } = await openSheet({ + test("a finished Hessen fill leaves no visit progress", async () => { + const { karte, clock } = await view(250); + karte.startHessen(); + await clock.tick(); + await clock.tick(); + await clock.tick(); + expect(karte.snapshot().visit).toBeNull(); + expect(karte.snapshot().hessen).toEqual({ arrived: 3, total: 3 }); + karte.panTo({ easting: 50, northing: 50 }); + expect(appearance(karte, TILE_A)).toBe("drawn"); + karte.panTo({ easting: 150, northing: 50 }); + expect(appearance(karte, TILE_B)).toBe("drawn"); + }); + + test("a second Hessen start while that fill is unfinished adds nothing", async () => { + const { karte, clock } = await view(250); + karte.startHessen(); + await clock.tick(); + expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 }); + karte.startHessen(); + expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 }); + await clock.tick(); + await clock.tick(); + expect(karte.snapshot().visit).toBeNull(); + expect(karte.snapshot().hessen).toEqual({ arrived: 3, total: 3 }); + }); + + test("closing the app stops the Hessen fill and opening does not continue it", async () => { + const { karte, clock } = await view(250); + karte.startHessen(); + karte.setAppOpen(false); + expect(karte.snapshot().visit).toBeNull(); + await clock.tick(); + expect(appearance(karte, TILE_C)).toBe("blank-not-on-device"); + + karte.setAppOpen(true); + expect(karte.snapshot().visit).toBeNull(); + await clock.tick(); + expect(appearance(karte, TILE_C)).toBe("drawn"); + await clock.tick(); + await clock.tick(); + karte.setOnline(false); + karte.panTo({ easting: 50, northing: 50 }); + expect(appearance(karte, TILE_A)).toBe("blank-not-on-device"); + + karte.setOnline(true); + karte.startHessen(); + expect(karte.snapshot().visit).toEqual({ arrived: 0, total: 2 }); + await clock.tick(); + expect(appearance(karte, TILE_A)).toBe("drawn"); + await clock.tick(); + karte.panTo({ easting: 150, northing: 50 }); + expect(appearance(karte, TILE_B)).toBe("drawn"); + expect(karte.snapshot().visit).toBeNull(); + }); + + test("looking around fetches only the missing Kacheln of the current view", async () => { + const { karte, clock } = await view(250); + await clock.tick(); + await clock.tick(); + await clock.tick(); + expect(karte.snapshot().visit).toBeNull(); + expect(karte.snapshot().hessen).toEqual({ arrived: 1, total: 3 }); + karte.panTo({ easting: 50, northing: 50 }); + expect(appearance(karte, TILE_A)).toBe("blank-downloading"); + }); + + test("losing the network pauses the Hessen fill and regaining it continues", async () => { + const { karte, clock } = await view(250); + karte.startHessen(); + await clock.tick(); + karte.setOnline(false); + expect(appearance(karte, TILE_C)).toBe("drawn"); + expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 }); + karte.panTo({ easting: 50, northing: 50 }); + expect(appearance(karte, TILE_A)).toBe("blank-not-on-device"); + await clock.tick(); + expect(appearance(karte, TILE_A)).toBe("blank-not-on-device"); + expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 }); + karte.setOnline(true); + expect(appearance(karte, TILE_A)).toBe("blank-downloading"); + await clock.tick(); + expect(appearance(karte, TILE_A)).toBe("drawn"); + expect(karte.snapshot().visit).toEqual({ arrived: 2, total: 3 }); + }); + + test("starting Hessen fetches every prepared scale, the current scale first", async () => { + const opened = await openSheet({ + viewport: oneTile, + uebersicht: { + tileSizePx: 10, + resolutions: [10, 20], + blaetter: [{ minE: 0, minN: 0, maxE: 100, maxN: 100 }], + gebiete: [{ name: "Hessen", covers: "all" }], + }, + }); + opened.karte.zoomTo(10); + opened.karte.panTo({ easting: 50, northing: 50 }); + opened.karte.setOnline(true); + expect(opened.karte.snapshot().hessen).toEqual({ arrived: 0, total: 2 }); + opened.karte.startHessen(); + expect(opened.karte.snapshot().visit).toEqual({ arrived: 0, total: 2 }); + await opened.clock.tick(); + expect(appearance(opened.karte, "0/0/0")).toBe("drawn"); + opened.karte.zoomTo(20); + expect(appearance(opened.karte, "1/0/0")).not.toBe("drawn"); + await opened.clock.tick(); + expect(appearance(opened.karte, "1/0/0")).toBe("drawn"); + expect(opened.karte.snapshot().visit).toBeNull(); + expect(opened.karte.snapshot().hessen).toEqual({ arrived: 2, total: 2 }); + }); + + test("looking around fetches the current view before the rest of Hessen", async () => { + const opened = await openSheet({ + viewport: oneTile, + uebersicht: { + tileSizePx: 10, + resolutions: [10, 20], + blaetter: [{ minE: 0, minN: 0, maxE: 300, maxN: 100 }], + gebiete: [{ name: "Hessen", covers: "all" }], + }, + }); + opened.karte.zoomTo(10); + opened.karte.panTo({ easting: 50, northing: 50 }); + opened.karte.setOnline(true); + opened.karte.startHessen(); + opened.karte.panTo({ easting: 250, northing: 50 }); + await opened.clock.tick(); + await opened.clock.tick(); + expect(appearance(opened.karte, "0/2/0")).toBe("drawn"); + opened.karte.setOnline(false); + opened.karte.zoomTo(20); + expect(appearance(opened.karte, "1/1/0")).toBe("blank-not-on-device"); + opened.karte.zoomTo(10); + opened.karte.panTo({ easting: 150, northing: 50 }); + expect(appearance(opened.karte, "0/1/0")).toBe("blank-not-on-device"); + }); + + test("Hessen skips Kacheln already in the Cache and keeps them drawn", async () => { + const { karte, clock } = await openSheet({ viewport: threeTiles, cache: createMemoryCache([TILE_A]), - online: true, }); karte.zoomTo(10); karte.panTo({ easting: 150, northing: 50 }); - karte.startGebiet("Hessen"); + karte.setOnline(true); + karte.startHessen(); expect(appearance(karte, TILE_A)).toBe("drawn"); expect(appearance(karte, TILE_B)).toBe("blank-downloading"); - expect(hessen(karte.snapshot()).arrived).toBe(1); + expect(karte.snapshot().hessen).toEqual({ arrived: 1, total: 3 }); + expect(karte.snapshot().visit).toEqual({ arrived: 0, total: 2 }); + await clock.tick(); + await clock.tick(); + expect(karte.snapshot().visit).toBeNull(); + expect(karte.snapshot().hessen).toEqual({ arrived: 3, total: 3 }); }); - test("a dropped connection keeps finished Kacheln and pauses the rest", async () => { - const { karte, clock } = await view(250); - karte.startGebiet("Hessen"); - await clock.tick(); - karte.setOnline(false); - expect(appearance(karte, TILE_C)).toBe("drawn"); - karte.panTo({ easting: 50, northing: 50 }); - expect(appearance(karte, TILE_A)).toBe("blank-not-on-device"); - await clock.tick(); - expect(appearance(karte, TILE_A)).toBe("blank-not-on-device"); - karte.setOnline(true); - expect(appearance(karte, TILE_A)).toBe("blank-downloading"); - }); - - test("while Hessen is not heruntergeladen, looking around fetches only the current view", async () => { - const { karte, clock } = await view(250); - await clock.tick(); - await clock.tick(); - await clock.tick(); - karte.panTo({ easting: 50, northing: 50 }); - expect(appearance(karte, TILE_A)).toBe("blank-downloading"); - expect(hessen(karte.snapshot()).heruntergeladen).toBe(false); - }); - - test("removing Hessen stops the rest and keeps a Kachel that still lies in Usingen", async () => { - const { karte, clock } = await view(250); - await clock.tick(); - karte.startGebiet("Usingen"); - await clock.tick(); - await clock.tick(); - karte.startGebiet("Hessen"); - await clock.tick(); - karte.setOnline(false); - karte.removeGebiet("Hessen"); - - expect(hessen(karte.snapshot()).heruntergeladen).toBe(false); - expect(karte.snapshot().gebiete.find((gebiet) => gebiet.name === "Usingen")!.heruntergeladen).toBe( - true, - ); - expect(appearance(karte, TILE_C)).toBe("blank-not-on-device"); - karte.panTo({ easting: 50, northing: 50 }); - expect(appearance(karte, TILE_A)).toBe("drawn"); - karte.panTo({ easting: 150, northing: 50 }); - expect(appearance(karte, TILE_B)).toBe("drawn"); - - karte.setOnline(true); - await clock.tick(); - await clock.tick(); - await clock.tick(); - karte.panTo({ easting: 250, northing: 50 }); - expect(appearance(karte, TILE_C)).not.toBe("drawn"); - }); - - test("removing Wehrheim keeps the overlap and drops the Kachel only Wehrheim held", async () => { - const { karte, clock } = await openSheet({ viewport: threeTiles, online: true }); - karte.zoomTo(10); - karte.panTo({ easting: 150, northing: 50 }); - karte.startGebiet("Usingen"); - karte.startGebiet("Wehrheim"); - await clock.tick(); - await clock.tick(); - await clock.tick(); - karte.setOnline(false); - karte.removeGebiet("Wehrheim"); - expect(appearance(karte, TILE_A)).toBe("drawn"); - expect(appearance(karte, TILE_B)).toBe("drawn"); - expect(appearance(karte, TILE_C)).toBe("blank-not-on-device"); - expect(karte.snapshot().gebiete.find((gebiet) => gebiet.name === "Wehrheim")!.heruntergeladen).toBe( - false, - ); - }); - - test("deleting the whole Cache clears marks and does not start Hessen again", async () => { - const { karte, clock } = await view(250); - karte.startGebiet("Hessen"); - await clock.tick(); - await clock.tick(); - await clock.tick(); + test("deleting the Cache leaves the Ziel", async () => { + const { karte } = await openSheet({ uebersicht: wideSquare, online: true }); + karte.enterKoordinate(sample); + const ziele = karte.snapshot().ziele; karte.deleteCache(); - expect(hessen(karte.snapshot()).heruntergeladen).toBe(false); - expect(hessen(karte.snapshot()).arrived).toBe(0); + expect(karte.snapshot().ziele).toEqual(ziele); + expect(karte.snapshot().visit).toBeNull(); + }); + + test("deleting the Cache removes every Kachel and does not start a fetch", async () => { + const { karte, clock } = await view(250); + karte.startHessen(); + await clock.tick(); + expect(appearance(karte, TILE_C)).toBe("drawn"); + karte.deleteCache(); + expect(karte.snapshot().visit).toBeNull(); + expect(karte.snapshot().hessen).toEqual({ arrived: 0, total: 3 }); + expect(appearance(karte, TILE_C)).toBe("blank-not-on-device"); + await clock.tick(); + expect(appearance(karte, TILE_C)).toBe("blank-not-on-device"); + karte.panTo({ easting: 260, northing: 50 }); expect(appearance(karte, TILE_C)).toBe("blank-downloading"); await clock.tick(); expect(appearance(karte, TILE_C)).toBe("drawn"); - expect(hessen(karte.snapshot()).heruntergeladen).toBe(false); - karte.panTo({ easting: 50, northing: 50 }); - expect(appearance(karte, TILE_A)).not.toBe("drawn"); - }); - - test("Hessen can be started again after it is removed", async () => { - const { karte, clock } = await view(50); - karte.startGebiet("Hessen"); - await clock.tick(); karte.setOnline(false); - karte.removeGebiet("Hessen"); + karte.panTo({ easting: 50, northing: 50 }); expect(appearance(karte, TILE_A)).toBe("blank-not-on-device"); - karte.setOnline(true); - karte.startGebiet("Hessen"); - expect(hessen(karte.snapshot()).heruntergeladen).toBe(true); - await clock.tick(); - expect(appearance(karte, TILE_A)).toBe("drawn"); }); }); diff --git a/src/karte/session.ts b/src/karte/session.ts index 34e7711..233d207 100644 --- a/src/karte/session.ts +++ b/src/karte/session.ts @@ -1,7 +1,6 @@ import { maxResolutionMeters } from "../config"; import { parseKoordinate } from "./koordinate"; import type { - GebietStatus, KachelView, KarteOptions, KarteSession, @@ -125,6 +124,15 @@ function formatDistance(metres: number): string { return `${kilometres.toFixed(1).replace(".", ",")} km`; } +function entfernungTo( + standort: Point | null, + point: Point, +): { metres: number; text: string } | null { + if (!standort) return null; + const metres = Math.hypot(point.easting - standort.easting, point.northing - standort.northing); + return { metres, text: formatDistance(metres) }; +} + function isAbort(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } @@ -140,17 +148,16 @@ export async function openKarte(options: KarteOptions): Promise { const stored = new Set(loaded); const cached = new Set(loaded.filter((id) => catalog.has(id))); const markState = await marks.read(); - const heruntergeladen = new Set( - markState.heruntergeladen.filter((name) => - uebersicht.gebiete.some((gebiet) => gebiet.name === name), - ), - ); - const totals = new Map(uebersicht.gebiete.map((gebiet) => [gebiet.name, 0])); - for (const entry of catalog.values()) { - for (const name of entry.gebiete) { - totals.set(name, (totals.get(name) ?? 0) + 1); - } + const hessenIds: string[] = []; + for (const [id, entry] of catalog) { + if (entry.gebiete.includes("Hessen")) hessenIds.push(id); + } + + function hessenArrived(): number { + let arrived = 0; + for (const id of hessenIds) if (cached.has(id)) arrived += 1; + return arrived; } let viewport = { ...options.viewport }; @@ -160,26 +167,16 @@ export async function openKarte(options: KarteOptions): Promise { let seenStandort = standort !== null; let personMovedView = false; let personEnteredZiel = false; - let ziel: Point | null = null; + let ziele = restoreZiele(markState.ziele); let koordinateNotice: string | null = null; let koordinate = { gitter: "32U MA", rechtswert: "", hochwert: "" }; let queue: string[] = []; + let hessenFill: string[] | null = null; + let fetchesHeld = false; const inFlight = new Map(); const failed = new Set(); const listeners = new Set<() => void>(); - function arrivedCounts(): Map { - const counts = new Map(uebersicht.gebiete.map((gebiet) => [gebiet.name, 0])); - for (const id of cached) { - const entry = catalog.get(id); - if (!entry) continue; - for (const name of entry.gebiete) { - counts.set(name, (counts.get(name) ?? 0) + 1); - } - } - return counts; - } - function clamp(resolution: number): number { const maxResolution = maxResolutionMeters(viewport, config); return Math.min(Math.max(resolution, config.minResolutionMeters), maxResolution); @@ -211,8 +208,23 @@ export async function openKarte(options: KarteOptions): Promise { for (const listener of listeners) listener(); } - function persistMarks(): void { - void marks.write({ heruntergeladen: [...heruntergeladen] }); + function persistZiele(): void { + void marks.write({ heruntergeladen: [], ziele: ziele.map((item) => item.koordinate) }); + } + + function restoreZiele(canonical: string[]): { koordinate: string; point: Point }[] { + const restored: { koordinate: string; point: Point }[] = []; + for (const koordinate of canonical) { + const parts = /^(\d{1,2}[C-HJ-NP-X] [A-HJ-NP-Z]{2}) (\d{5}) (\d{5})$/.exec(koordinate); + if (!parts) continue; + const parsed = parseKoordinate( + { gitter: parts[1], rechtswert: parts[2], hochwert: parts[3] }, + (point) => uebersicht.blaetter.some((blatt) => coversPoint(blatt, point)), + ); + if (!parsed.ok || parsed.koordinate !== koordinate) continue; + restored.push({ koordinate, point: parsed.point }); + } + return restored; } function kachelViews(): KachelView[] { @@ -228,7 +240,11 @@ export async function openKarte(options: KarteOptions): Promise { views.push({ id: cell.id, appearance: "drawn" }); continue; } - const willFetch = appOpen && online && !failed.has(cell.id); + const willFetch = + appOpen && + online && + !failed.has(cell.id) && + (queue.includes(cell.id) || inFlight.has(cell.id)); views.push({ id: cell.id, appearance: willFetch ? "blank-downloading" : "blank-not-on-device", @@ -238,7 +254,15 @@ export async function openKarte(options: KarteOptions): Promise { return views; } + function releaseFetches(): void { + fetchesHeld = false; + } + function rebuild(): void { + if (fetchesHeld) { + queue = []; + return; + } const rect = viewRect(center, resolution, viewport); const scale = scaleIndex(uebersicht.resolutions, resolution); const viewNow = cellsInView(scale, rect, uebersicht.resolutions, uebersicht.tileSizePx) @@ -247,17 +271,6 @@ export async function openKarte(options: KarteOptions): Promise { for (const id of failed) { if (!viewNow.includes(id)) failed.delete(id); } - const viewAny = new Set(); - for (let zi = 0; zi < uebersicht.resolutions.length; zi += 1) { - for (const cell of cellsInView(zi, rect, uebersicht.resolutions, uebersicht.tileSizePx)) { - if (catalog.has(cell.id)) viewAny.add(cell.id); - } - } - const gebietIds: string[] = []; - for (const [id, entry] of catalog) { - if (cached.has(id)) continue; - if (entry.gebiete.some((name) => heruntergeladen.has(name))) gebietIds.push(id); - } const ordered: string[] = []; const seen = new Set(); const push = (id: string) => { @@ -266,10 +279,9 @@ export async function openKarte(options: KarteOptions): Promise { ordered.push(id); }; for (const id of viewNow) push(id); - for (const id of gebietIds) { - if (viewAny.has(id)) push(id); + if (hessenFill) { + for (const id of hessenFill) push(id); } - for (const id of gebietIds) push(id); queue = ordered; pump(); } @@ -292,6 +304,7 @@ export async function openKarte(options: KarteOptions): Promise { cached.add(id); stored.add(id); await cache.put(id, bytes); + if (hessenFill?.every((tile) => cached.has(tile))) hessenFill = null; } catch (error) { if (!isAbort(error)) failed.add(id); } finally { @@ -310,21 +323,6 @@ export async function openKarte(options: KarteOptions): Promise { } function snapshot(): KarteSnapshot { - const arrived = arrivedCounts(); - const gebiete: GebietStatus[] = uebersicht.gebiete.map((gebiet) => ({ - name: gebiet.name, - heruntergeladen: heruntergeladen.has(gebiet.name), - arrived: arrived.get(gebiet.name) ?? 0, - total: totals.get(gebiet.name) ?? 0, - })); - const distance = - ziel && standort - ? { - metres: Math.hypot(ziel.easting - standort.easting, ziel.northing - standort.northing), - text: "", - } - : null; - if (distance) distance.text = formatDistance(distance.metres); const views = kachelViews(); const missingWhileOffline = !online && views.some((kachel) => kachel.appearance === "blank-not-on-device"); @@ -333,18 +331,25 @@ export async function openKarte(options: KarteOptions): Promise { center: { ...center }, resolution, northUp: true, - ziel: ziel ? { ...ziel } : null, + ziele: ziele.map((item) => ({ + koordinate: item.koordinate, + point: { ...item.point }, + entfernung: entfernungTo(standort, item.point), + })), standort: standort ? { ...standort } : null, - distance, notice, koordinate: { ...koordinate }, - gebiete, + hessen: { arrived: hessenArrived(), total: hessenIds.length }, + visit: visitOf(), kacheln: views, }; } - function knownGebiet(name: string): boolean { - return uebersicht.gebiete.some((gebiet) => gebiet.name === name); + function visitOf(): { arrived: number; total: number } | null { + if (!hessenFill) return null; + let arrived = 0; + for (const id of hessenFill) if (cached.has(id)) arrived += 1; + return { arrived, total: hessenFill.length }; } const session: KarteSession = { @@ -354,18 +359,21 @@ export async function openKarte(options: KarteOptions): Promise { return () => listeners.delete(listener); }, setViewport(next) { + releaseFetches(); viewport = { ...next }; resolution = clamp(resolution); rebuild(); publish(); }, panTo(next) { + releaseFetches(); personMovedView = true; center = { ...next }; rebuild(); publish(); }, zoomTo(next) { + releaseFetches(); resolution = clamp(next); rebuild(); publish(); @@ -379,6 +387,7 @@ export async function openKarte(options: KarteOptions): Promise { } standort = { ...next }; if (!seenStandort && !personMovedView && !personEnteredZiel) { + releaseFetches(); center = { ...next }; resolution = clamp(config.openResolutionMeters); rebuild(); @@ -388,8 +397,11 @@ export async function openKarte(options: KarteOptions): Promise { }, setAppOpen(open) { appOpen = open; - if (!open) abortInFlight(() => true); - else { + if (!open) { + hessenFill = null; + abortInFlight(() => true); + } else { + releaseFetches(); failed.clear(); rebuild(); } @@ -399,6 +411,7 @@ export async function openKarte(options: KarteOptions): Promise { online = next; if (!next) abortInFlight(() => true); else { + releaseFetches(); failed.clear(); rebuild(); } @@ -413,78 +426,71 @@ export async function openKarte(options: KarteOptions): Promise { publish(); return; } + if (ziele.some((item) => item.koordinate === parsed.koordinate)) { + koordinateNotice = "Diese Koordinate ist schon ein Ziel."; + publish(); + return; + } koordinateNotice = null; koordinate = { gitter: input.gitter, rechtswert: input.rechtswert, hochwert: input.hochwert, }; - ziel = parsed.point; + ziele = [{ koordinate: parsed.koordinate, point: parsed.point }, ...ziele]; personEnteredZiel = true; + releaseFetches(); center = { ...parsed.point }; resolution = clamp(config.openResolutionMeters); + persistZiele(); rebuild(); publish(); }, + deleteZiel(koordinate) { + const next = ziele.filter((item) => item.koordinate !== koordinate); + if (next.length === ziele.length) return; + ziele = next; + persistZiele(); + publish(); + }, centerOnStandort() { if (!standort) return; + releaseFetches(); personMovedView = true; center = { ...standort }; resolution = clamp(config.openResolutionMeters); rebuild(); publish(); }, - centerOnZiel() { - if (!ziel) return; + centerOnZiel(koordinate) { + const found = ziele.find((item) => item.koordinate === koordinate); + if (!found) return; + releaseFetches(); personMovedView = true; - center = { ...ziel }; + center = { ...found.point }; resolution = clamp(config.openResolutionMeters); rebuild(); publish(); }, - startGebiet(name) { - if (!knownGebiet(name)) return; - heruntergeladen.add(name); - persistMarks(); - rebuild(); - publish(); - }, - removeGebiet(name) { - if (!knownGebiet(name)) return; - heruntergeladen.delete(name); - const removeIds: string[] = []; - for (const id of cached) { - const entry = catalog.get(id); - if (!entry?.gebiete.includes(name)) continue; - const kept = entry.gebiete.some( - (gebiet) => gebiet !== name && heruntergeladen.has(gebiet), - ); - if (!kept) removeIds.push(id); - } - for (const id of removeIds) { - cached.delete(id); - stored.delete(id); - } - abortInFlight((id) => { - const entry = catalog.get(id); - if (!entry?.gebiete.includes(name)) return false; - return !entry.gebiete.some((gebiet) => gebiet !== name && heruntergeladen.has(gebiet)); - }); - if (removeIds.length > 0) void cache.delete(removeIds); - persistMarks(); + startHessen() { + if (hessenFill) return; + const missing = hessenIds.filter((id) => !cached.has(id)); + if (missing.length === 0) return; + releaseFetches(); + hessenFill = missing; rebuild(); publish(); }, deleteCache() { + fetchesHeld = true; + hessenFill = null; abortInFlight(() => true); const ids = [...stored]; cached.clear(); stored.clear(); - heruntergeladen.clear(); failed.clear(); + queue = []; void cache.delete(ids); - persistMarks(); - rebuild(); publish(); }, }; diff --git a/src/karte/types.ts b/src/karte/types.ts index e978435..cd1dbbf 100644 --- a/src/karte/types.ts +++ b/src/karte/types.ts @@ -33,30 +33,33 @@ export type KachelAppearance = export type KachelView = { id: string; appearance: KachelAppearance }; -export type GebietStatus = { - name: string; - heruntergeladen: boolean; - arrived: number; - total: number; -}; - export type KoordinateText = { gitter: string; rechtswert: string; hochwert: string; }; -/** What the screen shows. Distance text is German (`500 m`, `1,5 km`). */ +/** Straight-line Entfernung. Text is German (`500 m`, `1,5 km`). */ +export type Entfernung = { metres: number; text: string }; + +/** One named place. `koordinate` is the canonical writing, newest list first. */ +export type Ziel = { + koordinate: string; + point: Point; + entfernung: Entfernung | null; +}; + +/** What the screen shows. */ export type KarteSnapshot = { center: Point; resolution: number; northUp: true; - ziel: Point | null; + ziele: Ziel[]; standort: Point | null; - distance: { metres: number; text: string } | null; notice: string | null; koordinate: KoordinateText; - gebiete: GebietStatus[]; + hessen: { arrived: number; total: number }; + visit: { arrived: number; total: number } | null; kacheln: KachelView[]; }; @@ -67,8 +70,8 @@ export interface CachePort { } export interface MarksPort { - read(): Promise<{ heruntergeladen: string[] }>; - write(state: { heruntergeladen: string[] }): Promise; + read(): Promise<{ heruntergeladen: string[]; ziele: string[] }>; + write(state: { heruntergeladen: string[]; ziele: string[] }): Promise; } /** Fetches Kachel bytes. The session decides when, and stores them in the Cache. */ @@ -99,9 +102,9 @@ export type KarteSession = { setAppOpen(open: boolean): void; setOnline(online: boolean): void; enterKoordinate(input: KoordinateText): void; + deleteZiel(koordinate: string): void; centerOnStandort(): void; - centerOnZiel(): void; - startGebiet(name: string): void; - removeGebiet(name: string): void; + centerOnZiel(koordinate: string): void; + startHessen(): void; deleteCache(): void; }; diff --git a/src/style.css b/src/style.css index a1307d2..1bcb9b0 100644 --- a/src/style.css +++ b/src/style.css @@ -78,11 +78,11 @@ input { } .fold-toggle, -.cache-toggle { +.einstellungen-toggle { flex: 0 0 auto; } -.cache-toggle[aria-expanded="true"] { +.einstellungen-toggle[aria-expanded="true"] { background: #1e3328; color: #e7e1d4; } @@ -153,6 +153,18 @@ button.secondary { color: #1e3328; } +button.hessen-download { + background: #b42318; + border-color: #b42318; + color: #e7e1d4; +} + +button:disabled { + background: #e7e1d4; + color: #6f675c; + border-color: #c4bbaa; +} + .actions { flex-direction: row; } @@ -162,16 +174,30 @@ button.secondary { } .gebiet p, -.distance, .notice, .boot-error { margin: 0; font-size: 16px; } -.distance { - font-size: 18px; - font-weight: 700; +.ziel-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.ziel-list li { + display: flex; + gap: 8px; + align-items: stretch; +} + +.ziel-list .ziel-open { + flex: 1 1 auto; + text-align: left; } .notice { diff --git a/src/ui/map.ts b/src/ui/map.ts index f87b2f6..1f84904 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -163,9 +163,8 @@ export function createFieldMap( }); const markers = new VectorSource(); - const ziel = new Feature({ geometry: new OlPoint([0, 0]) }); + const zielStyle = markStyle(ZIEL); const standort = new Feature({ geometry: new OlPoint([0, 0]) }); - ziel.setStyle(markStyle(ZIEL)); standort.setStyle(markStyle(STANDORT)); const opening = session.snapshot(); @@ -268,7 +267,26 @@ export function createFieldMap( return { apply(snapshot) { - place(markers, ziel, snapshot.ziel); + const wanted = new Set(snapshot.ziele.map((ziel) => ziel.koordinate)); + for (const feature of [...markers.getFeatures()]) { + if (feature === standort) continue; + const id = feature.getId(); + if (typeof id !== "string" || !wanted.has(id)) markers.removeFeature(feature); + } + for (const ziel of snapshot.ziele) { + const existing = markers.getFeatureById(ziel.koordinate); + const geometry = existing?.getGeometry(); + if (geometry instanceof OlPoint) { + geometry.setCoordinates([ziel.point.easting, ziel.point.northing]); + continue; + } + const feature = new Feature({ + geometry: new OlPoint([ziel.point.easting, ziel.point.northing]), + }); + feature.setId(ziel.koordinate); + feature.setStyle(zielStyle); + markers.addFeature(feature); + } place(markers, standort, snapshot.standort); const current = map.getView(); diff --git a/src/ui/screen.ts b/src/ui/screen.ts index 3eec664..135f7b2 100644 --- a/src/ui/screen.ts +++ b/src/ui/screen.ts @@ -1,9 +1,6 @@ -import type { GebietStatus, KarteSession, KarteSnapshot, Uebersicht } from "../karte/types"; +import type { KarteSession, KarteSnapshot, Uebersicht } from "../karte/types"; import { createFieldMap } from "./map"; -type ConfirmKind = "hessen" | "cache"; -type GebietMode = "download" | "remove" | "none"; - export function mountScreen( root: HTMLElement, session: KarteSession, @@ -20,11 +17,11 @@ export function mountScreen(

Karte

- - + +
-
+
@@ -33,16 +30,15 @@ export function mountScreen(
+
    -
    - -