diff --git a/src/adapters/marks-db.ts b/src/adapters/marks-db.ts index c291801..615493b 100644 --- a/src/adapters/marks-db.ts +++ b/src/adapters/marks-db.ts @@ -1,4 +1,4 @@ -import type { MarksPort } from "../karte/types"; +import type { MarksPort, RememberedMarks } from "../karte/types"; const DB_NAME = "karte"; const STORE = "marks"; @@ -21,16 +21,23 @@ 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: [] }; +function rememberedFrom(value: unknown): RememberedMarks { + if (Array.isArray(value)) { + return { heruntergeladen: stringsFrom(value), ziele: [], downloadKnopf: false }; + } if (value && typeof value === "object") { - const record = value as { heruntergeladen?: unknown; ziele?: unknown }; + const record = value as { + heruntergeladen?: unknown; + ziele?: unknown; + downloadKnopf?: unknown; + }; return { heruntergeladen: stringsFrom(record.heruntergeladen), ziele: stringsFrom(record.ziele), + downloadKnopf: record.downloadKnopf === true, }; } - return { heruntergeladen: [], ziele: [] }; + return { heruntergeladen: [], ziele: [], downloadKnopf: false }; } function settle(tx: IDBTransaction, value: () => unknown): Promise { @@ -60,7 +67,11 @@ export function createMarksDb(): MarksPort { try { const tx = db.transaction(STORE, "readwrite"); tx.objectStore(STORE).put( - { heruntergeladen: state.heruntergeladen, ziele: state.ziele }, + { + heruntergeladen: state.heruntergeladen, + ziele: state.ziele, + downloadKnopf: state.downloadKnopf, + }, KEY, ); await settle(tx, () => undefined); diff --git a/src/karte/fakes.ts b/src/karte/fakes.ts index deae44d..5565480 100644 --- a/src/karte/fakes.ts +++ b/src/karte/fakes.ts @@ -18,13 +18,15 @@ export function createMemoryCache(initial: string[] = []): CachePort { export function createMemoryMarks(initial: string[] = []): MarksPort { let heruntergeladen = [...initial]; let ziele: string[] = []; + let downloadKnopf = false; return { async read() { - return { heruntergeladen: [...heruntergeladen], ziele: [...ziele] }; + return { heruntergeladen: [...heruntergeladen], ziele: [...ziele], downloadKnopf }; }, async write(state) { heruntergeladen = [...state.heruntergeladen]; ziele = [...state.ziele]; + downloadKnopf = state.downloadKnopf; }, }; } diff --git a/src/karte/koordinate.ts b/src/karte/koordinate.ts index c6cd0a2..f36e069 100644 --- a/src/karte/koordinate.ts +++ b/src/karte/koordinate.ts @@ -1,6 +1,6 @@ -import { toPoint } from "mgrs"; +import { forward, toPoint } from "mgrs"; import proj4 from "proj4"; -import type { Point } from "./types"; +import type { KoordinateText, Point } from "./types"; proj4.defs( "EPSG:25832", @@ -10,10 +10,31 @@ proj4.defs( const DIGITS = "Rechtswert und Hochwert müssen genau fünf Ziffern haben."; const GITTER_NOTICE = "Dieses Gitter ist keine gültige Koordinate."; const BLATT = "Kein Blatt deckt diese Koordinate."; +const MGRS_1M = /^(\d{1,2}[C-HJ-NP-X])([A-HJ-NP-Z]{2})(\d{5})(\d{5})$/; /** Zone, latitude band, and 100 km square. I and O are not MGRS letters. */ const GITTER_PATTERN = /^(\d{1,2})([C-HJ-NP-X])([A-HJ-NP-Z]{2})$/; +/** Nearest metre inside the 100 km square, written as Gitter, Rechtswert, and Hochwert. */ +export function writeKoordinate(point: Point): KoordinateText | null { + const easting = Math.round(point.easting); + const northing = Math.round(point.northing); + const geographic = proj4("EPSG:25832", "EPSG:4326", [easting, northing]); + let mgrsText: string; + try { + mgrsText = forward([geographic[0], geographic[1]], 5); + } catch { + return null; + } + const match = MGRS_1M.exec(mgrsText); + if (!match) return null; + return { + gitter: `${match[1]} ${match[2]}`, + rechtswert: match[3], + hochwert: match[4], + }; +} + export function parseKoordinate( input: { gitter: string; rechtswert: string; hochwert: string }, blattCovers: (point: Point) => boolean, diff --git a/src/karte/session.test.ts b/src/karte/session.test.ts index df7839e..44f851f 100644 --- a/src/karte/session.test.ts +++ b/src/karte/session.test.ts @@ -48,6 +48,7 @@ async function openSheet(options?: { clock?: ReturnType; uebersicht?: Uebersicht; maxSpanMeters?: number; + minResolutionMeters?: number; }) { const cache = options?.cache ?? createMemoryCache(); const marks = options?.marks ?? createMemoryMarks(); @@ -57,7 +58,11 @@ async function openSheet(options?: { cache, marks, kacheln: clock, - config: { ...shippedConfig, maxSpanMeters: options?.maxSpanMeters ?? shippedConfig.maxSpanMeters }, + config: { + ...shippedConfig, + maxSpanMeters: options?.maxSpanMeters ?? shippedConfig.maxSpanMeters, + minResolutionMeters: options?.minResolutionMeters ?? shippedConfig.minResolutionMeters, + }, viewport: options?.viewport ?? phone, standort: options?.standort, online: options?.online ?? false, @@ -431,6 +436,202 @@ describe("naming a Ziel", () => { }); }); +describe("tapping the Karte", () => { + test("a tap within 44 px deletes the nearest Ziel and leaves the view", async () => { + const { karte } = await openSheet({ uebersicht: withHole, online: true }); + karte.enterKoordinate(sample); + karte.enterKoordinate({ gitter: "32U MA", rechtswert: "67100", hochwert: "78109" }); + karte.panTo({ easting: 465_000, northing: 5_580_000 }); + karte.zoomTo(7); + const older = karte.snapshot().ziele[1].point; + karte.tap({ easting: older.easting, northing: older.northing - 10 }); + const snapshot = karte.snapshot(); + expect(snapshot.ziele.map((ziel) => ziel.koordinate)).toEqual(["32U MA 67100 78109"]); + expect(snapshot.center).toEqual({ easting: 465_000, northing: 5_580_000 }); + expect(snapshot.resolution).toBe(7); + }); + + test("a tie within 44 px deletes the newer Ziel", async () => { + const { karte } = await openSheet({ uebersicht: withHole, online: true }); + karte.enterKoordinate(sample); + karte.enterKoordinate({ gitter: "32U MA", rechtswert: "67100", hochwert: "78109" }); + const [newer, older] = karte.snapshot().ziele; + karte.panTo({ easting: 465_000, northing: 5_580_000 }); + karte.zoomTo(1.25); + karte.tap({ + easting: (newer.point.easting + older.point.easting) / 2, + northing: (newer.point.northing + older.point.northing) / 2, + }); + const snapshot = karte.snapshot(); + expect(snapshot.ziele.map((ziel) => ziel.koordinate)).toEqual(["32U MA 67030 78109"]); + expect(snapshot.center).toEqual({ easting: 465_000, northing: 5_580_000 }); + expect(snapshot.resolution).toBe(1.25); + }); + + test("a tap farther than 44 px adds that metre and leaves the view and the typed fields", async () => { + const { karte } = await openSheet({ uebersicht: withHole, online: true }); + karte.enterKoordinate({ gitter: "32u ma", rechtswert: "67030", hochwert: "78109" }); + karte.panTo({ easting: 465_000, northing: 5_580_000 }); + karte.zoomTo(10); + const typed = karte.snapshot().koordinate; + karte.tap({ easting: 481_000, northing: 5_578_109 }); + const snapshot = karte.snapshot(); + 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(Math.abs(snapshot.ziele[0].point.northing - 5_578_109)).toBeLessThan(1); + expect(snapshot.center).toEqual({ easting: 465_000, northing: 5_580_000 }); + expect(snapshot.resolution).toBe(10); + expect(snapshot.koordinate).toEqual(typed); + expect(snapshot.notice).toBeNull(); + }); + + test("a repeated Koordinate from a tap is refused and changes nothing else", async () => { + const { karte } = await openSheet({ + uebersicht: wideSquare, + online: true, + minResolutionMeters: 0.001, + }); + karte.enterKoordinate({ gitter: "32u ma", rechtswert: "67030", hochwert: "78109" }); + const ziel = karte.snapshot().ziele[0]; + karte.panTo({ easting: 420_000, northing: 5_520_000 }); + karte.zoomTo(0.001); + const kept = karte.snapshot(); + // 0.2 m west stays in this metre, and at 0.001 m/px it is outside 44 px. + karte.tap({ easting: ziel.point.easting - 0.2, northing: ziel.point.northing }); + const snapshot = karte.snapshot(); + expect(snapshot.notice).toBe("Diese Koordinate ist schon ein Ziel."); + expect(snapshot.ziele).toEqual(kept.ziele); + expect(snapshot.center).toEqual(kept.center); + expect(snapshot.resolution).toBe(0.001); + expect(snapshot.koordinate).toEqual(kept.koordinate); + }); + + test("the 44 px radius is inclusive, and one metre farther names that metre", async () => { + const { karte } = await openSheet({ uebersicht: wideSquare, online: true }); + karte.enterKoordinate(sample); + const onTheEdge = karte.snapshot().ziele[0].point; + karte.panTo({ easting: 420_000, northing: 5_520_000 }); + karte.zoomTo(10); + const typed = karte.snapshot().koordinate; + karte.tap({ easting: onTheEdge.easting, northing: onTheEdge.northing + 440 }); + expect(karte.snapshot().ziele).toEqual([]); + expect(karte.snapshot().center).toEqual({ easting: 420_000, northing: 5_520_000 }); + expect(karte.snapshot().resolution).toBe(10); + expect(karte.snapshot().koordinate).toEqual(typed); + + karte.enterKoordinate(sample); + const again = karte.snapshot().ziele[0].point; + karte.panTo({ easting: 420_000, northing: 5_520_000 }); + karte.zoomTo(10); + karte.tap({ easting: again.easting, northing: again.northing + 441 }); + const snapshot = karte.snapshot(); + expect(snapshot.ziele.map((ziel) => ziel.koordinate)).toEqual([ + "32U MA 67030 78550", + "32U MA 67030 78109", + ]); + expect(snapshot.center).toEqual({ easting: 420_000, northing: 5_520_000 }); + expect(snapshot.resolution).toBe(10); + expect(snapshot.koordinate).toEqual(typed); + }); + + test("a tap 100 m away deletes at 7 m per pixel and adds at 1.25 m per pixel", async () => { + const { karte } = await openSheet({ uebersicht: wideSquare, online: true }); + karte.enterKoordinate(sample); + const ziel = karte.snapshot().ziele[0].point; + karte.panTo({ easting: 420_000, northing: 5_520_000 }); + karte.zoomTo(1.25); + karte.tap({ easting: ziel.easting, northing: ziel.northing + 100 }); + expect(karte.snapshot().ziele.map((item) => item.koordinate)).toEqual([ + "32U MA 67030 78209", + "32U MA 67030 78109", + ]); + expect(karte.snapshot().center).toEqual({ easting: 420_000, northing: 5_520_000 }); + expect(karte.snapshot().resolution).toBe(1.25); + + karte.zoomTo(7); + karte.tap({ easting: ziel.easting, northing: ziel.northing + 100 }); + expect(karte.snapshot().ziele.map((item) => item.koordinate)).toEqual(["32U MA 67030 78109"]); + karte.tap({ easting: ziel.easting, northing: ziel.northing + 100 }); + expect(karte.snapshot().ziele).toEqual([]); + expect(karte.snapshot().center).toEqual({ easting: 420_000, northing: 5_520_000 }); + expect(karte.snapshot().resolution).toBe(7); + }); + + test("a tap on the Standort adds a Ziel there when no Ziel is within 44 px", async () => { + const standort = { easting: 467_030, northing: 5_578_109 }; + const { karte } = await openSheet({ uebersicht: wideSquare, online: true, standort }); + const before = karte.snapshot(); + karte.tap(standort); + const snapshot = karte.snapshot(); + expect(snapshot.ziele.map((ziel) => ziel.koordinate)).toEqual(["32U MA 67030 78109"]); + expect(Math.abs(snapshot.ziele[0].point.easting - standort.easting)).toBeLessThan(1); + expect(Math.abs(snapshot.ziele[0].point.northing - standort.northing)).toBeLessThan(1); + expect(snapshot.standort).toEqual(standort); + expect(snapshot.center).toEqual(before.center); + expect(snapshot.resolution).toBe(before.resolution); + expect(snapshot.koordinate).toEqual(before.koordinate); + }); + + test("a tap on the Standort deletes a Ziel within 44 px and leaves the Standort", async () => { + const { karte } = await openSheet({ uebersicht: wideSquare, online: true }); + karte.enterKoordinate(sample); + const ziel = karte.snapshot().ziele[0].point; + const center = karte.snapshot().center; + karte.reportStandort(ziel); + karte.tap(ziel); + expect(karte.snapshot().ziele).toEqual([]); + expect(karte.snapshot().standort).toEqual(ziel); + expect(karte.snapshot().center).toEqual(center); + }); + + test("a tap no Blatt covers leaves the list, the notice, and the view", async () => { + const { karte } = await openSheet({ uebersicht: withHole, online: true }); + karte.enterKoordinate(sample); + karte.enterKoordinate({ gitter: "32U MA", rechtswert: "6703", hochwert: "78109" }); + karte.panTo({ easting: 465_000, northing: 5_580_000 }); + karte.zoomTo(10); + const kept = karte.snapshot(); + expect(kept.notice).toBe("Rechtswert und Hochwert müssen genau fünf Ziffern haben."); + karte.tap({ easting: 475_000, northing: 5_578_109 }); + const snapshot = karte.snapshot(); + expect(snapshot.notice).toBe(kept.notice); + expect(snapshot.ziele).toEqual(kept.ziele); + expect(snapshot.center).toEqual(kept.center); + expect(snapshot.resolution).toBe(kept.resolution); + expect(snapshot.koordinate).toEqual(kept.koordinate); + }); + + test("a Ziel added by a tap keeps a later first Standort from moving the view", async () => { + const { karte } = await openSheet({ uebersicht: wideSquare, online: true }); + const opening = karte.snapshot().center; + karte.tap({ easting: 481_000, northing: 5_578_109 }); + karte.reportStandort({ easting: 420_000, northing: 5_520_000 }); + expect(karte.snapshot().center).toEqual(opening); + expect(karte.snapshot().standort).toEqual({ easting: 420_000, northing: 5_520_000 }); + expect(karte.snapshot().ziele.map((ziel) => ziel.koordinate)).toEqual(["32U MA 81000 78109"]); + }); + + test("leaving the page keeps a Ziel added by a tap and drops one removed by a tap", async () => { + const cache = createMemoryCache(); + const marks = createMemoryMarks(); + const first = await openSheet({ uebersicht: withHole, cache, marks, online: true }); + first.karte.enterKoordinate(sample); + first.karte.tap({ easting: 481_000, northing: 5_578_109 }); + first.karte.tap(first.karte.snapshot().ziele[1].point); + expect(first.karte.snapshot().ziele.map((ziel) => ziel.koordinate)).toEqual([ + "32U MA 81000 78109", + ]); + + const kept = await openSheet({ uebersicht: withHole, cache, marks, online: true }); + expect(kept.karte.snapshot().ziele.map((ziel) => ziel.koordinate)).toEqual([ + "32U MA 81000 78109", + ]); + }); +}); + describe("Standort and Entfernung", () => { test("a late first Standort centers the view once", async () => { const { karte } = await openSheet(); @@ -779,3 +980,264 @@ describe("downloading Hessen", () => { expect(appearance(karte, TILE_A)).toBe("blank-not-on-device"); }); }); + +describe("downloading the current view", () => { + test("the Download-Knopf starts off, is remembered, and deleteCache leaves it", async () => { + const marks = createMemoryMarks(["Hessen"]); + const first = await openSheet({ marks, uebersicht: wideSquare }); + expect(first.karte.snapshot().downloadKnopf).toBe(false); + expect(first.karte.snapshot().ziele).toEqual([]); + + first.karte.setDownloadKnopf(true); + expect(first.karte.snapshot().downloadKnopf).toBe(true); + first.karte.enterKoordinate(sample); + first.karte.deleteCache(); + expect(first.karte.snapshot().downloadKnopf).toBe(true); + expect(first.karte.snapshot().ziele.map((ziel) => ziel.koordinate)).toEqual([ + "32U MA 67030 78109", + ]); + + const second = await openSheet({ marks, uebersicht: wideSquare }); + expect(second.karte.snapshot().downloadKnopf).toBe(true); + expect(second.karte.snapshot().ziele.map((ziel) => ziel.koordinate)).toEqual([ + "32U MA 67030 78109", + ]); + + second.karte.setDownloadKnopf(false); + const third = await openSheet({ marks, uebersicht: wideSquare }); + expect(third.karte.snapshot().downloadKnopf).toBe(false); + expect(third.karte.snapshot().ziele).toHaveLength(1); + }); + + test("downloadView fetches the finer Kacheln inside a view that covers a Blatt, current scale first", async () => { + const opened = await openSheet({ + viewport: oneTile, + uebersicht: { + tileSizePx: 10, + resolutions: [10, 20], + blaetter: [{ minE: 0, minN: 0, maxE: 200, maxN: 200 }], + gebiete: [{ name: "Hessen", covers: "all" }], + }, + }); + opened.karte.zoomTo(20); + opened.karte.panTo({ easting: 100, northing: 100 }); + opened.karte.downloadView(); + expect(opened.karte.snapshot().visit).toEqual({ arrived: 0, total: 5 }); + + opened.karte.setOnline(true); + await opened.clock.tick(); + opened.karte.setOnline(false); + expect(appearance(opened.karte, "1/0/0")).toBe("drawn"); + opened.karte.zoomTo(10); + opened.karte.panTo({ easting: 50, northing: 50 }); + expect(appearance(opened.karte, "0/0/0")).toBe("blank-not-on-device"); + opened.karte.panTo({ easting: 150, northing: 150 }); + expect(appearance(opened.karte, "0/1/1")).toBe("blank-not-on-device"); + }); + + test("a later pan does not add Kacheln to the view fill", async () => { + const { karte, clock } = await openSheet({ viewport: oneTile }); + karte.zoomTo(10); + karte.panTo({ easting: 50, northing: 50 }); + karte.downloadView(); + expect(karte.snapshot().visit).toEqual({ arrived: 0, total: 1 }); + + karte.panTo({ easting: 250, northing: 50 }); + karte.zoomTo(30); + expect(karte.snapshot().visit).toEqual({ arrived: 0, total: 1 }); + karte.zoomTo(10); + + karte.setOnline(true); + await clock.tick(); + expect(karte.snapshot().visit).toEqual({ arrived: 0, total: 1 }); + await clock.tick(); + expect(karte.snapshot().visit).toBeNull(); + karte.setOnline(false); + 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("blank-not-on-device"); + }); + + test("downloadView skips Kacheln already in the Cache", async () => { + const { karte, clock } = await openSheet({ + viewport: threeTiles, + cache: createMemoryCache([TILE_A]), + }); + karte.zoomTo(10); + karte.panTo({ easting: 150, northing: 50 }); + karte.downloadView(); + expect(appearance(karte, TILE_A)).toBe("drawn"); + expect(karte.snapshot().visit).toEqual({ arrived: 0, total: 2 }); + karte.setOnline(true); + await clock.tick(); + await clock.tick(); + expect(karte.snapshot().visit).toBeNull(); + expect(appearance(karte, TILE_B)).toBe("drawn"); + expect(appearance(karte, TILE_C)).toBe("drawn"); + }); + + test("downloadView fetches nothing when the view is already in the Cache", async () => { + const { karte, clock } = await openSheet({ + viewport: oneTile, + cache: createMemoryCache([TILE_A]), + }); + karte.zoomTo(10); + karte.panTo({ easting: 50, northing: 50 }); + karte.setOnline(true); + karte.downloadView(); + expect(karte.snapshot().visit).toBeNull(); + expect(appearance(karte, TILE_A)).toBe("drawn"); + await clock.tick(); + await clock.tick(); + karte.setOnline(false); + karte.panTo({ easting: 150, northing: 50 }); + expect(appearance(karte, TILE_B)).toBe("blank-not-on-device"); + expect(karte.snapshot().visit).toBeNull(); + }); + + test("a second downloadView while that fill is unfinished adds nothing", async () => { + const { karte, clock } = await openSheet({ viewport: threeTiles }); + karte.zoomTo(10); + karte.panTo({ easting: 150, northing: 50 }); + karte.downloadView(); + expect(karte.snapshot().visit).toEqual({ arrived: 0, total: 3 }); + karte.setOnline(true); + await clock.tick(); + expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 }); + karte.panTo({ easting: 50, northing: 50 }); + karte.downloadView(); + expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 }); + await clock.tick(); + await clock.tick(); + expect(karte.snapshot().visit).toBeNull(); + karte.setOnline(false); + karte.zoomTo(10); + karte.panTo({ easting: 250, northing: 50 }); + expect(appearance(karte, TILE_C)).toBe("drawn"); + }); + + test("startHessen while a view fill runs still fetches the missing Hessen Kacheln", async () => { + const opened = await openSheet({ + viewport: oneTile, + uebersicht: { + tileSizePx: 10, + resolutions: [10], + blaetter: [{ minE: 0, minN: 0, maxE: 300, maxN: 100 }], + gebiete: [{ name: "Hessen", covers: [{ minE: 0, minN: 0, maxE: 200, maxN: 100 }] }], + }, + }); + opened.karte.zoomTo(10); + opened.karte.panTo({ easting: 250, northing: 50 }); + opened.karte.downloadView(); + expect(opened.karte.snapshot().visit).toEqual({ arrived: 0, total: 1 }); + expect(opened.karte.snapshot().hessen).toEqual({ arrived: 0, total: 2 }); + opened.karte.startHessen(); + expect(opened.karte.snapshot().visit).toEqual({ arrived: 0, total: 3 }); + opened.karte.setOnline(true); + await opened.clock.tick(); + await opened.clock.tick(); + await opened.clock.tick(); + expect(opened.karte.snapshot().visit).toBeNull(); + expect(opened.karte.snapshot().hessen).toEqual({ arrived: 2, total: 2 }); + opened.karte.setOnline(false); + opened.karte.panTo({ easting: 50, northing: 50 }); + expect(appearance(opened.karte, TILE_A)).toBe("drawn"); + }); + + test("the view fill and the Hessen fill share one visit count, current view first", async () => { + const { karte, clock } = await openSheet({ viewport: oneTile }); + karte.zoomTo(10); + karte.panTo({ easting: 50, northing: 50 }); + karte.downloadView(); + karte.startHessen(); + expect(karte.snapshot().visit).toEqual({ arrived: 0, total: 3 }); + karte.setOnline(true); + await clock.tick(); + expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 }); + karte.setOnline(false); + expect(appearance(karte, TILE_A)).toBe("drawn"); + karte.panTo({ easting: 150, northing: 50 }); + expect(appearance(karte, TILE_B)).toBe("blank-not-on-device"); + karte.setOnline(true); + await clock.tick(); + await clock.tick(); + expect(karte.snapshot().visit).toBeNull(); + expect(karte.snapshot().hessen).toEqual({ arrived: 3, total: 3 }); + }); + + test("closing drops the view fill, and downloadView again fetches what is still missing", async () => { + const opened = await openSheet({ + viewport: oneTile, + uebersicht: { + tileSizePx: 10, + resolutions: [10, 20], + blaetter: [{ minE: 0, minN: 0, maxE: 200, maxN: 200 }], + gebiete: [{ name: "Hessen", covers: "all" }], + }, + }); + opened.karte.zoomTo(20); + opened.karte.panTo({ easting: 100, northing: 100 }); + opened.karte.downloadView(); + expect(opened.karte.snapshot().visit).toEqual({ arrived: 0, total: 5 }); + opened.karte.setOnline(true); + await opened.clock.tick(); + opened.karte.setAppOpen(false); + expect(opened.karte.snapshot().visit).toBeNull(); + await opened.clock.tick(); + + opened.karte.setAppOpen(true); + expect(opened.karte.snapshot().visit).toBeNull(); + await opened.clock.tick(); + await opened.clock.tick(); + opened.karte.setOnline(false); + opened.karte.zoomTo(10); + opened.karte.panTo({ easting: 50, northing: 50 }); + expect(appearance(opened.karte, "0/0/0")).toBe("blank-not-on-device"); + + opened.karte.zoomTo(20); + opened.karte.panTo({ easting: 100, northing: 100 }); + opened.karte.setOnline(true); + opened.karte.downloadView(); + expect(opened.karte.snapshot().visit).toEqual({ arrived: 0, total: 4 }); + }); + + test("deleteCache drops an unfinished view fill and does not start a fetch", async () => { + const { karte, clock } = await openSheet({ viewport: threeTiles }); + karte.zoomTo(10); + karte.panTo({ easting: 150, northing: 50 }); + karte.setOnline(true); + karte.downloadView(); + karte.startHessen(); + await clock.tick(); + expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 }); + karte.deleteCache(); + expect(karte.snapshot().visit).toBeNull(); + expect(appearance(karte, TILE_A)).toBe("blank-not-on-device"); + expect(appearance(karte, TILE_B)).toBe("blank-not-on-device"); + await clock.tick(); + expect(appearance(karte, TILE_B)).toBe("blank-not-on-device"); + expect(karte.snapshot().visit).toBeNull(); + expect(karte.snapshot().hessen).toEqual({ arrived: 0, total: 3 }); + }); + + test("losing the network pauses the view fill and regaining it continues", async () => { + const { karte, clock } = await openSheet({ viewport: threeTiles }); + karte.zoomTo(10); + karte.panTo({ easting: 150, northing: 50 }); + karte.downloadView(); + karte.setOnline(true); + await clock.tick(); + karte.setOnline(false); + expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 }); + karte.panTo({ easting: 50, northing: 50 }); + await clock.tick(); + expect(appearance(karte, TILE_B)).toBe("blank-not-on-device"); + expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 }); + karte.setOnline(true); + await clock.tick(); + expect(karte.snapshot().visit).toEqual({ arrived: 2, total: 3 }); + await clock.tick(); + expect(karte.snapshot().visit).toBeNull(); + }); +}); diff --git a/src/karte/session.ts b/src/karte/session.ts index 233d207..1dd5422 100644 --- a/src/karte/session.ts +++ b/src/karte/session.ts @@ -1,5 +1,5 @@ import { maxResolutionMeters } from "../config"; -import { parseKoordinate } from "./koordinate"; +import { parseKoordinate, writeKoordinate } from "./koordinate"; import type { KachelView, KarteOptions, @@ -168,10 +168,12 @@ export async function openKarte(options: KarteOptions): Promise { let personMovedView = false; let personEnteredZiel = false; let ziele = restoreZiele(markState.ziele); + let downloadKnopf = markState.downloadKnopf === true; let koordinateNotice: string | null = null; let koordinate = { gitter: "32U MA", rechtswert: "", hochwert: "" }; let queue: string[] = []; let hessenFill: string[] | null = null; + let viewFill: string[] | null = null; let fetchesHeld = false; const inFlight = new Map(); const failed = new Set(); @@ -208,8 +210,12 @@ export async function openKarte(options: KarteOptions): Promise { for (const listener of listeners) listener(); } - function persistZiele(): void { - void marks.write({ heruntergeladen: [], ziele: ziele.map((item) => item.koordinate) }); + function persistMarks(): void { + void marks.write({ + heruntergeladen: [], + ziele: ziele.map((item) => item.koordinate), + downloadKnopf, + }); } function restoreZiele(canonical: string[]): { koordinate: string; point: Point }[] { @@ -282,6 +288,9 @@ export async function openKarte(options: KarteOptions): Promise { if (hessenFill) { for (const id of hessenFill) push(id); } + if (viewFill) { + for (const id of viewFill) push(id); + } queue = ordered; pump(); } @@ -305,6 +314,7 @@ export async function openKarte(options: KarteOptions): Promise { stored.add(id); await cache.put(id, bytes); if (hessenFill?.every((tile) => cached.has(tile))) hessenFill = null; + if (viewFill?.every((tile) => cached.has(tile))) viewFill = null; } catch (error) { if (!isAbort(error)) failed.add(id); } finally { @@ -339,17 +349,48 @@ export async function openKarte(options: KarteOptions): Promise { standort: standort ? { ...standort } : null, notice, koordinate: { ...koordinate }, + downloadKnopf, hessen: { arrived: hessenArrived(), total: hessenIds.length }, visit: visitOf(), kacheln: views, }; } + function visitIds(): string[] { + const ids: string[] = []; + const seen = new Set(); + for (const id of hessenFill ?? []) { + if (seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + for (const id of viewFill ?? []) { + if (seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; + } + function visitOf(): { arrived: number; total: number } | null { - if (!hessenFill) return null; + const ids = visitIds(); + if (ids.length === 0) return null; let arrived = 0; - for (const id of hessenFill) if (cached.has(id)) arrived += 1; - return { arrived, total: hessenFill.length }; + for (const id of ids) if (cached.has(id)) arrived += 1; + return { arrived, total: ids.length }; + } + + function idsCovering(rect: Rect): string[] { + const ids: string[] = []; + const seen = new Set(); + for (let zi = 0; zi < uebersicht.resolutions.length; zi += 1) { + for (const cell of cellsInView(zi, rect, uebersicht.resolutions, uebersicht.tileSizePx)) { + if (seen.has(cell.id) || !catalog.has(cell.id)) continue; + seen.add(cell.id); + ids.push(cell.id); + } + } + return ids; } const session: KarteSession = { @@ -399,6 +440,7 @@ export async function openKarte(options: KarteOptions): Promise { appOpen = open; if (!open) { hessenFill = null; + viewFill = null; abortInFlight(() => true); } else { releaseFetches(); @@ -417,6 +459,43 @@ export async function openKarte(options: KarteOptions): Promise { } publish(); }, + tap(point) { + const radius = 44 * resolution; + let nearest = -1; + let nearestDistance = Infinity; + for (let index = 0; index < ziele.length; index += 1) { + const distance = Math.hypot( + point.easting - ziele[index].point.easting, + point.northing - ziele[index].point.northing, + ); + if (distance <= radius && distance < nearestDistance) { + nearest = index; + nearestDistance = distance; + } + } + if (nearest >= 0) { + ziele = ziele.filter((_, index) => index !== nearest); + persistMarks(); + publish(); + return; + } + const written = writeKoordinate(point); + if (!written) return; + const parsed = parseKoordinate(written, (candidate) => + uebersicht.blaetter.some((blatt) => coversPoint(blatt, candidate)), + ); + if (!parsed.ok) return; + if (ziele.some((item) => item.koordinate === parsed.koordinate)) { + koordinateNotice = "Diese Koordinate ist schon ein Ziel."; + publish(); + return; + } + koordinateNotice = null; + ziele = [{ koordinate: parsed.koordinate, point: parsed.point }, ...ziele]; + personEnteredZiel = true; + persistMarks(); + publish(); + }, enterKoordinate(input) { const parsed = parseKoordinate(input, (point) => uebersicht.blaetter.some((blatt) => coversPoint(blatt, point)), @@ -442,7 +521,7 @@ export async function openKarte(options: KarteOptions): Promise { releaseFetches(); center = { ...parsed.point }; resolution = clamp(config.openResolutionMeters); - persistZiele(); + persistMarks(); rebuild(); publish(); }, @@ -450,7 +529,12 @@ export async function openKarte(options: KarteOptions): Promise { const next = ziele.filter((item) => item.koordinate !== koordinate); if (next.length === ziele.length) return; ziele = next; - persistZiele(); + persistMarks(); + publish(); + }, + setDownloadKnopf(on) { + downloadKnopf = on; + persistMarks(); publish(); }, centerOnStandort() { @@ -481,9 +565,21 @@ export async function openKarte(options: KarteOptions): Promise { rebuild(); publish(); }, + downloadView() { + if (viewFill) return; + const missing = idsCovering(viewRect(center, resolution, viewport)).filter( + (id) => !cached.has(id), + ); + if (missing.length === 0) return; + releaseFetches(); + viewFill = missing; + rebuild(); + publish(); + }, deleteCache() { fetchesHeld = true; hessenFill = null; + viewFill = null; abortInFlight(() => true); const ids = [...stored]; cached.clear(); diff --git a/src/karte/types.ts b/src/karte/types.ts index cd1dbbf..17d7ad4 100644 --- a/src/karte/types.ts +++ b/src/karte/types.ts @@ -58,6 +58,7 @@ export type KarteSnapshot = { standort: Point | null; notice: string | null; koordinate: KoordinateText; + downloadKnopf: boolean; hessen: { arrived: number; total: number }; visit: { arrived: number; total: number } | null; kacheln: KachelView[]; @@ -69,9 +70,16 @@ export interface CachePort { delete(ids: string[]): Promise; } +/** Device memory for Ziele and the Download-Knopf. `heruntergeladen` is only how an older value is read. */ +export type RememberedMarks = { + heruntergeladen: string[]; + ziele: string[]; + downloadKnopf: boolean; +}; + export interface MarksPort { - read(): Promise<{ heruntergeladen: string[]; ziele: string[] }>; - write(state: { heruntergeladen: string[]; ziele: string[] }): Promise; + read(): Promise; + write(state: RememberedMarks): Promise; } /** Fetches Kachel bytes. The session decides when, and stores them in the Cache. */ @@ -102,9 +110,12 @@ export type KarteSession = { setAppOpen(open: boolean): void; setOnline(online: boolean): void; enterKoordinate(input: KoordinateText): void; + tap(point: Point): void; deleteZiel(koordinate: string): void; centerOnStandort(): void; centerOnZiel(koordinate: string): void; startHessen(): void; + setDownloadKnopf(on: boolean): void; + downloadView(): void; deleteCache(): void; }; diff --git a/src/style.css b/src/style.css index 1bcb9b0..f16e23e 100644 --- a/src/style.css +++ b/src/style.css @@ -43,6 +43,14 @@ input { background: #e7e1d4; } +.map-download { + position: absolute; + z-index: 2; + right: max(12px, env(safe-area-inset-right)); + bottom: max(12px, env(safe-area-inset-bottom)); + max-width: calc(100% - 24px); +} + .map .ol-viewport { background: #e7e1d4; } @@ -123,6 +131,23 @@ label { font-weight: 600; } +.download-knopf { + flex-direction: row; + align-items: center; + gap: 10px; + min-height: 44px; +} + +.download-knopf input { + width: 1.25rem; + height: 1.25rem; + min-height: 0; + margin: 0; + padding: 0; + flex: 0 0 auto; + accent-color: #1e3328; +} + input { width: 100%; min-height: 44px; diff --git a/src/ui/map.ts b/src/ui/map.ts index 1f84904..affc911 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -217,6 +217,10 @@ export function createFieldMap( pushView(); }; + map.on("singleclick", (event) => { + const [easting, northing] = event.coordinate; + session.tap({ easting, northing }); + }); map.on("pointerdrag", rememberGesture); map.getViewport()?.addEventListener("wheel", rememberGesture, { passive: true }); map.on("moveend", () => { diff --git a/src/ui/screen.ts b/src/ui/screen.ts index 135f7b2..c449913 100644 --- a/src/ui/screen.ts +++ b/src/ui/screen.ts @@ -36,6 +36,7 @@ export function mountScreen(