@konce-pt/map 0.8.0

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.
@@ -0,0 +1,396 @@
1
+ import { metersPerPixel, project, unproject } from "../geo.js";
2
+ const LOAD_ERROR = 'kpt-map: nie udało się załadować Google Maps JavaScript API. ' +
3
+ 'Sprawdź klucz (`apiKey`), czy Maps JavaScript API jest włączone w projekcie ' +
4
+ 'i czy restrykcja domenowa klucza obejmuje ten adres.';
5
+ /** Nazwa globalnego callbacku, którego wymaga `loading=async`. */
6
+ const CALLBACK = '__kptGoogleMapsReady';
7
+ let modulePromise = null;
8
+ /**
9
+ * Ładuje Google Maps JavaScript API raz na aplikację. Gdy `google.maps` już istnieje — bo
10
+ * aplikacja wstawiła skrypt sama albo stoi tu druga mapa — korzystamy z tego, co jest: drugie
11
+ * wczytanie Google i tak odrzuca, wypisując ostrzeżenie do konsoli.
12
+ *
13
+ * Publiczne, bo silnik nie jest jedynym, kto tego API potrzebuje. Geokoder Google **musi**
14
+ * iść przez SDK, a nie przez REST: web service `/maps/api/geocode/json` odrzuca klucze
15
+ * z restrykcją `Referer` („API keys with referer restrictions cannot be used with this API"),
16
+ * a to jedyna restrykcja, jaką da się sensownie nałożyć na klucz leżący w bundlu przeglądarki.
17
+ * Wywołania z załadowanego SDK autoryzują się tak samo jak sama mapa.
18
+ *
19
+ * Adaptera geokodera biblioteka nadal nie dostarcza — tylko drogę do API, którą i tak
20
+ * otwiera silnikowi. Powody stoją przy `provideKptGoogleMap` i w `llms.txt`.
21
+ */
22
+ export async function loadGoogleMapsApi(options) {
23
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
24
+ throw new Error('kpt-map: silnik Google Maps wymaga przeglądarki.');
25
+ }
26
+ const existing = window['google']?.maps;
27
+ if (existing?.importLibrary)
28
+ return existing;
29
+ modulePromise ??= new Promise((resolve, reject) => {
30
+ const params = new URLSearchParams({
31
+ key: options.apiKey,
32
+ v: options.version ?? 'weekly',
33
+ loading: 'async',
34
+ callback: CALLBACK,
35
+ });
36
+ if (options.language)
37
+ params.set('language', options.language);
38
+ if (options.region)
39
+ params.set('region', options.region);
40
+ window[CALLBACK] = () => {
41
+ delete window[CALLBACK];
42
+ resolve(window['google'].maps);
43
+ };
44
+ const script = document.createElement('script');
45
+ script.src = `https://maps.googleapis.com/maps/api/js?${params}`;
46
+ script.async = true;
47
+ script.onerror = () => {
48
+ modulePromise = null; // pozwól spróbować ponownie po poprawieniu klucza
49
+ reject(new Error(LOAD_ERROR));
50
+ };
51
+ document.head.append(script);
52
+ });
53
+ return modulePromise;
54
+ }
55
+ /** Fabryka silnika — każda mapa dostaje własną instancję. */
56
+ export function createGoogleEngine(options) {
57
+ return new GoogleEngine(options);
58
+ }
59
+ class GoogleEngine {
60
+ constructor(options) {
61
+ this.options = options;
62
+ this.kind = 'google';
63
+ this.maps = null;
64
+ this.markerLib = null;
65
+ this.map = null;
66
+ this.host = null;
67
+ this.userMarker = null;
68
+ /** Krąg dokładności jest własnym `<div>`, nie `google.maps.Circle` — patrz `setUserPosition`. */
69
+ this.accuracyEl = null;
70
+ this.accuracyMeters = 0;
71
+ this.accuracyAt = null;
72
+ /** Znaczniki po `id` wraz z ich sprzątaniem — bez tego każdy przelicz przerysowywałby mapę. */
73
+ this.markers = new Map();
74
+ this.listeners = [];
75
+ this.handlers = {};
76
+ }
77
+ async init(host, opts) {
78
+ const maps = (this.maps = await loadGoogleMapsApi(this.options));
79
+ // Pod nazwą `Map` kryje się konstruktor Google, nie wbudowana kolekcja — stąd alias.
80
+ const [{ Map: GoogleMap }, markerLib] = await Promise.all([
81
+ maps.importLibrary('maps'),
82
+ maps.importLibrary('marker'),
83
+ ]);
84
+ this.markerLib = markerLib;
85
+ this.host = host;
86
+ this.map = new GoogleMap(host, {
87
+ center: { lat: opts.center.lat, lng: opts.center.lng },
88
+ zoom: opts.zoom,
89
+ minZoom: opts.minZoom ?? this.options.minZoom ?? 2,
90
+ maxZoom: opts.maxZoom ?? this.options.maxZoom ?? 21,
91
+ mapId: this.options.mapId,
92
+ // Kontrolki rysuje warstwa Angulara — te same przyciski w obu silnikach.
93
+ disableDefaultUI: true,
94
+ // POI Google przechwytywałyby kliknięcia przeznaczone dla mapy (menu „dodaj lokalizację").
95
+ clickableIcons: false,
96
+ scrollwheel: this.options.scrollWheelZoom ?? true,
97
+ restriction: opts.maxBounds
98
+ ? { latLngBounds: this.toLatLngBounds(opts.maxBounds), strictBounds: true }
99
+ : undefined,
100
+ });
101
+ // `opts.tileUrl` i `opts.attribution` są tu bez znaczenia: kafle i atrybucję daje Google,
102
+ // a jej zasłonięcie albo usunięcie łamie warunki korzystania z usługi.
103
+ this.on_(this.map, 'bounds_changed', () => this.emit('move'));
104
+ this.on_(this.map, 'idle', () => this.emit('moveend'));
105
+ this.on_(this.map, 'zoom_changed', () => {
106
+ // Krąg dokładności ma stały promień w metrach, więc przy każdym zoomie zmienia piksele.
107
+ this.resizeAccuracy();
108
+ this.emit('zoomend');
109
+ });
110
+ this.on_(this.map, 'click', (e) => this.emit('click', this.toPointerEvent(e)));
111
+ this.on_(this.map, 'contextmenu', (e) => {
112
+ // Menu przeglądarki zasłoniłoby własne „Dodaj lokalizację".
113
+ e.domEvent?.preventDefault();
114
+ this.emit('contextmenu', this.toPointerEvent(e));
115
+ });
116
+ // Pierwszy `idle` znaczy, że mapa zna już swój rozmiar i zasięg.
117
+ await new Promise((resolve) => {
118
+ this.maps.event.addListenerOnce(this.map, 'idle', () => resolve());
119
+ });
120
+ }
121
+ destroy() {
122
+ for (const { marker, dispose } of this.markers.values()) {
123
+ dispose();
124
+ marker.map = null;
125
+ }
126
+ this.markers.clear();
127
+ if (this.userMarker)
128
+ this.userMarker.map = null;
129
+ this.userMarker = null;
130
+ this.accuracyEl = null;
131
+ this.accuracyAt = null;
132
+ for (const listener of this.listeners)
133
+ listener.remove?.();
134
+ this.listeners.length = 0;
135
+ // Google nie ma odpowiednika `map.remove()` — kontener czyścimy sami.
136
+ if (this.map)
137
+ this.maps?.event.clearInstanceListeners(this.map);
138
+ this.host?.replaceChildren();
139
+ this.map = null;
140
+ this.host = null;
141
+ }
142
+ setView(center, zoom, animate = true) {
143
+ if (!this.map)
144
+ return;
145
+ const at = { lat: center.lat, lng: center.lng };
146
+ if (animate) {
147
+ this.map.panTo(at);
148
+ this.map.setZoom(zoom);
149
+ }
150
+ else {
151
+ this.map.moveCamera({ center: at, zoom });
152
+ }
153
+ }
154
+ fitBounds(bounds, paddingPx = 0) {
155
+ this.map?.fitBounds(this.toLatLngBounds(bounds), paddingPx);
156
+ }
157
+ panBy(offset) {
158
+ this.map?.panBy(offset.x, offset.y);
159
+ }
160
+ getCenter() {
161
+ const c = this.map.getCenter();
162
+ return { lat: c.lat(), lng: c.lng() };
163
+ }
164
+ getZoom() {
165
+ return this.map.getZoom();
166
+ }
167
+ /**
168
+ * Zasięg widoku. `getBounds()` bywa `undefined`, zanim mapa policzy pierwszy układ —
169
+ * liczymy go wtedy z rdzenia, zamiast oddawać `null` przez kontrakt, który go nie przewiduje.
170
+ */
171
+ getBounds() {
172
+ const b = this.map?.getBounds();
173
+ if (b) {
174
+ const ne = b.getNorthEast();
175
+ const sw = b.getSouthWest();
176
+ return { south: sw.lat(), west: sw.lng(), north: ne.lat(), east: ne.lng() };
177
+ }
178
+ const size = this.getSize();
179
+ const nw = this.unproject({ x: 0, y: 0 });
180
+ const se = this.unproject({ x: size.width, y: size.height });
181
+ return { south: se.lat, west: nw.lng, north: nw.lat, east: se.lng };
182
+ }
183
+ getSize() {
184
+ const rect = this.host?.getBoundingClientRect();
185
+ return { width: rect?.width ?? 0, height: rect?.height ?? 0 };
186
+ }
187
+ /**
188
+ * Współrzędne → piksele kontenera.
189
+ *
190
+ * Świadomie **nie** przez `map.getProjection()`: ten zwraca `null` do pierwszego
191
+ * `projection_changed` i tak czy owak wymaga ręcznego przeliczenia na piksele kontenera.
192
+ * Rdzeń (`core/geo.ts`) ma dokładnie tę matematykę — tę samą, na której stoi grupowanie
193
+ * i adapter Leafleta — więc popup ląduje piksel w piksel tak samo w obu silnikach.
194
+ * Działa też dla zoomu ułamkowego, którego Google używa przy gestach.
195
+ */
196
+ project(at) {
197
+ const zoom = this.getZoom();
198
+ const size = this.getSize();
199
+ const origin = project(this.getCenter(), zoom);
200
+ const point = project(at, zoom);
201
+ return {
202
+ x: point.x - origin.x + size.width / 2,
203
+ y: point.y - origin.y + size.height / 2,
204
+ };
205
+ }
206
+ unproject(point) {
207
+ const zoom = this.getZoom();
208
+ const size = this.getSize();
209
+ const origin = project(this.getCenter(), zoom);
210
+ return unproject({ x: origin.x + point.x - size.width / 2, y: origin.y + point.y - size.height / 2 }, zoom);
211
+ }
212
+ /**
213
+ * Wymienia zestaw znaczników różnicowo — znaczniki o niezmienionym `id` zostają na miejscu,
214
+ * inaczej każdy przelicz gasiłby animacje i migotał.
215
+ */
216
+ setMarkers(next) {
217
+ if (!this.map || !this.markerLib)
218
+ return;
219
+ const { AdvancedMarkerElement } = this.markerLib;
220
+ const seen = new Set();
221
+ for (const m of next) {
222
+ seen.add(m.id);
223
+ const existing = this.markers.get(m.id);
224
+ if (existing) {
225
+ existing.marker.position = { lat: m.at.lat, lng: m.at.lng };
226
+ if (m.zIndex !== undefined)
227
+ existing.marker.zIndex = m.zIndex;
228
+ this.fillMarker(existing.inner, m);
229
+ continue;
230
+ }
231
+ const { anchor, inner } = this.buildMarkerContent(m);
232
+ const marker = new AdvancedMarkerElement({
233
+ map: this.map,
234
+ position: { lat: m.at.lat, lng: m.at.lng },
235
+ content: anchor,
236
+ zIndex: m.zIndex ?? 0,
237
+ });
238
+ /*
239
+ * Kliknięcie łapiemy listenerem DOM na własnej treści, a nie zdarzeniem `gmp-click`
240
+ * z `gmpClickable: true`. Powód jest ten sam, dla którego adapter Leafleta woła
241
+ * `L.DomEvent.stopPropagation`: bez zatrzymania propagacji to samo kliknięcie doszłoby
242
+ * do mapy i `onMapClick()` zamknąłby popup w tej samej klatce, w której go otwieramy.
243
+ * Przy okazji odpada zależność od tego, jak `gmp-click` zachowuje się w danej wersji API.
244
+ * Dostępność bez zmian — etykietę niesie `aria-label` w HTML-u ikony.
245
+ */
246
+ const onClick = (event) => {
247
+ event.stopPropagation();
248
+ this.emit('markerclick', m.id, this.fromDomEvent(event, m.at));
249
+ };
250
+ inner.addEventListener('click', onClick);
251
+ this.markers.set(m.id, {
252
+ marker,
253
+ inner,
254
+ dispose: () => inner.removeEventListener('click', onClick),
255
+ });
256
+ }
257
+ for (const [id, entry] of this.markers) {
258
+ if (seen.has(id))
259
+ continue;
260
+ entry.dispose();
261
+ entry.marker.map = null;
262
+ this.markers.delete(id);
263
+ }
264
+ }
265
+ setUserPosition(position, html) {
266
+ if (!this.map || !this.markerLib)
267
+ return;
268
+ const { AdvancedMarkerElement } = this.markerLib;
269
+ if (!position) {
270
+ if (this.userMarker)
271
+ this.userMarker.map = null;
272
+ this.userMarker = null;
273
+ this.accuracyEl = null;
274
+ this.accuracyAt = null;
275
+ return;
276
+ }
277
+ const at = { lat: position.at.lat, lng: position.at.lng };
278
+ this.accuracyMeters = position.accuracy;
279
+ this.accuracyAt = position.at;
280
+ if (this.userMarker) {
281
+ this.userMarker.position = at;
282
+ this.userMarker.content = this.buildUserContent(html);
283
+ }
284
+ else {
285
+ this.userMarker = new AdvancedMarkerElement({
286
+ map: this.map,
287
+ position: at,
288
+ content: this.buildUserContent(html),
289
+ // Nad znacznikami lokalizacji — własne położenie ma być zawsze widoczne.
290
+ zIndex: 1000,
291
+ });
292
+ }
293
+ this.resizeAccuracy();
294
+ }
295
+ /** Google dostarcza własne kafle i własną atrybucję — nie ma czego podmieniać. */
296
+ setTileSource() {
297
+ // celowo puste
298
+ }
299
+ setMaxBounds(bounds) {
300
+ this.map?.setOptions({
301
+ restriction: bounds ? { latLngBounds: this.toLatLngBounds(bounds), strictBounds: true } : null,
302
+ });
303
+ }
304
+ /** Google sam reaguje na zmianę rozmiaru kontenera — nie ma odpowiednika `invalidateSize`. */
305
+ invalidateSize() {
306
+ // celowo puste
307
+ }
308
+ on(event, handler) {
309
+ (this.handlers[event] ??= []).push(handler);
310
+ }
311
+ // ── prywatne ────────────────────────────────────────────────────────────────
312
+ emit(event, ...args) {
313
+ for (const handler of this.handlers[event] ?? []) {
314
+ handler(...args);
315
+ }
316
+ }
317
+ /** Rejestruje listener Google i zapamiętuje go do sprzątania w `destroy()`. */
318
+ on_(target, event, handler) {
319
+ this.listeners.push(target.addListener(event, handler));
320
+ }
321
+ /**
322
+ * Opakowanie znacznika. `AdvancedMarkerElement` kotwiczy treść **środkiem dolnej krawędzi**,
323
+ * a kontrakt daje kotwicę w pikselach od lewego górnego rogu ikony. Element zewnętrzny ma
324
+ * więc zerowe wymiary — jego „środek dolnej krawędzi" wypada dokładnie we współrzędnych —
325
+ * a wewnętrzny przesuwa ikonę o żądaną kotwicę.
326
+ */
327
+ buildMarkerContent(m) {
328
+ const anchor = document.createElement('div');
329
+ anchor.style.cssText = 'position:relative;width:0;height:0';
330
+ const inner = document.createElement('div');
331
+ inner.className = 'kpt-map__icon';
332
+ inner.style.position = 'absolute';
333
+ anchor.append(inner);
334
+ this.fillMarker(inner, m);
335
+ return { anchor, inner };
336
+ }
337
+ fillMarker(inner, m) {
338
+ inner.style.left = `${-m.anchor.x}px`;
339
+ inner.style.top = `${-m.anchor.y}px`;
340
+ inner.style.width = `${m.size.width}px`;
341
+ inner.style.height = `${m.size.height}px`;
342
+ // HTML pochodzi z `marker-html.ts` — z rdzenia biblioteki, nie od użytkownika.
343
+ inner.innerHTML = m.html;
344
+ }
345
+ /**
346
+ * Znacznik użytkownika i krąg dokładności w jednym elemencie.
347
+ *
348
+ * Krąg jest `<div>`-em z klasą `kpt-map__accuracy`, a nie `google.maps.Circle`: Circle
349
+ * przyjmuje kolory w opcjach, a kontrakt repo wymaga stylowania tokenami `var(--kpt-*)`.
350
+ * Średnicę liczy `metersPerPixel()` z rdzenia, więc jest wspólna dla obu silników.
351
+ */
352
+ buildUserContent(html) {
353
+ const anchor = document.createElement('div');
354
+ anchor.style.cssText = 'position:relative;width:0;height:0';
355
+ const circle = document.createElement('div');
356
+ circle.className = 'kpt-map__accuracy';
357
+ circle.style.cssText = 'position:absolute;border-radius:50%;pointer-events:none';
358
+ anchor.append(circle);
359
+ this.accuracyEl = circle;
360
+ const inner = document.createElement('div');
361
+ inner.className = 'kpt-map__icon';
362
+ inner.style.cssText = 'position:absolute;left:-12px;top:-12px;width:24px;height:24px';
363
+ inner.innerHTML = html;
364
+ anchor.append(inner);
365
+ this.resizeAccuracy();
366
+ return anchor;
367
+ }
368
+ resizeAccuracy() {
369
+ const el = this.accuracyEl;
370
+ const at = this.accuracyAt;
371
+ if (!el || !at || !this.map)
372
+ return;
373
+ const diameter = (2 * this.accuracyMeters) / metersPerPixel(at.lat, this.getZoom());
374
+ el.style.width = `${diameter}px`;
375
+ el.style.height = `${diameter}px`;
376
+ el.style.left = `${-diameter / 2}px`;
377
+ el.style.top = `${-diameter / 2}px`;
378
+ }
379
+ toPointerEvent(e) {
380
+ const at = { lat: e.latLng.lat(), lng: e.latLng.lng() };
381
+ return { at, point: this.toContainerPoint(e.domEvent), originalEvent: e.domEvent };
382
+ }
383
+ fromDomEvent(event, at) {
384
+ return { at, point: this.toContainerPoint(event), originalEvent: event };
385
+ }
386
+ /** Google podaje zdarzenie DOM bez pozycji w kontenerze — liczymy ją z prostokąta hosta. */
387
+ toContainerPoint(event) {
388
+ const rect = this.host?.getBoundingClientRect();
389
+ if (!event || !rect)
390
+ return { x: 0, y: 0 };
391
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top };
392
+ }
393
+ toLatLngBounds(b) {
394
+ return new this.maps.LatLngBounds({ lat: b.south, lng: b.west }, { lat: b.north, lng: b.east });
395
+ }
396
+ }
@@ -0,0 +1,13 @@
1
+ import type { KptMapEngine } from '../engine.ts';
2
+ export interface KptLeafletOptions {
3
+ /** Dolny limit zoomu. Domyślnie 2 — niżej świat powtarza się w poziomie. */
4
+ minZoom?: number;
5
+ /** Górny limit zoomu. Domyślnie 19 (tyle daje OSM). */
6
+ maxZoom?: number;
7
+ /** Bezwładność po przeciągnięciu. Domyślnie włączona. */
8
+ inertia?: boolean;
9
+ /** Zoom kółkiem myszy. Domyślnie włączony. */
10
+ scrollWheelZoom?: boolean;
11
+ }
12
+ /** Fabryka silnika — każda mapa dostaje własną instancję. */
13
+ export declare function createLeafletEngine(options?: KptLeafletOptions): KptMapEngine;
@@ -0,0 +1,232 @@
1
+ const MODULE_LOAD_ERROR = 'kpt-map: nie udało się załadować biblioteki `leaflet`. ' +
2
+ 'To zależność opcjonalna — zainstaluj ją w aplikacji: `pnpm add leaflet` ' +
3
+ '(oraz dołącz `leaflet/dist/leaflet.css`).';
4
+ let modulePromise = null;
5
+ /**
6
+ * Ładuje Leaflet raz na aplikację. Leaflet 1.9 jest paczką UMD/CJS bez pola `exports`,
7
+ * więc pod bundlerem trafia raz jako `default`, a raz jako sam namespace — stąd interop.
8
+ */
9
+ async function loadLeaflet() {
10
+ modulePromise ??= import('leaflet')
11
+ .then((mod) => mod?.default ?? mod)
12
+ .catch((cause) => {
13
+ modulePromise = null; // pozwól spróbować ponownie po doinstalowaniu paczki
14
+ throw new Error(MODULE_LOAD_ERROR, { cause });
15
+ });
16
+ return modulePromise;
17
+ }
18
+ /** Fabryka silnika — każda mapa dostaje własną instancję. */
19
+ export function createLeafletEngine(options = {}) {
20
+ return new LeafletEngine(options);
21
+ }
22
+ class LeafletEngine {
23
+ constructor(options) {
24
+ this.options = options;
25
+ this.kind = 'leaflet';
26
+ this.L = null;
27
+ this.map = null;
28
+ this.tiles = null;
29
+ this.userMarker = null;
30
+ this.userCircle = null;
31
+ /** Znaczniki po `id` — bez tego każdy przelicz przerysowywałby całą mapę. */
32
+ this.markers = new Map();
33
+ this.handlers = {};
34
+ }
35
+ async init(host, opts) {
36
+ const L = (this.L = await loadLeaflet());
37
+ this.map = new L.Map(host, {
38
+ center: [opts.center.lat, opts.center.lng],
39
+ zoom: opts.zoom,
40
+ minZoom: opts.minZoom ?? this.options.minZoom ?? 2,
41
+ maxZoom: opts.maxZoom ?? this.options.maxZoom ?? 19,
42
+ // Kontrolki rysuje warstwa Angulara — domyślnych nie da się otokenizować
43
+ // bez walki z niewarstwowanym `leaflet.css`.
44
+ zoomControl: false,
45
+ attributionControl: true,
46
+ inertia: this.options.inertia ?? true,
47
+ scrollWheelZoom: this.options.scrollWheelZoom ?? true,
48
+ maxBounds: opts.maxBounds ? this.toLatLngBounds(opts.maxBounds) : undefined,
49
+ // Bez tego mapa przy `maxBounds` daje się odciągnąć i sprężynuje.
50
+ maxBoundsViscosity: opts.maxBounds ? 1 : 0,
51
+ });
52
+ this.tiles = new L.TileLayer(opts.tileUrl, {
53
+ attribution: opts.attribution,
54
+ maxZoom: opts.maxZoom ?? this.options.maxZoom ?? 19,
55
+ }).addTo(this.map);
56
+ this.map.on('move', () => this.emit('move'));
57
+ this.map.on('moveend', () => this.emit('moveend'));
58
+ this.map.on('zoomend', () => this.emit('zoomend'));
59
+ this.map.on('click', (e) => this.emit('click', this.toPointerEvent(e)));
60
+ this.map.on('contextmenu', (e) => {
61
+ // Menu przeglądarki zasłoniłoby własne „Dodaj lokalizację".
62
+ L.DomEvent.preventDefault(e.originalEvent);
63
+ this.emit('contextmenu', this.toPointerEvent(e));
64
+ });
65
+ }
66
+ destroy() {
67
+ this.markers.clear();
68
+ this.userMarker = null;
69
+ this.userCircle = null;
70
+ this.tiles = null;
71
+ this.map?.remove();
72
+ this.map = null;
73
+ }
74
+ setView(center, zoom, animate = true) {
75
+ this.map?.setView([center.lat, center.lng], zoom, { animate });
76
+ }
77
+ fitBounds(bounds, paddingPx = 0) {
78
+ this.map?.fitBounds(this.toLatLngBounds(bounds), {
79
+ padding: [paddingPx, paddingPx],
80
+ maxZoom: this.options.maxZoom ?? 19,
81
+ });
82
+ }
83
+ panBy(offset) {
84
+ this.map?.panBy([offset.x, offset.y], { animate: true });
85
+ }
86
+ getCenter() {
87
+ const c = this.map.getCenter();
88
+ return { lat: c.lat, lng: c.lng };
89
+ }
90
+ getZoom() {
91
+ return this.map.getZoom();
92
+ }
93
+ getBounds() {
94
+ const b = this.map.getBounds();
95
+ return { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() };
96
+ }
97
+ getSize() {
98
+ const s = this.map.getSize();
99
+ return { width: s.x, height: s.y };
100
+ }
101
+ project(at) {
102
+ const p = this.map.latLngToContainerPoint([at.lat, at.lng]);
103
+ return { x: p.x, y: p.y };
104
+ }
105
+ unproject(point) {
106
+ const ll = this.map.containerPointToLatLng([point.x, point.y]);
107
+ return { lat: ll.lat, lng: ll.lng };
108
+ }
109
+ /**
110
+ * Wymienia zestaw znaczników różnicowo. Znaczniki, których `id` się nie zmieniło,
111
+ * zostają na miejscu — inaczej każdy przelicz gasiłby animacje i migotał.
112
+ */
113
+ setMarkers(next) {
114
+ if (!this.map || !this.L)
115
+ return;
116
+ const L = this.L;
117
+ const seen = new Set();
118
+ for (const m of next) {
119
+ seen.add(m.id);
120
+ const existing = this.markers.get(m.id);
121
+ const icon = L.divIcon({
122
+ html: m.html,
123
+ className: 'kpt-map__icon',
124
+ iconSize: [m.size.width, m.size.height],
125
+ iconAnchor: [m.anchor.x, m.anchor.y],
126
+ });
127
+ if (existing) {
128
+ existing.setLatLng([m.at.lat, m.at.lng]);
129
+ existing.setIcon(icon);
130
+ if (m.zIndex !== undefined)
131
+ existing.setZIndexOffset(m.zIndex);
132
+ continue;
133
+ }
134
+ const marker = new L.Marker([m.at.lat, m.at.lng], {
135
+ icon,
136
+ // Znacznik niesie własny `aria-label` w HTML-u ikony; pusty `alt`
137
+ // powstrzymuje Leaflet przed dorzuceniem drugiej etykiety.
138
+ alt: '',
139
+ keyboard: false,
140
+ zIndexOffset: m.zIndex ?? 0,
141
+ });
142
+ marker.on('click', (e) => {
143
+ L.DomEvent.stopPropagation(e);
144
+ this.emit('markerclick', m.id, this.toPointerEvent(e));
145
+ });
146
+ marker.addTo(this.map);
147
+ this.markers.set(m.id, marker);
148
+ }
149
+ for (const [id, marker] of this.markers) {
150
+ if (seen.has(id))
151
+ continue;
152
+ marker.remove();
153
+ this.markers.delete(id);
154
+ }
155
+ }
156
+ setUserPosition(position, html) {
157
+ if (!this.map || !this.L)
158
+ return;
159
+ const L = this.L;
160
+ if (!position) {
161
+ this.userMarker?.remove();
162
+ this.userCircle?.remove();
163
+ this.userMarker = null;
164
+ this.userCircle = null;
165
+ return;
166
+ }
167
+ const at = [position.at.lat, position.at.lng];
168
+ const icon = L.divIcon({ html, className: 'kpt-map__icon', iconSize: [24, 24], iconAnchor: [12, 12] });
169
+ if (this.userMarker) {
170
+ this.userMarker.setLatLng(at);
171
+ this.userMarker.setIcon(icon);
172
+ }
173
+ else {
174
+ // Nad znacznikami lokalizacji — własne położenie ma być zawsze widoczne.
175
+ this.userMarker = new L.Marker(at, { icon, alt: '', keyboard: false, zIndexOffset: 1000 }).addTo(this.map);
176
+ }
177
+ // Promień w metrach; L.Circle sam przelicza go na piksele przy każdym zoomie.
178
+ if (this.userCircle) {
179
+ this.userCircle.setLatLng(at);
180
+ this.userCircle.setRadius(position.accuracy);
181
+ }
182
+ else {
183
+ this.userCircle = new L.Circle(at, {
184
+ radius: position.accuracy,
185
+ className: 'kpt-map__accuracy',
186
+ interactive: false,
187
+ // Kolory idą z klasy CSS (tokeny), nie z opcji Leafleta.
188
+ stroke: true,
189
+ weight: 1,
190
+ }).addTo(this.map);
191
+ }
192
+ }
193
+ setTileSource(tileUrl, attribution) {
194
+ if (!this.map || !this.L)
195
+ return;
196
+ this.tiles?.remove();
197
+ this.tiles = new this.L.TileLayer(tileUrl, {
198
+ attribution,
199
+ maxZoom: this.options.maxZoom ?? 19,
200
+ }).addTo(this.map);
201
+ // Kafle wchodzą pod znaczniki — bez tego nowa warstwa przykryłaby je.
202
+ this.tiles.bringToBack();
203
+ }
204
+ setMaxBounds(bounds) {
205
+ if (!this.map)
206
+ return;
207
+ this.map.setMaxBounds(bounds ? this.toLatLngBounds(bounds) : null);
208
+ this.map.options.maxBoundsViscosity = bounds ? 1 : 0;
209
+ }
210
+ invalidateSize() {
211
+ // `animate: false` — to reakcja na zmianę layoutu, nie gest użytkownika.
212
+ this.map?.invalidateSize({ animate: false });
213
+ }
214
+ on(event, handler) {
215
+ (this.handlers[event] ??= []).push(handler);
216
+ }
217
+ emit(event, ...args) {
218
+ for (const handler of this.handlers[event] ?? []) {
219
+ handler(...args);
220
+ }
221
+ }
222
+ toPointerEvent(e) {
223
+ return {
224
+ at: { lat: e.latlng.lat, lng: e.latlng.lng },
225
+ point: { x: e.containerPoint.x, y: e.containerPoint.y },
226
+ originalEvent: e.originalEvent,
227
+ };
228
+ }
229
+ toLatLngBounds(b) {
230
+ return new this.L.LatLngBounds([b.south, b.west], [b.north, b.east]);
231
+ }
232
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Dystans po ludzku: poniżej kilometra w metrach zaokrąglonych do dziesiątek,
3
+ * wyżej w kilometrach z jednym miejscem po przecinku (`6,2 km` w `pl-PL`).
4
+ *
5
+ * Wartość ujemna albo nieskończona daje `null` — wołający chowa wtedy wiersz,
6
+ * zamiast malować „NaN km".
7
+ */
8
+ export declare function formatDistance(metres: number | null | undefined, locale: string): string | null;
9
+ /** Współrzędne do wiersza karty — pięć miejsc po przecinku to ok. metr. */
10
+ export declare function formatLatLng(at: {
11
+ lat: number;
12
+ lng: number;
13
+ }, digits?: number): string;
package/dist/format.js ADDED
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Formatowanie liczb dla warstwy prezentacji mapy — pure TS.
3
+ * Wszystko przez `Intl`, więc separator dziesiętny idzie z locale,
4
+ * a nie z zaszytej na sztywno kropki.
5
+ */
6
+ /**
7
+ * Dystans po ludzku: poniżej kilometra w metrach zaokrąglonych do dziesiątek,
8
+ * wyżej w kilometrach z jednym miejscem po przecinku (`6,2 km` w `pl-PL`).
9
+ *
10
+ * Wartość ujemna albo nieskończona daje `null` — wołający chowa wtedy wiersz,
11
+ * zamiast malować „NaN km".
12
+ */
13
+ export function formatDistance(metres, locale) {
14
+ if (metres == null || !Number.isFinite(metres) || metres < 0)
15
+ return null;
16
+ if (metres < 1000) {
17
+ const rounded = metres < 100 ? Math.round(metres) : Math.round(metres / 10) * 10;
18
+ return `${new Intl.NumberFormat(locale).format(rounded)} m`;
19
+ }
20
+ const km = metres / 1000;
21
+ // Powyżej 100 km ułamek nie niesie już informacji.
22
+ const digits = km >= 100 ? 0 : 1;
23
+ const value = new Intl.NumberFormat(locale, {
24
+ minimumFractionDigits: digits,
25
+ maximumFractionDigits: digits,
26
+ }).format(km);
27
+ return `${value} km`;
28
+ }
29
+ /** Współrzędne do wiersza karty — pięć miejsc po przecinku to ok. metr. */
30
+ export function formatLatLng(at, digits = 5) {
31
+ return `${at.lat.toFixed(digits)}, ${at.lng.toFixed(digits)}`;
32
+ }