@osm-editor-kit/osm-coverage 0.1.0-alpha.0 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,17 @@
1
1
  # `@osm-editor-kit/osm-coverage`
2
2
 
3
- **Status:** First npm **alpha** (`0.1.0-alpha.0`). Publish with `bun run packages:release -- --publish-only` after `npm login`.
3
+ > [!NOTE]
4
+ > This package is an **alpha** release and still under active development. APIs may change; install with the npm `alpha` dist-tag.
4
5
 
5
6
  ## What it does
6
7
 
7
- TanStack Query–backed OSM viewport coverage for map editors: incremental downloads, merged `ParsedOsmData` graphs, coverage polygons, and fetch-history GeoJSON. Includes zoom-aware missing-area detection (`computeMissingFetchRequests`, `unionIntoCoverage`), Overpass URL helpers (`boundsToOverpassBbox`, `buildOverpassInterpreterUrl`, `overpassDeUrl`, `overpassVkUrl`), and an `OsmDataSource` enum for download backends.
8
-
9
- The `@osm-editor-kit/osm-coverage/dev-osm-map-fixture` subpath is **editor-dev only** — local OSM Map API fixture handler, bbox filtering, and fixture index loader.
8
+ - Download OSM data for the current map viewport (and only the parts you have not covered yet)
9
+ - Keep a session graph of ways/nodes/relations as the map moves and pans
10
+ - Track covered areas as polygons so refetches skip what you already have
11
+ - Record fetch history as GeoJSON for debugging and UI
12
+ - Build Overpass query URLs for common interpreters
13
+ - Optional durable session storage (`storage` adapter) for graph + coverage across reloads
14
+ - Optional editor-dev fixture loader via `@osm-editor-kit/osm-coverage/dev-osm-map-fixture` (not for production apps; not on the main export)
10
15
 
11
16
  ## Usage
12
17
 
@@ -19,11 +24,13 @@ const api = createOsmCoverageApi<{ source: OsmDataSource }>({
19
24
  minZoom: 14,
20
25
  getDownloadUrl: (bounds, { source }) =>
21
26
  /* build Overpass or Map API URL */ '',
27
+ // optional: storage: { load, save, clear }
22
28
  })
23
29
 
24
30
  const queryClient = new QueryClient()
25
31
  const params = { source: OsmDataSource.OverpassVk }
26
32
 
33
+ await api.restoreSession(queryClient, params)
27
34
  await api.ensureCoverage(queryClient, {
28
35
  bounds: { south: 52.47, west: 13.44, north: 52.48, east: 13.45 },
29
36
  zoom: 18,
@@ -32,5 +39,5 @@ await api.ensureCoverage(queryClient, {
32
39
  })
33
40
 
34
41
  const data = queryClient.getQueryData(api.sessionKey(params))
35
- // { graph, coverage, fetchHistory }
42
+ // { graph, coverage, fetchHistory, savedAt }
36
43
  ```
@@ -2,11 +2,16 @@ import { type MapBounds, type ParsedOsmData } from '@osm-editor-kit/osm-data';
2
2
  import { type QueryClient } from '@tanstack/react-query';
3
3
  import type { Feature, FeatureCollection, MultiPolygon, Polygon } from 'geojson';
4
4
  import { type CoverageFetchProps, type MapSizePx } from './coverage-geometry';
5
+ import type { OsmCoverageStorage } from './osm-coverage-storage';
5
6
  export type { CoverageFetchProps, MapSizePx } from './coverage-geometry';
7
+ export type { OsmCoveragePersisted, OsmCoverageStorage } from './osm-coverage-storage';
8
+ export { formatCoverageAgeHour, getCoverageSavedAt } from './osm-coverage-storage';
6
9
  export type OsmCoverageQueryData = {
7
10
  graph: ParsedOsmData;
8
11
  coverage: Feature<Polygon | MultiPolygon> | null;
9
12
  fetchHistory: FeatureCollection<Polygon, CoverageFetchProps>;
13
+ /** ISO timestamp of last durable save / successful network merge; null if never saved. */
14
+ savedAt: string | null;
10
15
  };
11
16
  export type CreateOsmCoverageApiOptions<TSessionParams> = {
12
17
  getSessionKey: (params: TSessionParams) => readonly unknown[];
@@ -15,22 +20,34 @@ export type CreateOsmCoverageApiOptions<TSessionParams> = {
15
20
  download?: (url: string) => Promise<ParsedOsmData>;
16
21
  /** When false, session queries stay enabled=false and ensureCoverage skips network. */
17
22
  isNetworkEnabled?: () => boolean;
23
+ /** Optional durable store for graph + coverage across reloads. */
24
+ storage?: OsmCoverageStorage;
25
+ /** Debounce durable writes after merges (ms). Default 500. */
26
+ storageSaveDebounceMs?: number;
18
27
  };
19
- export declare function createOsmCoverageApi<TSessionParams>({ getSessionKey, minZoom, getDownloadUrl, download, isNetworkEnabled, }: CreateOsmCoverageApiOptions<TSessionParams>): {
28
+ export declare function createOsmCoverageApi<TSessionParams>({ getSessionKey, minZoom, getDownloadUrl, download, isNetworkEnabled, storage, storageSaveDebounceMs, }: CreateOsmCoverageApiOptions<TSessionParams>): {
20
29
  emptyData: () => OsmCoverageQueryData;
21
30
  sessionKey: (params: TSessionParams) => readonly unknown[];
22
31
  coverageKey: (params: TSessionParams) => readonly [...unknown[], "coverage"];
23
- ensureCoverage: (queryClient: QueryClient, { bounds, zoom, mapSizePx, force, ...sessionParams }: {
32
+ ensureCoverage: (queryClient: QueryClient, { bounds, zoom, mapSizePx, force, skipRestore, clearPersistedOnForce, ...sessionParams }: {
24
33
  bounds: MapBounds;
25
34
  zoom: number;
26
35
  mapSizePx: MapSizePx;
27
36
  force?: boolean;
37
+ /** Skip durable hydrate for this call (e.g. prefer-fresh). */
38
+ skipRestore?: boolean;
39
+ /** When force=true, also wipe durable storage before refetch. */
40
+ clearPersistedOnForce?: boolean;
28
41
  } & TSessionParams) => Promise<{
29
42
  skipped: boolean;
30
43
  }>;
44
+ restoreSession: (queryClient: QueryClient, params: TSessionParams, options?: {
45
+ forceReload?: boolean;
46
+ }) => Promise<boolean>;
47
+ clearPersisted: (params: TSessionParams) => Promise<void>;
31
48
  createUseQuery: (useSessionParams: () => TSessionParams) => <TData = OsmCoverageQueryData>(options?: {
32
49
  select?: (data: OsmCoverageQueryData) => TData;
33
- }) => import("@tanstack/react-query").DefinedUseQueryResult<NoInfer<TData>, Error>;
50
+ }) => import("@tanstack/react-query").UseQueryResult<NoInfer<TData>, Error>;
34
51
  createUseIsFetching: (useSessionParams: () => TSessionParams) => () => boolean;
35
52
  createUseResetOnSessionChange: (useSessionParams: () => TSessionParams) => () => void;
36
53
  };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- export { DEV_OSM_MAP_FIXTURE_ROUTE, devOsmMapFixtureMapUrl, } from './dev-osm-map-fixture/dev-osm-map-fixture-url';
2
- export { createOsmCoverageApi, type OsmCoverageQueryData } from './create-osm-coverage-api';
1
+ export { createOsmCoverageApi, type OsmCoverageQueryData, type OsmCoveragePersisted, type OsmCoverageStorage, formatCoverageAgeHour, getCoverageSavedAt, } from './create-osm-coverage-api';
3
2
  export { type CoverageFetchProps, type CoverageFetchKind, type MapSizePx, boundsToPolygon, bufferPxAtZoom, computeMissingFetchRequests, unionIntoCoverage, } from './coverage-geometry';
4
3
  export { OsmDataSource } from './osm-data-source';
5
4
  export { boundsToOverpassBbox, buildOverpassInterpreterUrl } from './overpass-url';
package/dist/index.js CHANGED
@@ -10,13 +10,7 @@ import { featureCollection } from '@turf/helpers';
10
10
  import intersect from '@turf/intersect';
11
11
  import union from '@turf/union';
12
12
 
13
- // src/dev-osm-map-fixture/dev-osm-map-fixture-url.ts
14
- var DEV_OSM_MAP_FIXTURE_ROUTE = "dev-osm-map-fixture";
15
- function devOsmMapFixtureMapUrl(baseUrl, bounds) {
16
- const base = baseUrl.replace(/\/$/, "");
17
- const bbox2 = [bounds.west, bounds.south, bounds.east, bounds.north].join(",");
18
- return `${base}/${DEV_OSM_MAP_FIXTURE_ROUTE}/api/0.6/map?bbox=${bbox2}`;
19
- }
13
+ // src/create-osm-coverage-api.ts
20
14
  var BUFFER_PX_AT_Z13 = 200;
21
15
  var MIN_MISSING_AREA_RATIO = 5e-3;
22
16
  var FULL_FETCH_RATIO = 0.85;
@@ -209,29 +203,112 @@ function appendFetchHistory(history, groupId, fetchedAt, requests) {
209
203
  });
210
204
  }
211
205
 
206
+ // src/osm-coverage-storage.ts
207
+ function getCoverageSavedAt(savedAt) {
208
+ if (!savedAt) return null;
209
+ const parsed = Date.parse(savedAt);
210
+ return Number.isNaN(parsed) ? null : savedAt;
211
+ }
212
+ function formatCoverageAgeHour(savedAt, now = /* @__PURE__ */ new Date()) {
213
+ const iso = getCoverageSavedAt(savedAt);
214
+ if (!iso) return null;
215
+ const saved = new Date(iso);
216
+ saved.setMinutes(0, 0, 0);
217
+ const hour = String(saved.getHours()).padStart(2, "0");
218
+ const sameDay = saved.getFullYear() === now.getFullYear() && saved.getMonth() === now.getMonth() && saved.getDate() === now.getDate();
219
+ if (sameDay) {
220
+ return `${hour}:00`;
221
+ }
222
+ const y = saved.getFullYear();
223
+ const m = String(saved.getMonth() + 1).padStart(2, "0");
224
+ const d = String(saved.getDate()).padStart(2, "0");
225
+ return `${y}-${m}-${d} ${hour}:00`;
226
+ }
227
+
212
228
  // src/create-osm-coverage-api.ts
229
+ var DEFAULT_STORAGE_SAVE_DEBOUNCE_MS = 500;
213
230
  function createOsmCoverageApi({
214
231
  getSessionKey,
215
232
  minZoom,
216
233
  getDownloadUrl,
217
234
  download = downloadOsmData,
218
- isNetworkEnabled
235
+ isNetworkEnabled,
236
+ storage,
237
+ storageSaveDebounceMs = DEFAULT_STORAGE_SAVE_DEBOUNCE_MS
219
238
  }) {
220
239
  const getCoverageKey = (params) => [...getSessionKey(params), "coverage"];
221
240
  const networkEnabled = () => isNetworkEnabled?.() ?? true;
222
241
  const coverageChains = /* @__PURE__ */ new Map();
242
+ const hydratedSessions = /* @__PURE__ */ new Set();
243
+ const saveTimers = /* @__PURE__ */ new Map();
223
244
  function emptyData() {
224
245
  return {
225
246
  graph: emptyParsedOsmData(),
226
247
  coverage: null,
227
- fetchHistory: emptyFetchHistory()
248
+ fetchHistory: emptyFetchHistory(),
249
+ savedAt: null
228
250
  };
229
251
  }
252
+ function scheduleSave(sessionKey, data) {
253
+ if (!storage || !data.coverage) return;
254
+ const chainKey = JSON.stringify(sessionKey);
255
+ const previous = saveTimers.get(chainKey);
256
+ if (previous) clearTimeout(previous);
257
+ const persisted = {
258
+ graph: data.graph,
259
+ coverage: data.coverage,
260
+ savedAt: data.savedAt ?? (/* @__PURE__ */ new Date()).toISOString()
261
+ };
262
+ saveTimers.set(
263
+ chainKey,
264
+ setTimeout(() => {
265
+ saveTimers.delete(chainKey);
266
+ void storage.save(sessionKey, persisted).catch((error) => {
267
+ console.error("Failed to persist OSM coverage session", error);
268
+ });
269
+ }, storageSaveDebounceMs)
270
+ );
271
+ }
272
+ async function restoreSession(queryClient, params, options) {
273
+ if (!storage) return false;
274
+ const sessionKey = getSessionKey(params);
275
+ const chainKey = JSON.stringify(sessionKey);
276
+ if (options?.forceReload) {
277
+ hydratedSessions.delete(chainKey);
278
+ }
279
+ if (hydratedSessions.has(chainKey)) return false;
280
+ hydratedSessions.add(chainKey);
281
+ const loaded = await storage.load(sessionKey);
282
+ if (!loaded?.coverage) return false;
283
+ const current = queryClient.getQueryData(sessionKey);
284
+ if (current?.coverage) return false;
285
+ queryClient.setQueryData(sessionKey, {
286
+ graph: loaded.graph,
287
+ coverage: loaded.coverage,
288
+ fetchHistory: emptyFetchHistory(),
289
+ savedAt: loaded.savedAt
290
+ });
291
+ return true;
292
+ }
293
+ async function clearPersisted(params) {
294
+ if (!storage) return;
295
+ const sessionKey = getSessionKey(params);
296
+ const chainKey = JSON.stringify(sessionKey);
297
+ const pending = saveTimers.get(chainKey);
298
+ if (pending) {
299
+ clearTimeout(pending);
300
+ saveTimers.delete(chainKey);
301
+ }
302
+ hydratedSessions.delete(chainKey);
303
+ await storage.clear(sessionKey);
304
+ }
230
305
  async function ensureCoverage(queryClient, {
231
306
  bounds,
232
307
  zoom,
233
308
  mapSizePx,
234
309
  force = false,
310
+ skipRestore = false,
311
+ clearPersistedOnForce = false,
235
312
  ...sessionParams
236
313
  }) {
237
314
  if (!networkEnabled()) {
@@ -242,6 +319,12 @@ function createOsmCoverageApi({
242
319
  const coverageKey = getCoverageKey(params);
243
320
  const chainKey = JSON.stringify(sessionKey);
244
321
  const run = async () => {
322
+ if (!skipRestore && !force) {
323
+ await restoreSession(queryClient, params);
324
+ }
325
+ if (force && clearPersistedOnForce) {
326
+ await clearPersisted(params);
327
+ }
245
328
  const requestKey = [...coverageKey, crypto.randomUUID()];
246
329
  return queryClient.fetchQuery({
247
330
  queryKey: requestKey,
@@ -274,7 +357,14 @@ function createOsmCoverageApi({
274
357
  for (const newGraph of fetchedGraphs) {
275
358
  graph = mergeParsedOsm(graph, newGraph);
276
359
  }
277
- return { graph, coverage, fetchHistory };
360
+ const next2 = {
361
+ graph,
362
+ coverage,
363
+ fetchHistory,
364
+ savedAt: fetchedAt
365
+ };
366
+ scheduleSave(sessionKey, next2);
367
+ return next2;
278
368
  });
279
369
  return { skipped: false };
280
370
  },
@@ -299,8 +389,11 @@ function createOsmCoverageApi({
299
389
  return useQuery({
300
390
  queryKey: getSessionKey(params),
301
391
  queryFn: () => emptyData(),
302
- initialData: emptyData(),
392
+ // Placeholder (not initialData) so durable restore via setQueryData is not fought
393
+ // by a permanently seeded empty session.
394
+ placeholderData: emptyData(),
303
395
  staleTime: Number.POSITIVE_INFINITY,
396
+ gcTime: Number.POSITIVE_INFINITY,
304
397
  enabled: networkEnabled(),
305
398
  select: options?.select
306
399
  });
@@ -332,6 +425,8 @@ function createOsmCoverageApi({
332
425
  sessionKey: getSessionKey,
333
426
  coverageKey: getCoverageKey,
334
427
  ensureCoverage,
428
+ restoreSession,
429
+ clearPersisted,
335
430
  createUseQuery,
336
431
  createUseIsFetching,
337
432
  createUseResetOnSessionChange
@@ -358,4 +453,4 @@ function buildOverpassInterpreterUrl(server, query) {
358
453
  var overpassDeUrl = "https://overpass-api.de/api/interpreter?data=";
359
454
  var overpassVkUrl = "https://maps.mail.ru/osm/tools/overpass/api/interpreter?data=";
360
455
 
361
- export { DEV_OSM_MAP_FIXTURE_ROUTE, OsmDataSource, boundsToOverpassBbox, boundsToPolygon, bufferPxAtZoom, buildOverpassInterpreterUrl, computeMissingFetchRequests, createOsmCoverageApi, devOsmMapFixtureMapUrl, overpassDeUrl, overpassVkUrl, unionIntoCoverage };
456
+ export { OsmDataSource, boundsToOverpassBbox, boundsToPolygon, bufferPxAtZoom, buildOverpassInterpreterUrl, computeMissingFetchRequests, createOsmCoverageApi, formatCoverageAgeHour, getCoverageSavedAt, overpassDeUrl, overpassVkUrl, unionIntoCoverage };
@@ -0,0 +1,20 @@
1
+ import type { ParsedOsmData } from '@osm-editor-kit/osm-data';
2
+ import type { Feature, MultiPolygon, Polygon } from 'geojson';
3
+ /** Durable session blob — fetchHistory stays memory-only. */
4
+ export type OsmCoveragePersisted = {
5
+ graph: ParsedOsmData;
6
+ coverage: Feature<Polygon | MultiPolygon> | null;
7
+ savedAt: string;
8
+ };
9
+ export type OsmCoverageStorage = {
10
+ load(sessionKey: readonly unknown[]): Promise<OsmCoveragePersisted | null>;
11
+ save(sessionKey: readonly unknown[], data: OsmCoveragePersisted): Promise<void>;
12
+ clear(sessionKey: readonly unknown[]): Promise<void>;
13
+ };
14
+ /** ISO `savedAt` from session query data, or null when never persisted/fetched. */
15
+ export declare function getCoverageSavedAt(savedAt: string | null | undefined): string | null;
16
+ /**
17
+ * Round `savedAt` down to the hour for UI (“OSM data from 14:00”).
18
+ * Returns null when missing/invalid.
19
+ */
20
+ export declare function formatCoverageAgeHour(savedAt: string | null | undefined, now?: Date): string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osm-editor-kit/osm-coverage",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "Incremental OSM coverage fetching for map viewports with TanStack Query session state.",
5
5
  "license": "MIT",
6
6
  "author": "Tobias Jordans",
@@ -9,6 +9,7 @@
9
9
  "url": "git+https://github.com/osmberlin/street-space-editor.git",
10
10
  "directory": "packages/osm-coverage"
11
11
  },
12
+ "homepage": "https://github.com/osmberlin/street-space-editor/tree/main/packages/osm-coverage",
12
13
  "type": "module",
13
14
  "sideEffects": false,
14
15
  "files": [