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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-09 08:23:16 +02:00
co-authored by Cursor
parent 83a36c7a22
commit 6e635320e5
9 changed files with 601 additions and 387 deletions
+17 -2
View File
@@ -21,6 +21,18 @@ function stringsFrom(value: unknown): string[] {
return value.filter((item): item is string => typeof item === "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<unknown> { function settle(tx: IDBTransaction, value: () => unknown): Promise<unknown> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve(value()); tx.oncomplete = () => resolve(value());
@@ -37,7 +49,7 @@ export function createMarksDb(): MarksPort {
const tx = db.transaction(STORE, "readonly"); const tx = db.transaction(STORE, "readonly");
const request = tx.objectStore(STORE).get(KEY); const request = tx.objectStore(STORE).get(KEY);
const value = await settle(tx, () => request.result); const value = await settle(tx, () => request.result);
return { heruntergeladen: stringsFrom(value) }; return rememberedFrom(value);
} finally { } finally {
db.close(); db.close();
} }
@@ -47,7 +59,10 @@ export function createMarksDb(): MarksPort {
const db = await openDatabase(); const db = await openDatabase();
try { try {
const tx = db.transaction(STORE, "readwrite"); 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); await settle(tx, () => undefined);
} finally { } finally {
db.close(); db.close();
+3 -1
View File
@@ -17,12 +17,14 @@ export function createMemoryCache(initial: string[] = []): CachePort {
export function createMemoryMarks(initial: string[] = []): MarksPort { export function createMemoryMarks(initial: string[] = []): MarksPort {
let heruntergeladen = [...initial]; let heruntergeladen = [...initial];
let ziele: string[] = [];
return { return {
async read() { async read() {
return { heruntergeladen: [...heruntergeladen] }; return { heruntergeladen: [...heruntergeladen], ziele: [...ziele] };
}, },
async write(state) { async write(state) {
heruntergeladen = [...state.heruntergeladen]; heruntergeladen = [...state.heruntergeladen];
ziele = [...state.ziele];
}, },
}; };
} }
+9 -3
View File
@@ -17,13 +17,14 @@ const GITTER_PATTERN = /^(\d{1,2})([C-HJ-NP-X])([A-HJ-NP-Z]{2})$/;
export function parseKoordinate( export function parseKoordinate(
input: { gitter: string; rechtswert: string; hochwert: string }, input: { gitter: string; rechtswert: string; hochwert: string },
blattCovers: (point: Point) => boolean, 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)) { if (!/^\d{5}$/.test(input.rechtswert) || !/^\d{5}$/.test(input.hochwert)) {
return { ok: false, notice: DIGITS }; return { ok: false, notice: DIGITS };
} }
const compact = input.gitter.trim().toUpperCase().replace(/\s+/g, ""); 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 }; return { ok: false, notice: GITTER_NOTICE };
} }
@@ -37,5 +38,10 @@ export function parseKoordinate(
const projected = proj4("EPSG:4326", "EPSG:25832", geographic); const projected = proj4("EPSG:4326", "EPSG:25832", geographic);
const point = { easting: projected[0], northing: projected[1] }; const point = { easting: projected[0], northing: projected[1] };
if (!blattCovers(point)) return { ok: false, notice: BLATT }; 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}`,
};
} }
+321 -163
View File
@@ -1,7 +1,7 @@
import { shippedConfig } from "../config"; import { shippedConfig } from "../config";
import { createFetchClock, createMemoryCache, createMemoryMarks } from "./fakes"; import { createFetchClock, createMemoryCache, createMemoryMarks } from "./fakes";
import { openKarte } from "./session"; import { openKarte } from "./session";
import type { KarteSession, KarteSnapshot, Point, Uebersicht, Viewport } from "./types"; import type { KarteSession, Point, Uebersicht, Viewport } from "./types";
const sheet: Uebersicht = { const sheet: Uebersicht = {
tileSizePx: 10, tileSizePx: 10,
@@ -71,12 +71,6 @@ function appearance(karte: KarteSession, id: string) {
return kachel.appearance; 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" }; const sample = { gitter: "32U MA", rechtswert: "67030", hochwert: "78109" };
describe("opening the Karte", () => { describe("opening the Karte", () => {
@@ -87,7 +81,7 @@ describe("opening the Karte", () => {
expect(snapshot.resolution).toBe(7); expect(snapshot.resolution).toBe(7);
expect(snapshot.center).toEqual({ easting: 150, northing: 50 }); expect(snapshot.center).toEqual({ easting: 150, northing: 50 });
expect(snapshot.koordinate.gitter).toBe("32U MA"); expect(snapshot.koordinate.gitter).toBe("32U MA");
expect(snapshot.ziel).toBeNull(); expect(snapshot.ziele).toEqual([]);
expect(snapshot.standort).toBeNull(); expect(snapshot.standort).toBeNull();
}); });
@@ -243,18 +237,6 @@ describe("fetching a missing Kachel", () => {
expect(appearance(opened.karte, "1/0/0")).toBe("blank-downloading"); 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 () => { test("a finished Kachel is still drawn after the app is left and opened again", async () => {
const cache = createMemoryCache(); const cache = createMemoryCache();
const marks = createMemoryMarks(); const marks = createMemoryMarks();
@@ -270,7 +252,7 @@ describe("fetching a missing Kachel", () => {
second.karte.zoomTo(10); second.karte.zoomTo(10);
second.karte.panTo({ easting: 250, northing: 50 }); second.karte.panTo({ easting: 250, northing: 50 });
expect(appearance(second.karte, TILE_C)).toBe("drawn"); 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(); const snapshot = karte.snapshot();
expect(snapshot.koordinate).toEqual({ gitter: "32u ma", rechtswert: "67030", hochwert: "78109" }); expect(snapshot.koordinate).toEqual({ gitter: "32u ma", rechtswert: "67030", hochwert: "78109" });
expect(snapshot.resolution).toBe(7); expect(snapshot.resolution).toBe(7);
expect(snapshot.ziel).not.toBeNull(); expect(snapshot.ziele).toHaveLength(1);
expect(Math.abs(snapshot.ziel!.easting - 467_030)).toBeLessThan(1); expect(snapshot.ziele[0].koordinate).toBe("32U MA 67030 78109");
expect(Math.abs(snapshot.ziel!.northing - 5_578_109)).toBeLessThan(1); 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."); expect(snapshot.notice).toBe("Nicht auf dem Gerät.");
}); });
test("treats 32UMA like 32U MA", async () => { test("treats 32UMA like 32U MA", async () => {
const { karte } = await openSheet({ uebersicht: wideSquare }); const { karte } = await openSheet({ uebersicht: wideSquare });
karte.enterKoordinate({ gitter: "32UMA", rechtswert: "67030", hochwert: "78109" }); karte.enterKoordinate({ gitter: "32UMA", rechtswert: "67030", hochwert: "78109" });
expect(Math.abs(karte.snapshot().ziel!.easting - 467_030)).toBeLessThan(1); const snapshot = karte.snapshot();
expect(karte.snapshot().koordinate.gitter).toBe("32UMA"); 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 () => { test("accepts a leading zero as five digits, and works with the device offline", async () => {
const { karte } = await openSheet({ uebersicht: wideSquare, online: false }); const { karte } = await openSheet({ uebersicht: wideSquare, online: false });
karte.enterKoordinate({ gitter: "32U MA", rechtswert: "06703", hochwert: "78109" }); karte.enterKoordinate({ gitter: "32U MA", rechtswert: "06703", hochwert: "78109" });
expect(karte.snapshot().ziel).not.toBeNull(); expect(karte.snapshot().ziele[0].koordinate).toBe("32U MA 06703 78109");
expect(Math.abs(karte.snapshot().ziel!.easting - 406_703)).toBeLessThan(1); 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 () => { 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.panTo({ easting: 420_000, northing: 5_520_000 });
karte.enterKoordinate({ gitter: "32U MA", rechtswert: "6703", hochwert: "78109" }); 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().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().center).toEqual({ easting: 420_000, northing: 5_520_000 });
expect(karte.snapshot().koordinate).toEqual(kept.koordinate); expect(karte.snapshot().koordinate).toEqual(kept.koordinate);
karte.enterKoordinate({ gitter: "32U MA", rechtswert: "670300", hochwert: "78109" }); 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().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 () => { test("rejects an impossible Gitter", async () => {
@@ -323,31 +311,62 @@ describe("naming a Ziel", () => {
const kept = karte.snapshot(); const kept = karte.snapshot();
karte.enterKoordinate({ gitter: "32U II", rechtswert: "67030", hochwert: "78109" }); karte.enterKoordinate({ gitter: "32U II", rechtswert: "67030", hochwert: "78109" });
expect(karte.snapshot().notice).toBe("Dieses Gitter ist keine gültige Koordinate."); 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().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 () => { test("rejects a Koordinate no Blatt covers, including a hole between Blätter", async () => {
const { karte } = await openSheet({ uebersicht: withHole }); const { karte } = await openSheet({ uebersicht: withHole });
karte.enterKoordinate(sample); karte.enterKoordinate(sample);
const kept = karte.snapshot(); const kept = karte.snapshot();
expect(kept.ziel).not.toBeNull(); expect(kept.ziele).toHaveLength(1);
karte.panTo({ easting: 465_000, northing: 5_580_000 }); karte.panTo({ easting: 465_000, northing: 5_580_000 });
karte.enterKoordinate({ gitter: "32U MA", rechtswert: "75000", hochwert: "78109" }); karte.enterKoordinate({ gitter: "32U MA", rechtswert: "75000", hochwert: "78109" });
expect(karte.snapshot().notice).toBe("Kein Blatt deckt diese Koordinate."); 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().center).toEqual({ easting: 465_000, northing: 5_580_000 });
expect(karte.snapshot().resolution).toBe(kept.resolution); 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 }); const { karte } = await openSheet({ uebersicht: withHole });
karte.enterKoordinate(sample); karte.enterKoordinate(sample);
karte.enterKoordinate({ gitter: "32U MA", rechtswert: "81000", hochwert: "78109" }); karte.enterKoordinate({ gitter: "32U MA", rechtswert: "81000", hochwert: "78109" });
const snapshot = karte.snapshot(); 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.resolution).toBe(7);
expect(snapshot.center).toEqual(snapshot.ziele[0].point);
expect(snapshot.koordinate).toEqual({ expect(snapshot.koordinate).toEqual({
gitter: "32U MA", gitter: "32U MA",
rechtswert: "81000", 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 cache = createMemoryCache();
const marks = createMemoryMarks(); const marks = createMemoryMarks();
const first = await openSheet({ uebersicht: wideSquare, cache, marks }); const first = await openSheet({ uebersicht: wideSquare, cache, marks });
first.karte.enterKoordinate(sample); first.karte.enterKoordinate(sample);
first.karte.startGebiet("Hessen"); first.karte.deleteCache();
expect(first.karte.snapshot().ziel).not.toBeNull(); expect(first.karte.snapshot().ziele.map((ziel) => ziel.koordinate)).toEqual([
"32U MA 67030 78109",
]);
const second = await openSheet({ uebersicht: wideSquare, cache, marks }); const second = await openSheet({ uebersicht: wideSquare, cache, marks });
expect(second.karte.snapshot().ziel).toBeNull(); expect(second.karte.snapshot().ziele.map((ziel) => ziel.koordinate)).toEqual([
expect(hessen(second.karte.snapshot()).heruntergeladen).toBe(true); "32U MA 67030 78109",
]);
}); });
}); });
describe("Standort and distance", () => { describe("Standort and Entfernung", () => {
test("a late first Standort centers the view once", async () => { test("a late first Standort centers the view once", async () => {
const { karte } = await openSheet(); const { karte } = await openSheet();
expect(karte.snapshot().center).toEqual({ easting: 150, northing: 50 }); 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 }); expect(karte.snapshot().center).toEqual({ easting: 5, northing: 5 });
}); });
test("centers on the Standort and on the Ziel at 1:25,000", async () => { test("centers on the Standort and on one Ziel at 1:25,000", async () => {
const { karte } = await openSheet({ uebersicht: wideSquare }); const { karte } = await openSheet({ uebersicht: withHole });
const before = karte.snapshot(); const before = karte.snapshot();
karte.centerOnStandort(); karte.centerOnStandort();
karte.centerOnZiel(); karte.centerOnZiel("32U MA 67030 78109");
expect(karte.snapshot().center).toEqual(before.center); expect(karte.snapshot().center).toEqual(before.center);
expect(karte.snapshot().resolution).toBe(before.resolution); expect(karte.snapshot().resolution).toBe(before.resolution);
karte.enterKoordinate(sample); karte.enterKoordinate(sample);
karte.enterKoordinate({ gitter: "32U MA", rechtswert: "81000", hochwert: "78109" });
const older = karte.snapshot().ziele[1].point;
karte.reportStandort({ karte.reportStandort({
easting: karte.snapshot().ziel!.easting + 100, easting: older.easting + 100,
northing: karte.snapshot().ziel!.northing, northing: older.northing,
}); });
karte.zoomTo(1.25); karte.zoomTo(1.25);
karte.panTo({ easting: 410_000, northing: 5_510_000 }); karte.panTo({ easting: 465_000, northing: 5_580_000 });
karte.centerOnZiel(); karte.centerOnZiel("32U MA 67030 78109");
expect(karte.snapshot().resolution).toBe(7); 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.zoomTo(1.25);
karte.centerOnStandort(); karte.centerOnStandort();
@@ -449,32 +513,64 @@ describe("Standort and distance", () => {
expect(karte.snapshot().center).toEqual(karte.snapshot().standort); 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 () => { test("shows metres below 1000 m and kilometres from 1000 m, and hides a missing Standort", async () => {
const { karte } = await openSheet({ uebersicht: wideSquare, online: false }); const { karte } = await openSheet({ uebersicht: withHole, online: false });
expect(karte.snapshot().distance).toBeNull();
karte.enterKoordinate(sample); 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!; const nearer = karte.snapshot().ziele[0].point;
karte.reportStandort({ easting: ziel.easting, northing: ziel.northing + 500 }); const keptCenter = karte.snapshot().center;
expect(karte.snapshot().distance).toEqual({ metres: 500, text: "500 m" }); 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 }); karte.reportStandort({ easting: nearer.easting, northing: nearer.northing + 999 });
expect(karte.snapshot().distance?.text).toBe("999 m"); expect(karte.snapshot().ziele[0].entfernung?.text).toBe("999 m");
karte.reportStandort({ easting: ziel.easting, northing: ziel.northing + 1000 }); karte.reportStandort({ easting: nearer.easting, northing: nearer.northing + 1000 });
expect(karte.snapshot().distance?.text).toBe("1 km"); expect(karte.snapshot().ziele[0].entfernung?.text).toBe("1 km");
karte.reportStandort({ easting: ziel.easting, northing: ziel.northing + 1500 }); karte.reportStandort({ easting: nearer.easting, northing: nearer.northing + 1500 });
expect(karte.snapshot().distance?.text).toBe("1,5 km"); expect(karte.snapshot().ziele[0].entfernung?.text).toBe("1,5 km");
karte.reportStandort({ easting: ziel.easting + 3000, northing: ziel.northing + 4000 }); karte.reportStandort({ easting: nearer.easting + 3000, northing: nearer.northing + 4000 });
expect(karte.snapshot().distance).toEqual({ metres: 5000, text: "5 km" }); expect(karte.snapshot().ziele[0].entfernung).toEqual({ metres: 5000, text: "5 km" });
expect(karte.snapshot().center).toEqual(ziel); 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) { async function view(easting: number, viewport: Viewport = oneTile) {
const opened = await openSheet({ viewport }); const opened = await openSheet({ viewport });
opened.karte.zoomTo(10); opened.karte.zoomTo(10);
@@ -483,141 +579,203 @@ describe("downloading and removing Hessen", () => {
return opened; 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); const { karte, clock } = await view(250);
karte.startGebiet("Hessen"); karte.startHessen();
const started = hessen(karte.snapshot()); expect(karte.snapshot().hessen).toEqual({ arrived: 0, total: 3 });
expect(started.heruntergeladen).toBe(true); expect(karte.snapshot().visit).toEqual({ arrived: 0, total: 3 });
expect(started.arrived).toBe(0);
expect(started.total).toBe(3);
await clock.tick(); await clock.tick();
expect(appearance(karte, TILE_C)).toBe("drawn"); 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 }); karte.panTo({ easting: 50, northing: 50 });
expect(appearance(karte, TILE_A)).not.toBe("drawn"); expect(appearance(karte, TILE_A)).not.toBe("drawn");
});
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(); 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 }); karte.panTo({ easting: 50, northing: 50 });
expect(appearance(karte, TILE_A)).toBe("drawn"); 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({
viewport: threeTiles,
cache: createMemoryCache([TILE_A]),
online: true,
});
karte.zoomTo(10);
karte.panTo({ easting: 150, northing: 50 }); karte.panTo({ easting: 150, northing: 50 });
karte.startGebiet("Hessen"); expect(appearance(karte, TILE_B)).toBe("drawn");
expect(appearance(karte, TILE_A)).toBe("drawn");
expect(appearance(karte, TILE_B)).toBe("blank-downloading");
expect(hessen(karte.snapshot()).arrived).toBe(1);
}); });
test("a dropped connection keeps finished Kacheln and pauses the rest", async () => { test("a second Hessen start while that fill is unfinished adds nothing", async () => {
const { karte, clock } = await view(250); const { karte, clock } = await view(250);
karte.startGebiet("Hessen"); 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(); await clock.tick();
karte.setOnline(false); karte.setOnline(false);
expect(appearance(karte, TILE_C)).toBe("drawn"); expect(appearance(karte, TILE_C)).toBe("drawn");
expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 });
karte.panTo({ easting: 50, northing: 50 }); karte.panTo({ easting: 50, northing: 50 });
expect(appearance(karte, TILE_A)).toBe("blank-not-on-device"); expect(appearance(karte, TILE_A)).toBe("blank-not-on-device");
await clock.tick(); await clock.tick();
expect(appearance(karte, TILE_A)).toBe("blank-not-on-device"); expect(appearance(karte, TILE_A)).toBe("blank-not-on-device");
expect(karte.snapshot().visit).toEqual({ arrived: 1, total: 3 });
karte.setOnline(true); karte.setOnline(true);
expect(appearance(karte, TILE_A)).toBe("blank-downloading"); 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();
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"); expect(appearance(karte, TILE_A)).toBe("drawn");
karte.panTo({ easting: 150, northing: 50 }); expect(karte.snapshot().visit).toEqual({ arrived: 2, total: 3 });
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 () => { test("starting Hessen fetches every prepared scale, the current scale first", async () => {
const { karte, clock } = await openSheet({ viewport: threeTiles, online: true }); 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]),
});
karte.zoomTo(10); karte.zoomTo(10);
karte.panTo({ easting: 150, northing: 50 }); karte.panTo({ easting: 150, northing: 50 });
karte.startGebiet("Usingen"); karte.setOnline(true);
karte.startGebiet("Wehrheim"); karte.startHessen();
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_A)).toBe("drawn");
expect(appearance(karte, TILE_B)).toBe("drawn"); expect(appearance(karte, TILE_B)).toBe("blank-downloading");
expect(appearance(karte, TILE_C)).toBe("blank-not-on-device"); expect(karte.snapshot().hessen).toEqual({ arrived: 1, total: 3 });
expect(karte.snapshot().gebiete.find((gebiet) => gebiet.name === "Wehrheim")!.heruntergeladen).toBe( expect(karte.snapshot().visit).toEqual({ arrived: 0, total: 2 });
false, await clock.tick();
); await clock.tick();
expect(karte.snapshot().visit).toBeNull();
expect(karte.snapshot().hessen).toEqual({ arrived: 3, total: 3 });
}); });
test("deleting the whole Cache clears marks and does not start Hessen again", async () => { test("deleting the Cache leaves the Ziel", async () => {
const { karte, clock } = await view(250); const { karte } = await openSheet({ uebersicht: wideSquare, online: true });
karte.startGebiet("Hessen"); karte.enterKoordinate(sample);
await clock.tick(); const ziele = karte.snapshot().ziele;
await clock.tick();
await clock.tick();
karte.deleteCache(); karte.deleteCache();
expect(hessen(karte.snapshot()).heruntergeladen).toBe(false); expect(karte.snapshot().ziele).toEqual(ziele);
expect(hessen(karte.snapshot()).arrived).toBe(0); 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"); expect(appearance(karte, TILE_C)).toBe("blank-downloading");
await clock.tick(); await clock.tick();
expect(appearance(karte, TILE_C)).toBe("drawn"); 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.setOnline(false);
karte.removeGebiet("Hessen"); karte.panTo({ easting: 50, northing: 50 });
expect(appearance(karte, TILE_A)).toBe("blank-not-on-device"); 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");
}); });
}); });
+105 -99
View File
@@ -1,7 +1,6 @@
import { maxResolutionMeters } from "../config"; import { maxResolutionMeters } from "../config";
import { parseKoordinate } from "./koordinate"; import { parseKoordinate } from "./koordinate";
import type { import type {
GebietStatus,
KachelView, KachelView,
KarteOptions, KarteOptions,
KarteSession, KarteSession,
@@ -125,6 +124,15 @@ function formatDistance(metres: number): string {
return `${kilometres.toFixed(1).replace(".", ",")} km`; 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 { function isAbort(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError"; return error instanceof DOMException && error.name === "AbortError";
} }
@@ -140,17 +148,16 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
const stored = new Set(loaded); const stored = new Set(loaded);
const cached = new Set(loaded.filter((id) => catalog.has(id))); const cached = new Set(loaded.filter((id) => catalog.has(id)));
const markState = await marks.read(); 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])); const hessenIds: string[] = [];
for (const entry of catalog.values()) { for (const [id, entry] of catalog) {
for (const name of entry.gebiete) { if (entry.gebiete.includes("Hessen")) hessenIds.push(id);
totals.set(name, (totals.get(name) ?? 0) + 1);
} }
function hessenArrived(): number {
let arrived = 0;
for (const id of hessenIds) if (cached.has(id)) arrived += 1;
return arrived;
} }
let viewport = { ...options.viewport }; let viewport = { ...options.viewport };
@@ -160,26 +167,16 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
let seenStandort = standort !== null; let seenStandort = standort !== null;
let personMovedView = false; let personMovedView = false;
let personEnteredZiel = false; let personEnteredZiel = false;
let ziel: Point | null = null; let ziele = restoreZiele(markState.ziele);
let koordinateNotice: string | null = null; let koordinateNotice: string | null = null;
let koordinate = { gitter: "32U MA", rechtswert: "", hochwert: "" }; let koordinate = { gitter: "32U MA", rechtswert: "", hochwert: "" };
let queue: string[] = []; let queue: string[] = [];
let hessenFill: string[] | null = null;
let fetchesHeld = false;
const inFlight = new Map<string, AbortController>(); const inFlight = new Map<string, AbortController>();
const failed = new Set<string>(); const failed = new Set<string>();
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
function arrivedCounts(): Map<string, number> {
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 { function clamp(resolution: number): number {
const maxResolution = maxResolutionMeters(viewport, config); const maxResolution = maxResolutionMeters(viewport, config);
return Math.min(Math.max(resolution, config.minResolutionMeters), maxResolution); return Math.min(Math.max(resolution, config.minResolutionMeters), maxResolution);
@@ -211,8 +208,23 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
for (const listener of listeners) listener(); for (const listener of listeners) listener();
} }
function persistMarks(): void { function persistZiele(): void {
void marks.write({ heruntergeladen: [...heruntergeladen] }); 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[] { function kachelViews(): KachelView[] {
@@ -228,7 +240,11 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
views.push({ id: cell.id, appearance: "drawn" }); views.push({ id: cell.id, appearance: "drawn" });
continue; 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({ views.push({
id: cell.id, id: cell.id,
appearance: willFetch ? "blank-downloading" : "blank-not-on-device", appearance: willFetch ? "blank-downloading" : "blank-not-on-device",
@@ -238,7 +254,15 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
return views; return views;
} }
function releaseFetches(): void {
fetchesHeld = false;
}
function rebuild(): void { function rebuild(): void {
if (fetchesHeld) {
queue = [];
return;
}
const rect = viewRect(center, resolution, viewport); const rect = viewRect(center, resolution, viewport);
const scale = scaleIndex(uebersicht.resolutions, resolution); const scale = scaleIndex(uebersicht.resolutions, resolution);
const viewNow = cellsInView(scale, rect, uebersicht.resolutions, uebersicht.tileSizePx) const viewNow = cellsInView(scale, rect, uebersicht.resolutions, uebersicht.tileSizePx)
@@ -247,17 +271,6 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
for (const id of failed) { for (const id of failed) {
if (!viewNow.includes(id)) failed.delete(id); if (!viewNow.includes(id)) failed.delete(id);
} }
const viewAny = new Set<string>();
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 ordered: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const push = (id: string) => { const push = (id: string) => {
@@ -266,10 +279,9 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
ordered.push(id); ordered.push(id);
}; };
for (const id of viewNow) push(id); for (const id of viewNow) push(id);
for (const id of gebietIds) { if (hessenFill) {
if (viewAny.has(id)) push(id); for (const id of hessenFill) push(id);
} }
for (const id of gebietIds) push(id);
queue = ordered; queue = ordered;
pump(); pump();
} }
@@ -292,6 +304,7 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
cached.add(id); cached.add(id);
stored.add(id); stored.add(id);
await cache.put(id, bytes); await cache.put(id, bytes);
if (hessenFill?.every((tile) => cached.has(tile))) hessenFill = null;
} catch (error) { } catch (error) {
if (!isAbort(error)) failed.add(id); if (!isAbort(error)) failed.add(id);
} finally { } finally {
@@ -310,21 +323,6 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
} }
function snapshot(): KarteSnapshot { 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 views = kachelViews();
const missingWhileOffline = const missingWhileOffline =
!online && views.some((kachel) => kachel.appearance === "blank-not-on-device"); !online && views.some((kachel) => kachel.appearance === "blank-not-on-device");
@@ -333,18 +331,25 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
center: { ...center }, center: { ...center },
resolution, resolution,
northUp: true, 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, standort: standort ? { ...standort } : null,
distance,
notice, notice,
koordinate: { ...koordinate }, koordinate: { ...koordinate },
gebiete, hessen: { arrived: hessenArrived(), total: hessenIds.length },
visit: visitOf(),
kacheln: views, kacheln: views,
}; };
} }
function knownGebiet(name: string): boolean { function visitOf(): { arrived: number; total: number } | null {
return uebersicht.gebiete.some((gebiet) => gebiet.name === name); 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 = { const session: KarteSession = {
@@ -354,18 +359,21 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
return () => listeners.delete(listener); return () => listeners.delete(listener);
}, },
setViewport(next) { setViewport(next) {
releaseFetches();
viewport = { ...next }; viewport = { ...next };
resolution = clamp(resolution); resolution = clamp(resolution);
rebuild(); rebuild();
publish(); publish();
}, },
panTo(next) { panTo(next) {
releaseFetches();
personMovedView = true; personMovedView = true;
center = { ...next }; center = { ...next };
rebuild(); rebuild();
publish(); publish();
}, },
zoomTo(next) { zoomTo(next) {
releaseFetches();
resolution = clamp(next); resolution = clamp(next);
rebuild(); rebuild();
publish(); publish();
@@ -379,6 +387,7 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
} }
standort = { ...next }; standort = { ...next };
if (!seenStandort && !personMovedView && !personEnteredZiel) { if (!seenStandort && !personMovedView && !personEnteredZiel) {
releaseFetches();
center = { ...next }; center = { ...next };
resolution = clamp(config.openResolutionMeters); resolution = clamp(config.openResolutionMeters);
rebuild(); rebuild();
@@ -388,8 +397,11 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
}, },
setAppOpen(open) { setAppOpen(open) {
appOpen = open; appOpen = open;
if (!open) abortInFlight(() => true); if (!open) {
else { hessenFill = null;
abortInFlight(() => true);
} else {
releaseFetches();
failed.clear(); failed.clear();
rebuild(); rebuild();
} }
@@ -399,6 +411,7 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
online = next; online = next;
if (!next) abortInFlight(() => true); if (!next) abortInFlight(() => true);
else { else {
releaseFetches();
failed.clear(); failed.clear();
rebuild(); rebuild();
} }
@@ -413,78 +426,71 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
publish(); publish();
return; return;
} }
if (ziele.some((item) => item.koordinate === parsed.koordinate)) {
koordinateNotice = "Diese Koordinate ist schon ein Ziel.";
publish();
return;
}
koordinateNotice = null; koordinateNotice = null;
koordinate = { koordinate = {
gitter: input.gitter, gitter: input.gitter,
rechtswert: input.rechtswert, rechtswert: input.rechtswert,
hochwert: input.hochwert, hochwert: input.hochwert,
}; };
ziel = parsed.point; ziele = [{ koordinate: parsed.koordinate, point: parsed.point }, ...ziele];
personEnteredZiel = true; personEnteredZiel = true;
releaseFetches();
center = { ...parsed.point }; center = { ...parsed.point };
resolution = clamp(config.openResolutionMeters); resolution = clamp(config.openResolutionMeters);
persistZiele();
rebuild(); rebuild();
publish(); publish();
}, },
deleteZiel(koordinate) {
const next = ziele.filter((item) => item.koordinate !== koordinate);
if (next.length === ziele.length) return;
ziele = next;
persistZiele();
publish();
},
centerOnStandort() { centerOnStandort() {
if (!standort) return; if (!standort) return;
releaseFetches();
personMovedView = true; personMovedView = true;
center = { ...standort }; center = { ...standort };
resolution = clamp(config.openResolutionMeters); resolution = clamp(config.openResolutionMeters);
rebuild(); rebuild();
publish(); publish();
}, },
centerOnZiel() { centerOnZiel(koordinate) {
if (!ziel) return; const found = ziele.find((item) => item.koordinate === koordinate);
if (!found) return;
releaseFetches();
personMovedView = true; personMovedView = true;
center = { ...ziel }; center = { ...found.point };
resolution = clamp(config.openResolutionMeters); resolution = clamp(config.openResolutionMeters);
rebuild(); rebuild();
publish(); publish();
}, },
startGebiet(name) { startHessen() {
if (!knownGebiet(name)) return; if (hessenFill) return;
heruntergeladen.add(name); const missing = hessenIds.filter((id) => !cached.has(id));
persistMarks(); if (missing.length === 0) return;
rebuild(); releaseFetches();
publish(); hessenFill = missing;
},
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();
rebuild(); rebuild();
publish(); publish();
}, },
deleteCache() { deleteCache() {
fetchesHeld = true;
hessenFill = null;
abortInFlight(() => true); abortInFlight(() => true);
const ids = [...stored]; const ids = [...stored];
cached.clear(); cached.clear();
stored.clear(); stored.clear();
heruntergeladen.clear();
failed.clear(); failed.clear();
queue = [];
void cache.delete(ids); void cache.delete(ids);
persistMarks();
rebuild();
publish(); publish();
}, },
}; };
+19 -16
View File
@@ -33,30 +33,33 @@ export type KachelAppearance =
export type KachelView = { id: string; appearance: KachelAppearance }; export type KachelView = { id: string; appearance: KachelAppearance };
export type GebietStatus = {
name: string;
heruntergeladen: boolean;
arrived: number;
total: number;
};
export type KoordinateText = { export type KoordinateText = {
gitter: string; gitter: string;
rechtswert: string; rechtswert: string;
hochwert: 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 = { export type KarteSnapshot = {
center: Point; center: Point;
resolution: number; resolution: number;
northUp: true; northUp: true;
ziel: Point | null; ziele: Ziel[];
standort: Point | null; standort: Point | null;
distance: { metres: number; text: string } | null;
notice: string | null; notice: string | null;
koordinate: KoordinateText; koordinate: KoordinateText;
gebiete: GebietStatus[]; hessen: { arrived: number; total: number };
visit: { arrived: number; total: number } | null;
kacheln: KachelView[]; kacheln: KachelView[];
}; };
@@ -67,8 +70,8 @@ export interface CachePort {
} }
export interface MarksPort { export interface MarksPort {
read(): Promise<{ heruntergeladen: string[] }>; read(): Promise<{ heruntergeladen: string[]; ziele: string[] }>;
write(state: { heruntergeladen: string[] }): Promise<void>; write(state: { heruntergeladen: string[]; ziele: string[] }): Promise<void>;
} }
/** Fetches Kachel bytes. The session decides when, and stores them in the Cache. */ /** Fetches Kachel bytes. The session decides when, and stores them in the Cache. */
@@ -99,9 +102,9 @@ export type KarteSession = {
setAppOpen(open: boolean): void; setAppOpen(open: boolean): void;
setOnline(online: boolean): void; setOnline(online: boolean): void;
enterKoordinate(input: KoordinateText): void; enterKoordinate(input: KoordinateText): void;
deleteZiel(koordinate: string): void;
centerOnStandort(): void; centerOnStandort(): void;
centerOnZiel(): void; centerOnZiel(koordinate: string): void;
startGebiet(name: string): void; startHessen(): void;
removeGebiet(name: string): void;
deleteCache(): void; deleteCache(): void;
}; };
+32 -6
View File
@@ -78,11 +78,11 @@ input {
} }
.fold-toggle, .fold-toggle,
.cache-toggle { .einstellungen-toggle {
flex: 0 0 auto; flex: 0 0 auto;
} }
.cache-toggle[aria-expanded="true"] { .einstellungen-toggle[aria-expanded="true"] {
background: #1e3328; background: #1e3328;
color: #e7e1d4; color: #e7e1d4;
} }
@@ -153,6 +153,18 @@ button.secondary {
color: #1e3328; color: #1e3328;
} }
button.hessen-download {
background: #b42318;
border-color: #b42318;
color: #e7e1d4;
}
button:disabled {
background: #e7e1d4;
color: #6f675c;
border-color: #c4bbaa;
}
.actions { .actions {
flex-direction: row; flex-direction: row;
} }
@@ -162,16 +174,30 @@ button.secondary {
} }
.gebiet p, .gebiet p,
.distance,
.notice, .notice,
.boot-error { .boot-error {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
} }
.distance { .ziel-list {
font-size: 18px; list-style: none;
font-weight: 700; 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 { .notice {
+21 -3
View File
@@ -163,9 +163,8 @@ export function createFieldMap(
}); });
const markers = new VectorSource(); 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]) }); const standort = new Feature({ geometry: new OlPoint([0, 0]) });
ziel.setStyle(markStyle(ZIEL));
standort.setStyle(markStyle(STANDORT)); standort.setStyle(markStyle(STANDORT));
const opening = session.snapshot(); const opening = session.snapshot();
@@ -268,7 +267,26 @@ export function createFieldMap(
return { return {
apply(snapshot) { 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); place(markers, standort, snapshot.standort);
const current = map.getView(); const current = map.getView();
+54 -74
View File
@@ -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"; import { createFieldMap } from "./map";
type ConfirmKind = "hessen" | "cache";
type GebietMode = "download" | "remove" | "none";
export function mountScreen( export function mountScreen(
root: HTMLElement, root: HTMLElement,
session: KarteSession, session: KarteSession,
@@ -20,11 +17,11 @@ export function mountScreen(
<div class="panel-head"> <div class="panel-head">
<h1>Karte</h1> <h1>Karte</h1>
<div class="head-actions"> <div class="head-actions">
<button type="button" class="secondary fold-toggle" aria-expanded="true" aria-controls="koordinaten">Einklappen</button> <button type="button" class="secondary fold-toggle" aria-expanded="true" aria-controls="ziele">Einklappen</button>
<button type="button" class="secondary cache-toggle" aria-expanded="false" aria-controls="cache">Cache</button> <button type="button" class="secondary einstellungen-toggle" aria-expanded="false" aria-controls="einstellungen">Einstellungen</button>
</div> </div>
</div> </div>
<div id="koordinaten" class="fold"> <div id="ziele" class="fold">
<form> <form>
<div class="fields"> <div class="fields">
<label>Gitter <input name="gitter" type="text" autocomplete="off" spellcheck="false" autocapitalize="off" /></label> <label>Gitter <input name="gitter" type="text" autocomplete="off" spellcheck="false" autocapitalize="off" /></label>
@@ -33,16 +30,15 @@ export function mountScreen(
</div> </div>
<button type="submit">Ziel setzen</button> <button type="submit">Ziel setzen</button>
</form> </form>
<ul class="ziel-list"></ul>
<div class="actions"> <div class="actions">
<button type="button" class="secondary">Auf Standort</button> <button type="button" class="secondary">Auf Standort</button>
<button type="button" class="secondary">Auf Ziel</button>
</div> </div>
</div> </div>
<p class="distance" hidden></p> <div id="einstellungen" class="fold" hidden>
<div id="cache" class="fold" hidden>
<div class="gebiet"> <div class="gebiet">
<p hidden></p> <p hidden></p>
<button type="button"></button> <button type="button" class="hessen-download">Hessen herunterladen</button>
</div> </div>
<button type="button" class="secondary wipe">Ganzen Cache löschen</button> <button type="button" class="secondary wipe">Ganzen Cache löschen</button>
</div> </div>
@@ -61,17 +57,16 @@ export function mountScreen(
root.append(panel, mapElement, dialog); root.append(panel, mapElement, dialog);
const fold = panel.querySelector<HTMLElement>("#koordinaten"); const fold = panel.querySelector<HTMLElement>("#ziele");
const foldToggle = panel.querySelector<HTMLButtonElement>(".fold-toggle"); const foldToggle = panel.querySelector<HTMLButtonElement>(".fold-toggle");
const cacheFold = panel.querySelector<HTMLElement>("#cache"); const einstellungenFold = panel.querySelector<HTMLElement>("#einstellungen");
const cacheToggle = panel.querySelector<HTMLButtonElement>(".cache-toggle"); const einstellungenToggle = panel.querySelector<HTMLButtonElement>(".einstellungen-toggle");
const form = panel.querySelector("form"); const form = panel.querySelector("form");
const gitter = panel.querySelector<HTMLInputElement>('input[name="gitter"]'); const gitter = panel.querySelector<HTMLInputElement>('input[name="gitter"]');
const rechtswert = panel.querySelector<HTMLInputElement>('input[name="rechtswert"]'); const rechtswert = panel.querySelector<HTMLInputElement>('input[name="rechtswert"]');
const hochwert = panel.querySelector<HTMLInputElement>('input[name="hochwert"]'); const hochwert = panel.querySelector<HTMLInputElement>('input[name="hochwert"]');
const distance = panel.querySelector<HTMLElement>(".distance"); const zielList = panel.querySelector<HTMLElement>(".ziel-list");
const standortButton = panel.querySelector<HTMLButtonElement>(".actions button:nth-child(1)"); const standortButton = panel.querySelector<HTMLButtonElement>(".actions button");
const zielButton = panel.querySelector<HTMLButtonElement>(".actions button:nth-child(2)");
const gebietBlock = panel.querySelector<HTMLElement>(".gebiet"); const gebietBlock = panel.querySelector<HTMLElement>(".gebiet");
const gebietText = panel.querySelector<HTMLElement>(".gebiet p"); const gebietText = panel.querySelector<HTMLElement>(".gebiet p");
const gebietButton = panel.querySelector<HTMLButtonElement>(".gebiet button"); const gebietButton = panel.querySelector<HTMLButtonElement>(".gebiet button");
@@ -85,15 +80,14 @@ export function mountScreen(
if ( if (
!fold || !fold ||
!foldToggle || !foldToggle ||
!cacheFold || !einstellungenFold ||
!cacheToggle || !einstellungenToggle ||
!form || !form ||
!gitter || !gitter ||
!rechtswert || !rechtswert ||
!hochwert || !hochwert ||
!distance || !zielList ||
!standortButton || !standortButton ||
!zielButton ||
!gebietBlock || !gebietBlock ||
!gebietText || !gebietText ||
!gebietButton || !gebietButton ||
@@ -113,8 +107,7 @@ export function mountScreen(
hochwert.value = opening.koordinate.hochwert; hochwert.value = opening.koordinate.hochwert;
const fieldMap = createFieldMap(mapElement, uebersicht, session); const fieldMap = createFieldMap(mapElement, uebersicht, session);
let gebietMode: GebietMode = "none"; let pendingWipe = false;
let pending: ConfirmKind | null = null;
let kachelKey = kachelnOf(session.snapshot()); let kachelKey = kachelnOf(session.snapshot());
form.addEventListener("submit", (event) => { form.addEventListener("submit", (event) => {
@@ -130,38 +123,36 @@ export function mountScreen(
const open = fold.hasAttribute("hidden"); const open = fold.hasAttribute("hidden");
fold.toggleAttribute("hidden", !open); fold.toggleAttribute("hidden", !open);
foldToggle.setAttribute("aria-expanded", open ? "true" : "false"); foldToggle.setAttribute("aria-expanded", open ? "true" : "false");
foldToggle.textContent = open ? "Einklappen" : "Koordinaten"; foldToggle.textContent = open ? "Einklappen" : "Ziele";
}); });
cacheToggle.addEventListener("click", () => { einstellungenToggle.addEventListener("click", () => {
const open = cacheFold.hasAttribute("hidden"); const open = einstellungenFold.hasAttribute("hidden");
cacheFold.toggleAttribute("hidden", !open); einstellungenFold.toggleAttribute("hidden", !open);
cacheToggle.setAttribute("aria-expanded", open ? "true" : "false"); einstellungenToggle.setAttribute("aria-expanded", open ? "true" : "false");
}); });
standortButton.addEventListener("click", () => session.centerOnStandort()); standortButton.addEventListener("click", () => session.centerOnStandort());
zielButton.addEventListener("click", () => session.centerOnZiel());
gebietButton.addEventListener("click", () => { gebietButton.addEventListener("click", () => {
if (gebietMode === "download") session.startGebiet("Hessen"); if (gebietButton.disabled) return;
if (gebietMode === "remove") ask("hessen"); session.startHessen();
}); });
wipeButton.addEventListener("click", () => ask("cache")); wipeButton.addEventListener("click", () => askToWipe());
confirmButton.addEventListener("click", () => { confirmButton.addEventListener("click", () => {
const kind = pending; const wipe = pendingWipe;
pending = null; pendingWipe = false;
dialog.close(); dialog.close();
if (kind === "hessen") session.removeGebiet("Hessen"); if (wipe) session.deleteCache();
if (kind === "cache") session.deleteCache();
}); });
const dismiss = () => { const dismiss = () => {
pending = null; pendingWipe = false;
if (dialog.open) dialog.close(); if (dialog.open) dialog.close();
}; };
cancelButton.addEventListener("click", dismiss); cancelButton.addEventListener("click", dismiss);
dialog.addEventListener("cancel", () => { dialog.addEventListener("cancel", () => {
pending = null; pendingWipe = false;
}); });
const render = () => { const render = () => {
@@ -176,12 +167,21 @@ export function mountScreen(
}; };
const renderPanel = (snapshot: KarteSnapshot) => { const renderPanel = (snapshot: KarteSnapshot) => {
if (snapshot.distance) { zielList.replaceChildren();
distance.hidden = false; for (const ziel of snapshot.ziele) {
distance.textContent = `Entfernung ${snapshot.distance.text}`; const row = document.createElement("li");
} else { const open = document.createElement("button");
distance.hidden = true; open.type = "button";
distance.textContent = ""; open.className = "secondary ziel-open";
open.textContent = ziel.entfernung ? `${ziel.koordinate} ${ziel.entfernung.text}` : ziel.koordinate;
open.addEventListener("click", () => session.centerOnZiel(ziel.koordinate));
const remove = document.createElement("button");
remove.type = "button";
remove.className = "secondary";
remove.textContent = "Löschen";
remove.addEventListener("click", () => session.deleteZiel(ziel.koordinate));
row.append(open, remove);
zielList.append(row);
} }
if (snapshot.notice) { if (snapshot.notice) {
@@ -192,46 +192,26 @@ export function mountScreen(
notice.textContent = ""; notice.textContent = "";
} }
renderHessen(snapshot.gebiete.find((gebiet) => gebiet.name === "Hessen")); const missing = snapshot.hessen.arrived < snapshot.hessen.total;
};
const renderHessen = (hessen: GebietStatus | undefined) => {
if (!hessen) {
gebietBlock.hidden = true;
gebietMode = "none";
return;
}
gebietBlock.hidden = false; gebietBlock.hidden = false;
if (!hessen.heruntergeladen) { gebietButton.textContent = "Hessen herunterladen";
gebietButton.disabled = !missing;
gebietButton.classList.toggle("hessen-download", missing);
if (snapshot.visit) {
gebietText.hidden = false;
gebietText.textContent = `Kacheln: ${snapshot.visit.arrived} von ${snapshot.visit.total}`;
} else {
gebietText.hidden = true; gebietText.hidden = true;
gebietText.textContent = ""; gebietText.textContent = "";
gebietButton.textContent = "Hessen herunterladen";
gebietButton.classList.remove("secondary");
gebietMode = "download";
return;
} }
gebietMode = "remove";
gebietButton.textContent = "Hessen entfernen";
gebietButton.classList.add("secondary");
gebietText.hidden = false;
gebietText.textContent =
hessen.arrived === hessen.total
? "Hessen heruntergeladen"
: `Hessen: ${hessen.arrived} von ${hessen.total} Kacheln`;
}; };
const ask = (kind: ConfirmKind) => { const askToWipe = () => {
if (dialog.open) return; if (dialog.open) return;
pending = kind; pendingWipe = true;
if (kind === "hessen") {
question.textContent = "Hessen entfernen?";
explain.textContent = "Die Kacheln von Hessen werden gelöscht.";
confirmButton.textContent = "Entfernen";
} else {
question.textContent = "Den ganzen Cache löschen?"; question.textContent = "Den ganzen Cache löschen?";
explain.textContent = "Alle Kacheln werden gelöscht."; explain.textContent = "Alle Kacheln werden gelöscht.";
confirmButton.textContent = "Löschen"; confirmButton.textContent = "Löschen";
}
dialog.showModal(); dialog.showModal();
}; };