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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user