Let a tap name a Ziel and download the view in front of you.

The same queue fetches that view at every prepared scale when Download-Knopf is on.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-09 08:38:35 +02:00
co-authored by Cursor
parent 6e635320e5
commit 2f1fab202e
9 changed files with 667 additions and 20 deletions
+17 -6
View File
@@ -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<unknown> {
@@ -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);
+3 -1
View File
@@ -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;
},
};
}
+23 -2
View File
@@ -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,
+463 -1
View File
@@ -48,6 +48,7 @@ async function openSheet(options?: {
clock?: ReturnType<typeof createFetchClock>;
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();
});
});
+104 -8
View File
@@ -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<KarteSession> {
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<string, AbortController>();
const failed = new Set<string>();
@@ -208,8 +210,12 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
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<KarteSession> {
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<KarteSession> {
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<KarteSession> {
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<string>();
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<string>();
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<KarteSession> {
appOpen = open;
if (!open) {
hessenFill = null;
viewFill = null;
abortInFlight(() => true);
} else {
releaseFetches();
@@ -417,6 +459,43 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
}
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<KarteSession> {
releaseFetches();
center = { ...parsed.point };
resolution = clamp(config.openResolutionMeters);
persistZiele();
persistMarks();
rebuild();
publish();
},
@@ -450,7 +529,12 @@ export async function openKarte(options: KarteOptions): Promise<KarteSession> {
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<KarteSession> {
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();
+13 -2
View File
@@ -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<void>;
}
/** 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<void>;
read(): Promise<RememberedMarks>;
write(state: RememberedMarks): Promise<void>;
}
/** 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;
};
+25
View File
@@ -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;
+4
View File
@@ -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", () => {
+15
View File
@@ -36,6 +36,7 @@ export function mountScreen(
</div>
</div>
<div id="einstellungen" class="fold" hidden>
<label class="download-knopf"><input type="checkbox" /> Download-Knopf</label>
<div class="gebiet">
<p hidden></p>
<button type="button" class="hessen-download">Hessen herunterladen</button>
@@ -67,6 +68,7 @@ export function mountScreen(
const hochwert = panel.querySelector<HTMLInputElement>('input[name="hochwert"]');
const zielList = panel.querySelector<HTMLElement>(".ziel-list");
const standortButton = panel.querySelector<HTMLButtonElement>(".actions button");
const downloadKnopf = panel.querySelector<HTMLInputElement>(".download-knopf input");
const gebietBlock = panel.querySelector<HTMLElement>(".gebiet");
const gebietText = panel.querySelector<HTMLElement>(".gebiet p");
const gebietButton = panel.querySelector<HTMLButtonElement>(".gebiet button");
@@ -88,6 +90,7 @@ export function mountScreen(
!hochwert ||
!zielList ||
!standortButton ||
!downloadKnopf ||
!gebietBlock ||
!gebietText ||
!gebietButton ||
@@ -107,6 +110,11 @@ export function mountScreen(
hochwert.value = opening.koordinate.hochwert;
const fieldMap = createFieldMap(mapElement, uebersicht, session);
const viewDownload = document.createElement("button");
viewDownload.type = "button";
viewDownload.className = "map-download";
viewDownload.textContent = "Diese Ansicht herunterladen";
viewDownload.addEventListener("click", () => session.downloadView());
let pendingWipe = false;
let kachelKey = kachelnOf(session.snapshot());
@@ -133,6 +141,9 @@ export function mountScreen(
});
standortButton.addEventListener("click", () => session.centerOnStandort());
downloadKnopf.addEventListener("change", () => {
session.setDownloadKnopf(downloadKnopf.checked);
});
gebietButton.addEventListener("click", () => {
if (gebietButton.disabled) return;
session.startHessen();
@@ -192,6 +203,10 @@ export function mountScreen(
notice.textContent = "";
}
downloadKnopf.checked = snapshot.downloadKnopf;
if (snapshot.downloadKnopf) mapElement.append(viewDownload);
else viewDownload.remove();
const missing = snapshot.hessen.arrived < snapshot.hessen.total;
gebietBlock.hidden = false;
gebietButton.textContent = "Hessen herunterladen";