@konce-pt/angular 0.7.0 → 0.7.5

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,741 @@
1
+ # KptMap (kpt-map)
2
+
3
+ Mapa z konfigurowalnymi znacznikami: kategorie z kolorem i ikoną, grupowanie z licznikiem,
4
+ popup szczegółów o szablonie zależnym od kategorii, gotowa karta miejsca, legenda, wyszukiwarka
5
+ adresów i dodawanie lokalizacji prawym przyciskiem myszy. Domyślny silnik to Leaflet + OpenStreetMap; API komponentu
6
+ nie zna silnika, więc drugi adapter (Google Maps) podmienia się samym providerem.
7
+
8
+ Import: `import { KptMap, KptMapPoiCard, KptMapSearch } from '@konce-pt/angular/map';`
9
+
10
+ ## Wymagania (aplikacja)
11
+
12
+ Poniżej domyślny silnik, Leaflet. Alternatywa — Google Maps — nie wymaga żadnej paczki
13
+ ani arkusza, za to wymaga klucza API i `mapId`; patrz sekcja „Silniki".
14
+
15
+ 1. Zależność **opcjonalna** `leaflet` — biblioteka jej nie instaluje:
16
+
17
+ pnpm add leaflet
18
+
19
+ 2. Styl Leafleta — jak `@angular/cdk/overlay-prebuilt.css`, przez `angular.json → styles[]`,
20
+ NIE jako `import` w `main.ts` (Angular CLI zrobiłby z tego osierocony chunk):
21
+
22
+ node_modules/leaflet/dist/leaflet.css
23
+
24
+ Vite/webpack: `import 'leaflet/dist/leaflet.css';`
25
+
26
+ 3. Provider silnika w bootstrapie:
27
+
28
+ bootstrapApplication(App, { providers: [provideKptLeafletMap()] });
29
+
30
+ Bez zainstalowanej paczki `leaflet` komponent kompiluje się i importuje bez błędu —
31
+ dopiero próba wyrenderowania mapy kończy się czytelnym komunikatem z instrukcją.
32
+ Leaflet ładujemy dynamicznym `import()`, więc nie obciąża aplikacji, które mapy nie używają.
33
+
34
+ ## Selektor
35
+
36
+ `kpt-map` — element blokowy. Wysokość ustawia `[height]` (domyślnie `480px`).
37
+
38
+ ## Wejścia
39
+
40
+ - `locations`: `readonly KptMapLocation<T>[]` — **wymagane**
41
+ - `categories`: `readonly KptMapCategory[]` — **wymagane**
42
+ - `country`: `string` = `'PL'` — preset widoku startowego; nieznany kod cofa się do Polski
43
+ - `view`: `{ center, zoom } | null` — jawny widok; nadpisuje `country`
44
+ - `bounds`: `KptMapBounds | null` — jawny zasięg; nadpisuje `country` i `view`
45
+ - `restrictToCountry`: `boolean` = `false` — blokuje przesuwanie poza zasięg kraju/`bounds`
46
+ - `cluster`: `boolean` = `true`
47
+ - `clusterOptions`: `{ gridSize?, maxZoom?, minPoints? }` — domyślnie `60 / 16 / 2`
48
+ - `clusterColorBy`: `'dominant' | 'none'` = `'none'` — czy grupa bierze kolor najliczniejszej kategorii
49
+ - `legend`: `boolean` = `true` — renderuje `kpt-map-legend` pod mapą
50
+ - `legendCounts`: `boolean` = `true` — liczniki przy pozycjach legendy
51
+ - `showUserLocation`: `boolean` = `false`
52
+ - `followUser`: `boolean` = `false` — centruje mapę przy każdej nowej pozycji
53
+ - `allowCreate`: `boolean` = `false` — menu „Dodaj lokalizację" pod prawym przyciskiem
54
+ - `search`: `boolean` = `false` — wyszukiwarka adresów w rogu mapy; wymaga portu
55
+ `KPT_MAP_GEOCODER` **z metodą `search()`**, bez niego pole się nie renderuje
56
+ - `searchPlaceholder`: `string` = `''` — pusty oddaje głos słownikowi
57
+ - `searchOptions`: `{ minLength?, debounce?, limit?, countryCodes? }` — domyślnie `3 / 300 / 5`
58
+ - `searchZoom`: `number` = `16` — zoom dla wyniku bez własnego zasięgu
59
+ - `searchMarker`: `boolean` = `true` — tymczasowa pinezka w znalezionym punkcie
60
+ - `geocoder`: `KptMapGeocoderPort | null` = `null` — geokoder **tej** mapy; wygrywa nad
61
+ `KPT_MAP_GEOCODER` z DI i obsługuje oba kierunki: wyszukiwarkę i podpowiadanie adresu
62
+ w formularzu dodawania
63
+ - `popupLayout`: `'plain' | 'card'` = `'plain'` — `card` oddaje popup w całości treści:
64
+ bez paddingu, szerszy (`--kpt-map-popup-width-card`), z przewijaniem po przekroczeniu
65
+ `--kpt-map-popup-max-height` i bez wbudowanego krzyżyka (daje go karta)
66
+ - `distanceFrom`: `KptLatLng | null` = `null` — punkt odniesienia dystansu podawanego
67
+ szablonowi popupu; `null` znaczy „pozycja użytkownika z `KPT_MAP_GEOLOCATION`"
68
+ - `height`: `string` = `'480px'`
69
+ - `tileUrl`: `string | null` — domyślnie kafle OSM (patrz „Kafle" niżej); **bez znaczenia dla silnika Google**
70
+ - `attribution`: `string | null` — domyślnie `© OpenStreetMap contributors`; jw.
71
+ - `engine`: `KptMapEngineFactory | null` — silnik tej mapy; wygrywa nad `KPT_MAP_ENGINE` z DI
72
+ (patrz „Silniki")
73
+
74
+ ## Model / zdarzenia
75
+
76
+ - `hiddenCategories`: `model<string[]>` — identyfikatory kategorii ukrytych w legendzie
77
+ - `locationClick`: `KptMapLocation<T>`
78
+ - `clusterClick`: `KptMapCluster<T>`
79
+ - `locationCreate`: `{ at, categoryId, value }` — komponent **nie** dopisuje nic do `locations`;
80
+ źródłem prawdy jest aplikacja
81
+ - `searchSelect`: `KptMapSearchResult` — wybrano podpowiedź; mapa **sama** przeniosła już widok
82
+ - `viewChange`: `{ center, zoom, bounds }` — po zakończeniu ruchu mapy
83
+ - `userPositionChange`: `KptMapUserPosition | null`
84
+ - `userLocationError`: `KptMapGeolocationError`
85
+
86
+ ## KptMapCategory
87
+
88
+ { id: 'closure', label: 'Zamknięcie', color: 'danger', icon: 'lock',
89
+ shape: 'pin', legend: true, fields: [ … ] }
90
+
91
+ `color` to rola z warstwy semantycznej (`primary | success | warning | danger | info | muted`)
92
+ albo gotowe `var(--kpt-*)`. Literały (`#c00`, `rgb(...)`) są zablokowane na poziomie typu —
93
+ kontrakt repo wymaga tokenów.
94
+
95
+ Rola daje **trzy** zmienne, zależnie od tego, czym element jest:
96
+
97
+ | funkcja | do czego | `muted` daje |
98
+ |---|---|---|
99
+ | `colorVarOf()` | tło (pinezka, krążek legendy, grupa) | `--kpt-color-muted` (neutral-100) |
100
+ | `contrastVarOf()` | glif **na** tym tle | `--kpt-color-on-muted` (neutral-800) |
101
+ | `foregroundVarOf()` | glif stojący wprost na powierzchni, bez tła | `--kpt-color-on-surface-muted` |
102
+
103
+ Pomyłka boli tylko przy `muted`, bo to jedyna rola, której `--kpt-color-*` jest kolorem tła,
104
+ a nie treści — pozostałe są nasycone i czytają się w obu funkcjach. Stąd trzecia funkcja:
105
+ karta miejsca maluje ikonę kategorii wprost na tle karty i `colorVarOf()` dałby jej
106
+ neutral-100 na bieli. `icon` to nazwa z `KptIconRegistry` (pełny Tabler dodaje
107
+ `provideKptTablerIcons()`). `shape`: `pin` (domyślnie, kotwiczy czubkiem), `dot`, `square`.
108
+
109
+ ## KptMapLocation
110
+
111
+ { id: 'w1', at: { lat: 51.11, lng: 17.04 }, categoryId: 'opening',
112
+ title: '2210 · Wrocław', data: { … } }
113
+
114
+ `title` jest wymagany — trafia do `aria-label` znacznika. `data` jest dowolne i trafia
115
+ do szablonu popupu.
116
+
117
+ ## Szablony
118
+
119
+ `kptMapPopup` — popup szczegółów, wybierany po kategorii. Szablon bez atrybutu `category`
120
+ jest wariantem zapasowym; bez żadnego szablonu komponent pokazuje tytuł i nazwę kategorii.
121
+
122
+ <kpt-map [locations]="locations()" [categories]="categories">
123
+ <ng-template kptMapPopup category="closure" let-loc let-d="data">
124
+ <strong>{{ loc.title }}</strong>
125
+ <p>Zamknięcie · {{ d.status }}</p>
126
+ <kpt-button size="sm" (click)="openCard(loc)">Otwórz kartę projektu</kpt-button>
127
+ </ng-template>
128
+ <ng-template kptMapPopup let-loc><strong>{{ loc.title }}</strong></ng-template>
129
+ </kpt-map>
130
+
131
+ Kontekst: `$implicit` = lokalizacja, `data` = `location.data` (typ `T | undefined`, bo `data`
132
+ jest opcjonalne), `category`, `distance`, `close()`.
133
+
134
+ `distance` to odległość punktu w **metrach** od `[distanceFrom]`, a bez niego od pozycji
135
+ użytkownika. `null`, gdy nie ma punktu odniesienia — geolokalizacja wyłączona albo odmówiona.
136
+ Formatuje się ją `formatDistance(metry, locale)` z rdzenia; `kpt-map-poi-card` robi to sam.
137
+
138
+ Typ `data` domyślnie jest luźny — Angular nie przenosi generyka z `kpt-map` na dyrektywę osadzoną
139
+ w jego treści. Kto chce wnioskowania, wiąże `[kptMapPopupTypeFor]` z tą samą tablicą, którą karmi
140
+ mapę (input istnieje wyłącznie po to, w runtime nieużywany):
141
+
142
+ <ng-template kptMapPopup [kptMapPopupTypeFor]="locations()" category="opening" let-d="data">
143
+ <p>{{ d?.address }}</p>
144
+ </ng-template>
145
+
146
+ Popup otwarty przy krawędzi kontenera przesuwa mapę tak, żeby się zmieścił (auto-pan mierzy go
147
+ po pierwszym renderze — przed nim nie znamy jego wymiarów).
148
+
149
+ `kptMapCreateForm` — własny formularz dodawania. Jego obecność wyłącza formularz
150
+ budowany ze schemy kategorii.
151
+
152
+ <ng-template kptMapCreateForm let-api>
153
+ <!-- api: { at, category, close(), submit(value) } -->
154
+ </ng-template>
155
+
156
+ ## KptMapPoiCard (kpt-map-poi-card)
157
+
158
+ Gotowa karta miejsca do popupu: dystans, zdjęcie 16:9, przycisk główny + udostępnij + ulubione,
159
+ wiersz kategorii, rozwijane godziny otwarcia ze statusem otwarte/zamknięte oraz wiersze
160
+ kontaktowe (telefon, www, e-mail, współrzędne) z akcjami i kopiowaniem do schowka.
161
+
162
+ <kpt-map [locations]="places()" [categories]="categories" popupLayout="card">
163
+ <ng-template kptMapPopup [kptMapPopupTypeFor]="places()"
164
+ let-loc let-d="data" let-category="category"
165
+ let-distance="distance" let-close="close">
166
+ <kpt-map-poi-card
167
+ [poi]="poiOf(loc, d)"
168
+ [distance]="distance"
169
+ [category]="category ?? null"
170
+ primaryLabel="Wyznacz trasę"
171
+ (primaryAction)="planRoute($event)"
172
+ (closed)="close()"
173
+ />
174
+ </ng-template>
175
+ </kpt-map>
176
+
177
+ Wejścia:
178
+
179
+ - `poi`: `KptMapPoi` — **wymagane**
180
+ - `distance`: `number | null` = `null` — metry; `null` chowa wiersz dystansu
181
+ - `category`: `KptMapCategory | null` = `null` — ikona, kolor i etykieta wiersza kategorii
182
+ - `primaryLabel`: `string` = `''` — pusta chowa przycisk główny
183
+ - `showShare`, `showFavourite`, `closable`: `boolean` = `true`
184
+ - `favourite`: `model<boolean>` = `false`
185
+ - `hoursExpanded`: `model<boolean>` = `false`
186
+
187
+ Wyjścia: `primaryAction: KptMapPoi`, `closed: void`, `shared: KptMapPoi`.
188
+
189
+ `KptMapPoi` jest **osobne od modelu domenowego** — mapowanie `location.data → KptMapPoi` robi
190
+ aplikacja. Biblioteka nie zgaduje, które pole jest telefonem:
191
+
192
+ { title, address?, photo?, photoAlt?, hours?, phone?, email?, website?, at?,
193
+ facts?: [{ icon?, label, value }] }
194
+
195
+ `photo` przyjmuje adres albo `data:` — karta nie rozróżnia. Bez niego rysuje zastępnik z ikoną,
196
+ zamiast zwijać kadr do zera. `facts` to dowolne wiersze „ikona · etykieta · wartość" pod kontaktami.
197
+
198
+ Zachowania warte zapamiętania:
199
+
200
+ - **Zdjęcie** jest w kadrze 16:9 z `object-fit: cover` na zwykłym `<img>`, nie na `kpt-image` —
201
+ tamten jest `inline-block` z `height: auto` i kadru nie utrzyma.
202
+ - **Godziny** to natywny disclosure (`aria-expanded` + `aria-controls`), nie `kpt-accordion`:
203
+ `KptAccordionPanel.title` przyjmuje sam string, a nagłówek musi zmieścić ikonę, status
204
+ w kolorze i chevron. Status liczy `isOpenAt()`; dni sklejają się przez `groupWeek()`.
205
+ - **Akcje wierszy** są zawsze w DOM. Ukrywa je wyłącznie `@media (hover: hover) and
206
+ (pointer: fine)`, a `:hover`/`:focus-within` je odsłania — na dotyku i z klawiatury
207
+ są dostępne bez sztuczek.
208
+ - **Kopiowanie** idzie przez `navigator.clipboard`; bez tego API przycisk się nie renderuje.
209
+ Po sukcesie ikona zmienia się na `check`, a komunikat leci do `aria-live`.
210
+ - **Udostępnianie** woła `navigator.share()`, a bez niego kopiuje adres. Anulowanie arkusza
211
+ przez użytkownika nie jest błędem i nie emituje `shared`.
212
+ - **Tytuł to `<p role="heading" aria-level="3">`, nie `<h3>`.** Style komponentów siedzą
213
+ w `@layer kpt.components`, a CSS aplikacji spoza warstw wygrywa z nimi zawsze — zwykły
214
+ `<h3>` łapałby każdą regułę `h3 { … }` z układu strony (w playgroundzie robiła z tytułu
215
+ miejsca 13-pikselowy uppercase). Dla czytnika ekranu to dalej nagłówek poziomu 3.
216
+ - Karta nie ustawia własnego `role` na hoście — popup `kpt-map` jest już `role="dialog"`.
217
+
218
+ Anatomia i przewijanie:
219
+
220
+ kpt-map-poi-card
221
+ .kpt-map-poi__head tytuł + krzyżyk — nieruchome
222
+ .kpt-map-poi__meta dystans + adres — nieruchome
223
+ .kpt-map-poi__scroll zdjęcie, akcje, kategoria, godziny, kontakty, aria-live
224
+
225
+ Nagłówek stoi, przewija się wyłącznie `__scroll`. Dystans i adres należą do bloku
226
+ nagłówkowego, bo są częścią tożsamości miejsca — żeby przewijały się razem z treścią,
227
+ wystarczy przenieść `__meta` do `__scroll`.
228
+
229
+ Warunkiem jest to, że `popupLayout="card"` **oddaje przewijanie karcie**: `.kpt-map__popup-body`
230
+ dostaje wtedy `display: flex` i `overflow: hidden`, a karta pełną wysokość popupu. Dopóki
231
+ przewija kontener nadrzędny, nagłówka nie da się przypiąć bez `sticky` i ujemnych marginesów.
232
+ Sam popup nie dostaje `overflow: hidden` — strzałka `::after` wisi na `top: 100%` i zostałaby
233
+ ucięta. Karta poza popupem (stories, dowolny wrapper bez limitu wysokości) nie przewija się
234
+ wcale: `__scroll` ma `flex: 0 1 auto` i bez sufitu wysokości po prostu rośnie.
235
+
236
+ ## Dodawanie lokalizacji
237
+
238
+ Prawy klik → menu z kategoriami → `kpt-dialog` z formularzem. Domyślnie formularz powstaje
239
+ z `category.fields` na Signal Forms. Typy pól i kontrolki:
240
+
241
+ | `type` | kontrolka | uwagi |
242
+ |---|---|---|
243
+ | `text` | `kpt-input` | |
244
+ | `textarea` | `kpt-textarea` | |
245
+ | `number` | `kpt-input-number` | `min`/`max` ze schemy idą przez walidatory, nie przez `[min]` |
246
+ | `date` | `kpt-datepicker` | `min`/`max` jako daty ISO |
247
+ | `select` / `multiselect` | `kpt-select` (`multiple`) | wymaga `options` |
248
+ | `checkbox` | `kpt-checkbox` | `placeholder` służy za etykietę przy polu |
249
+ | `tel` | `kpt-input type="tel"` | bez walidacji formatu — numery bywają dziwne |
250
+ | `email` | `kpt-input type="email"` | walidator `email()` |
251
+ | `url` | `kpt-input type="url"` | wzorzec `https?://…` |
252
+ | `image` | `kpt-map-image-field` | wybór pliku **albo** wklejony adres; wartość to `string` |
253
+ | `hours` | `kpt-map-hours-field` | wartość to `KptMapOpeningHours` |
254
+
255
+ Współrzędne z kliknięcia są pokazane, ale nieedytowalne.
256
+
257
+ `image` czyta plik `FileReader.readAsDataURL()` i **nigdzie go nie wysyła** — biblioteka nie ma
258
+ wiedzy o storage'u aplikacji. Limit 2 MB jest realny, nie ozdobny: data URL puchnie o ~⅓,
259
+ a cała wartość leci w `locationCreate`. Wpisanie adresu czyści wybrany plik i odwrotnie.
260
+
261
+ `hours` daje siedem wierszy od poniedziałku (nazwy dni z `Intl`, nie ze słownika), po dwa
262
+ przedziały na dobę, znacznik „zamknięte" i skrót „tak samo przez cały tydzień" kopiujący
263
+ poniedziałek na resztę. Dzień nieoznaczony jako zamknięty musi mieć obie godziny — inaczej
264
+ formularz jest `invalid`.
265
+
266
+ Godzinę wybiera się na `kpt-datepicker selectionMode="time"` (tarcza zegara, `minuteStep=5`),
267
+ nie na natywnym `<input type="time">` — ten renderuje własny, systemowy dropdown, którego
268
+ nie da się ostylować i który wygląda obco pośród kontrolek biblioteki. Wartość jest ta sama:
269
+ `HH:mm` albo pusty string.
270
+
271
+ **Ograniczenie:** przy dynamicznej schemie model to `Record<string, unknown>`, więc pola
272
+ nie są typowane statycznie. Kto potrzebuje typów — podaje własny `kptMapCreateForm`.
273
+
274
+ Bramka „nie strasz czerwienią świeżego formularza" siedzi w prezentacji (`touched() &&
275
+ invalid()`), a nie w walidatorze — zgodnie z kontraktem Signal Forms w tym repo.
276
+
277
+ ## Podpowiadanie adresu (geokodowanie wsteczne)
278
+
279
+ Pola formularza „dodaj lokalizację" mogą wypełnić się adresem klikniętego punktu. Dwa warunki:
280
+ pole ma `autofill` wskazujące część adresu, a mapa ma port — z DI (`KPT_MAP_GEOCODER`)
281
+ albo z wejścia `[geocoder]`, które wygrywa.
282
+
283
+ { key: 'city', type: 'text', label: 'Miasto', autofill: 'city' },
284
+ { key: 'address', type: 'text', label: 'Adres', autofill: 'address' },
285
+
286
+ Części adresu (`KptMapAddressPart`): `street`, `houseNumber`, `address` (ulica z numerem),
287
+ `city`, `postcode`, `state`, `county`, `country`, `countryCode`, `label` (pełny adres jednym
288
+ ciągiem). Działa tylko dla pól `text` i `textarea` — data ani liczba nie przyjmą fragmentu adresu.
289
+
290
+ **Podpowiedź nigdy nie nadpisuje tego, co użytkownik wpisał** — wypełniane są wyłącznie pola
291
+ puste w chwili odpowiedzi. Brak portu albo nieudane zapytanie nie blokuje formularza: pola
292
+ zostają puste, a pod współrzędnymi pojawia się notka.
293
+
294
+ Gotowy adapter (opt-in, NIE jest rejestrowany domyślnie):
295
+
296
+ providers: [provideKptLeafletMap(), provideKptNominatimGeocoder({ language: 'pl' })]
297
+
298
+ **Polityka Nominatim.** Publiczna instancja `nominatim.openstreetmap.org` dopuszcza maksymalnie
299
+ jedno zapytanie na sekundę, wymaga rozpoznawalnego `Referer`/`User-Agent` i zabrania masowego
300
+ odpytywania; ruch może zostać zablokowany bez uprzedzenia, w szczególności komercyjny. Adapter
301
+ sam kolejkuje zapytania z odstępem sekundy i przerywa je przy zamknięciu okna, ale do produkcji
302
+ podstaw własną instancję (`baseUrl`) albo komercyjnego dostawcę przez własny `KPT_MAP_GEOCODER`:
303
+
304
+ export interface KptMapGeocoderPort {
305
+ reverse(at: KptLatLng, options?): Observable<KptMapAddress | null>;
306
+ search?(query: string, options?): Observable<readonly KptMapSuggestion[]>;
307
+ resolve?(suggestion: KptMapSuggestion, options?): Observable<KptMapSearchResult | null>;
308
+ }
309
+
310
+ Tylko `reverse()` jest obowiązkowe. Bez `search()` mapa nie pokazuje wyszukiwarki;
311
+ `resolve()` opisano niżej, przy podpowiedziach bez współrzędnych.
312
+
313
+ **Odstęp między zapytaniami (`minIntervalMs`).** Sekunda nie jest cechą Nominatima, tylko
314
+ wymogiem regulaminu **publicznej instancji OSM** — dlatego domyślna zależy od `baseUrl`:
315
+
316
+ provideKptNominatimGeocoder() // 1000 ms
317
+ provideKptNominatimGeocoder({ baseUrl: 'https://geo.local' }) // 0 ms
318
+ provideKptNominatimGeocoder({ baseUrl: '…', minIntervalMs: 200 })
319
+
320
+ Na własnym serwerze nie ma czego respektować, a przy podpowiadaniu w trakcie pisania sekundowa
321
+ zwłoka jest różnicą między „działa" a „zacina się". `0` zdejmuje też kolejkowanie — zapytania
322
+ lecą równolegle. Podstawiając **cudzą, współdzieloną** instancję ustaw wartość jawnie.
323
+
324
+ Testy i Storybook: `provideKptMapGeocoderStub({ city: 'Warszawa', address: 'Marszałkowska 100' })`.
325
+
326
+ ## Wyszukiwarka adresów (geokodowanie wprost)
327
+
328
+ `[search]` pokazuje w prawym górnym rogu mapy pole z podpowiedziami. Wpisanie frazy odpytuje
329
+ port geokodera — **ten sam**, co podpowiadanie adresu w formularzu dodawania, tylko
330
+ w drugą stronę; z DI (`KPT_MAP_GEOCODER`) albo z wejścia `[geocoder]`, które wygrywa.
331
+ Metoda `search()` jest w porcie **opcjonalna**: porty napisane przed jej powstaniem dalej
332
+ się kompilują, a mapa bez niej po prostu nie renderuje pola.
333
+
334
+ <kpt-map [locations]="places()" [categories]="categories" search
335
+ (searchSelect)="onFound($event)" />
336
+
337
+ // main.ts — bez tego pola nie będzie
338
+ providers: [provideKptLeafletMap(), provideKptNominatimGeocoder({ language: 'pl' })]
339
+
340
+ Dwa typy, celowo rozdzielone:
341
+
342
+ KptMapSuggestion = { id, label, at?, bounds?, address? } // pozycja na liście
343
+ KptMapSearchResult = KptMapSuggestion & { at: KptLatLng } // to, co wychodzi ze zdarzenia
344
+
345
+ `address` to ten sam `KptMapAddress`, co przy geokodowaniu wstecznym — nie ma drugiego modelu adresu.
346
+
347
+ ### Podpowiedzi bez współrzędnych (`resolve()`)
348
+
349
+ Większość geokoderów pytanych wprost (Nominatim, Photon, Google Geocoding API) podaje `lat`/`lng`
350
+ od razu na liście. **Google Places Autocomplete tego nie robi**: zwraca `placeId` i tekst,
351
+ a współrzędne dociąga się osobnym Place Details. To nie jest dziwactwo jednego dostawcy — na tym
352
+ stoi jego rozliczanie sesyjne, w którym cały ciąg podpowiedzi plus finalne Place Details liczy się
353
+ jako jedno zdarzenie.
354
+
355
+ Dlatego `search()` zwraca `KptMapSuggestion` (z `at` **opcjonalnym**), a port może dopisać:
356
+
357
+ resolve?(suggestion: KptMapSuggestion, options?: { language?, signal? })
358
+ : Observable<KptMapSearchResult | null>;
359
+
360
+ Reguła jest jednozdaniowa: **jeśli Twój `search()` nie zwraca `at`, musisz zaimplementować
361
+ `resolve()`.** Kto zwraca współrzędne od razu, tej metody nie potrzebuje — pole nawet jej nie zawoła.
362
+
363
+ Obsługa siedzi w `kpt-map-search`, nie w `kpt-map`, więc gwarancja obowiązuje tak samo przy polu
364
+ postawionym samodzielnie:
365
+
366
+ - podpowiedź z `at` → `selected` leci **natychmiast**, bez dodatkowego zapytania;
367
+ - podpowiedź bez `at` → `resolve()` raz, **po wybraniu pozycji**, nigdy w trakcie pisania;
368
+ panel zostaje otwarty z notą „Szukam…", a `aria-busy` na polu niesie to czytnikowi;
369
+ - `null` albo błąd → etykieta zostaje w polu, lista wraca (można wybrać inną pozycję),
370
+ a `aria-live` mówi „brak pasujących miejsc";
371
+ - kolejny wybór i wyczyszczenie pola przerywają zapytanie w locie.
372
+
373
+ **`selected` i `searchSelect` zawsze niosą współrzędne.** Aplikacja nigdy nie sprawdza, czy `at` jest.
374
+
375
+ Gdy port **nie ma** `resolve()`, podpowiedzi bez `at` w ogóle nie trafiają na listę: pozycja,
376
+ której kliknięcie nie miałoby dokąd polecieć, jest gorsza niż jej brak.
377
+
378
+ Atrapa umie udawać takiego dostawcę — bez tego ścieżki nie da się pokazać bez klucza API:
379
+
380
+ provideKptMapGeocoderStub(address, results, { deferCoordinates: true })
381
+
382
+ **Po wyborze podpowiedzi mapa przenosi widok sama.** Wynik z `bounds` (miasto, region,
383
+ województwo) dostaje `fitBounds`, a wynik bez nich — `setView` na `[searchZoom]`. Zdarzenie
384
+ `searchSelect` leci już po ruchu i służy aplikacji do jej własnych rzeczy (przestawienie
385
+ `[distanceFrom]`, zapytanie o punkty w okolicy), nie do nawigacji.
386
+
387
+ Adapter Nominatim **celowo odrzuca zbyt ciasny prostokąt** i zwraca wtedy sam punkt.
388
+ Pojedynczy adres ma tam bounding box rzędu 0,0001°, więc `fitBounds` skakałby na nim do
389
+ górnego limitu zoomu i każdy adres lądowałby w innym przybliżeniu, zależnym od tego, jak
390
+ dostawca obrysował budynek — z progiem adresy trafiają w powtarzalne `[searchZoom]`,
391
+ a miasta dalej wchodzą w kadr w całości.
392
+
393
+ Regułę trzyma `isFittableBounds(bounds, minSpan?)` z rdzenia (`KPT_MIN_FIT_SPAN` = 0,001°,
394
+ czyli ~100 m) — jest w publicznym API, ma testy i obowiązuje każdy adapter, nie tylko Nominatim.
395
+ Próg wystarczy przekroczyć w **jednej** osi: długa, wąska ulica ma jeden wymiar mikroskopijny
396
+ i dalej zasługuje na `fitBounds`.
397
+
398
+ **Zawężanie geograficzne.** Zapytanie dostaje `viewbox` z `[bounds]`, a bez nich z presetu
399
+ `[country]` — to sam bias, przestawia kolejność wyników i niczego nie odcina. Twarde
400
+ `countrycodes` dokłada się dopiero przy `restrictToCountry` (mapa i tak nie pozwoli
401
+ przesunąć widoku poza kraj, więc adres z zagranicy byłby nie do pokazania) albo jawnie
402
+ przez `searchOptions.countryCodes`.
403
+
404
+ **Tymczasowa pinezka** (`[searchMarker]`, domyślnie włączona) idzie tą samą drogą co reszta
405
+ znaczników — przez `engine.setMarkers()` — więc przesuwa się z mapą klatka po klatce.
406
+ Nie należy do `entries()`, więc kliknięcie w nią nic nie otwiera. Znika po wyczyszczeniu
407
+ pola krzyżykiem i po kliknięciu w mapę.
408
+
409
+ Polityka Nominatim jest tu **ostrzejsza niż przy `reverse()`**, bo podpowiadanie w trakcie
410
+ pisania z natury generuje serie zapytań. Adapter broni się dwoma mechanizmami: pole czeka
411
+ `debounce` (300 ms) od ostatniego znaku i nie pyta poniżej `minLength` (3 znaki), a sam
412
+ adapter trzyma **jedną wspólną kolejkę z `reverse()`** — jedno zapytanie na sekundę na całego
413
+ klienta, nie na metodę. Zapytanie porzucone (kolejny znak, zamknięcie pola) jest przerywane
414
+ `AbortController`-em. Do produkcji i tak podstaw własną instancję albo dostawcę komercyjnego.
415
+
416
+ Kolejka liczy odstęp **od zegara**, a nie stałym opóźnieniem przed każdym zapytaniem: pierwsze
417
+ po chwili bezczynności leci od razu, a kolejne czeka tylko tyle, ile brakuje do sekundy.
418
+ Górna granica jednego zapytania na sekundę zostaje nienaruszona, ale pierwsza podpowiedź
419
+ nie kosztuje już sekundy w plecy.
420
+
421
+ ### KptMapSearch (kpt-map-search)
422
+
423
+ Samo pole jest osobnym komponentem — jak legenda, żeby dało się je postawić poza mapą
424
+ (pasek narzędzi aplikacji, panel boczny):
425
+
426
+ <kpt-map-search [viewbox]="bounds" (selected)="goTo($event)" (cleared)="clearPin()" />
427
+
428
+ Wejścia: `placeholder`, `minLength` (3), `debounce` (300), `limit` (5), `countryCodes`,
429
+ `viewbox`, `disabled`, `geocoder` (port tego pola; wygrywa nad DI — taką drogą podaje go
430
+ `kpt-map`). Wyjścia: `selected: KptMapSearchResult`, `cleared: void`.
431
+ Bez portu z `search()` pole renderuje się **wyłączone**, zamiast udawać, że coś znajdzie.
432
+
433
+ **Dlaczego nie `kpt-autocomplete`** — pytanie wraca, więc na piśmie: tamten filtruje opcje
434
+ po stronie klienta (`includes(q)`), więc wynik geokodera tolerancyjny na literówki albo inaczej
435
+ brzmiący wypadałby z listy; jego wartością jest sam `string`, więc nie ma czym przewieźć
436
+ współrzędnych; i stoi na CDK Overlay, co dołożyłoby `@angular/cdk/overlay-prebuilt.css`
437
+ do wymagań mapy, której poza tym nie potrzebuje. Panel podpowiedzi jest więc rysowany wprost
438
+ w warstwie Angulara nad mapą, tak samo jak popup i menu prawego przycisku.
439
+
440
+ Debounce stoi na `setTimeout` w efekcie z `onCleanup`, a nie na operatorach rxjs: porty tego
441
+ pakietu używają wyłącznie `Observable`/`of`, a rxjs nie jest zadeklarowany jako
442
+ `peerDependency` biblioteki (wchodzi tranzytywnie przez `@angular/core`).
443
+
444
+ Wpisany tekst i fraza do odpytania to **dwa osobne sygnały**. Wybór podpowiedzi wpisuje jej
445
+ etykietę do pola, ale nie rusza frazy — inaczej wybór natychmiast odpalałby kolejne zapytanie
446
+ o coś, co właśnie znaleźliśmy.
447
+
448
+ ## Legenda
449
+
450
+ `kpt-map-legend` — osobny komponent, żeby dało się go umieścić poza mapą. Pozycje są
451
+ przyciskami z `aria-pressed`; kliknięcie chowa kategorię (znika też z grup, liczniki maleją).
452
+
453
+ <kpt-map [legend]="false" [(hiddenCategories)]="hidden" … />
454
+ <kpt-map-legend [categories]="categories" [(hidden)]="hidden" orientation="vertical" />
455
+
456
+ Wejścia: `categories` (wymagane), `hidden` (model), `counts`, `orientation`, `interactive`.
457
+
458
+ ## Geolokalizacja
459
+
460
+ `[showUserLocation]` włącza znacznik własnego położenia (ikona `user`) z kręgiem dokładności
461
+ i kontrolkę „wyśrodkuj na mnie". Pozycja idzie przez port DI:
462
+
463
+ KPT_MAP_GEOLOCATION → KptBrowserGeolocation (domyślnie)
464
+ provideKptMapGeolocationStub({ lat: 52.23, lng: 21.01 }) // Storybook, testy
465
+
466
+ Geolocation API działa **wyłącznie w bezpiecznym kontekście** (HTTPS albo `localhost`).
467
+ Na `http://` z adresem IP przeglądarka odmówi — to nie jest błąd komponentu. Odmowa zgody
468
+ wyłącza kontrolkę i emituje `userLocationError`; komponent nie pokazuje własnego alertu,
469
+ decyzję o komunikacie zostawia aplikacji. Na SSR port zwraca `null`.
470
+
471
+ ## Silniki
472
+
473
+ Silnik dostarcza fabryka pod tokenem `KPT_MAP_ENGINE`, a komponent rozmawia z nią przez
474
+ interfejs `KptMapEngine` (zero typów Leafleta w sygnaturach). Podział renderowania:
475
+
476
+ - kafle, panning, zoom, **znaczniki i grupy** → silnik (`L.divIcon` z HTML-em z rdzenia),
477
+ - **popup, kontrolki, menu, legenda, dialog** → warstwa Angulara nad kontenerem mapy.
478
+
479
+ Znaczniki idą przez silnik, bo muszą przesuwać się z mapą klatka po klatce (Leaflet robi to
480
+ jednym `transform` na panie). Popup jest jeden na raz, więc przeliczanie jego pozycji
481
+ z `engine.project()` przy każdym ruchu jest tanie — a daje pełnego Angulara w treści
482
+ i jedną implementację dla obu silników.
483
+
484
+ ### Dwa silniki
485
+
486
+ provideKptLeafletMap() // domyślny, bez klucza i konta
487
+ provideKptGoogleMap({ apiKey, mapId }) // Google Maps JavaScript API
488
+
489
+ Leaflet wymaga paczki `leaflet` (opcjonalnej) i `leaflet/dist/leaflet.css`. Google nie wymaga
490
+ **żadnej paczki npm** — API dogrywa się skryptem przy pierwszej mapie — ale wymaga klucza,
491
+ konta rozliczeniowego z kartą i `mapId` z konsoli (*Map management → Create Map ID*).
492
+ Bez `mapId` nie działa `AdvancedMarkerElement`, czyli nie będzie znaczników.
493
+
494
+ | | `leaflet` | `google` |
495
+ | ---------------------- | ----------------------------- | ----------------------------------------- |
496
+ | `[tileUrl]`/`[attribution]` | działa, wymiana w locie | **ignorowane** — kafle daje Google |
497
+ | atrybucja | kontrolka Leafleta (ODbL) | logo i linki ToS wbudowane, **nie zasłaniać** |
498
+ | klucz / konto | niepotrzebne | klucz API + billing z kartą |
499
+ | koszt | kafle OSM (bez SLA) | SKU Dynamic Maps: 10 tys. wczytań/mies. za darmo |
500
+ | geokoder Google | **niedozwolony** | dozwolony |
501
+ | zoom | całkowity, max 19 | ułamkowy przy gestach, ponad 21 |
502
+
503
+ **Ostatni wiersz jest powodem, dla którego ten silnik istnieje.** Google Maps Platform Service
504
+ Specific Terms §6.2 (Geocoding API) i §14.2 (Places API) zabraniają używać ich wyników
505
+ „in conjunction with a non-Google map". Adapter Google pod `KPT_MAP_GEOCODER` jest więc zgodny
506
+ z umową **wyłącznie** przy silniku `google` — z domyślnym Leafletem nie. Sama biblioteka
507
+ takiego adaptera nie dostarcza; to decyzja i kod aplikacji.
508
+
509
+ Dlatego geokoder jest per mapa, a nie per aplikacja: wejście `[geocoder]` (niżej) pozwala
510
+ postawić obok siebie mapę Leafleta z geokoderem OSM i mapę Google z geokoderem Google.
511
+ Globalny provider nie umiałby tego rozstrzygnąć — musiałby złamać regułę po jednej
512
+ ze stron.
513
+
514
+ ### `[engine]` — dwie mapy, dwa silniki, jedna strona
515
+
516
+ Wejście przyjmuje fabrykę i wygrywa nad `KPT_MAP_ENGINE` z DI. Bez niego nic się nie zmienia.
517
+
518
+ readonly google: KptMapEngineFactory = () => createGoogleEngine({ apiKey, mapId });
519
+
520
+ <kpt-map [engine]="google" [locations]="places()" [categories]="cats" />
521
+
522
+ Czytane raz, przy inicjalizacji — podmiana po starcie nie przeładowuje mapy. Brak silnika
523
+ w obu źródłach daje czytelny błąd z instrukcją, a nie `NullInjectorError`.
524
+
525
+ ### `[geocoder]` — port idzie za silnikiem
526
+
527
+ Symetryczne do `[engine]` i z tego samego powodu: geokoder Google wolno pokazać tylko
528
+ z mapą Google, a to jest własność **mapy**, nie aplikacji. Wejście wygrywa nad
529
+ `KPT_MAP_GEOCODER` z DI i trafia zarówno do wyszukiwarki, jak i do formularza dodawania.
530
+
531
+ <kpt-map [engine]="leaflet" search … /> <!-- port z DI (np. Nominatim) -->
532
+ <kpt-map [engine]="google" [geocoder]="googleGeocoder" search … />
533
+
534
+ W odróżnieniu od `[engine]` czytane jest **reaktywnie**: podmiana portu zeruje listę
535
+ podpowiedzi i od następnej frazy pyta nowego dostawcę. `null` w obu źródłach znaczy tyle,
536
+ co dziś brak portu: pole wyszukiwarki się nie renderuje, a formularz nie podpowiada adresu.
537
+
538
+ To samo wejście ma `kpt-map-search` i `kpt-map-create-dialog`, gdy stoją poza mapą.
539
+
540
+ **Pisząc adapter Google, idź przez SDK, nie przez REST.** Web service
541
+ `maps.googleapis.com/maps/api/geocode/json` odrzuca klucze ograniczone po domenie:
542
+
543
+ { "status": "REQUEST_DENIED",
544
+ "error_message": "API keys with referer restrictions cannot be used with this API." }
545
+
546
+ a to jedyna restrykcja, jaka ma sens dla klucza leżącego jawnie w bundlu przeglądarki. CORS na
547
+ tym endpoincie działa, więc nie ma nawet błędu sieci — przychodzi `200` z `REQUEST_DENIED`
548
+ w treści, łatwe do przeoczenia. `google.maps.Geocoder` z załadowanego SDK autoryzuje się tak samo jak sama
549
+ mapa, więc działa z tym samym kluczem — to ten sam SKU, tylko inna droga. Dostęp do API
550
+ daje wyeksportowany loader (ten sam, którego używa silnik):
551
+
552
+ const maps = await loadGoogleMapsApi({ apiKey, language: 'pl' });
553
+ const { Geocoder } = await maps.importLibrary('geocoding');
554
+ const { results } = await new Geocoder().geocode({ address: 'Marszałkowska 100, Warszawa' });
555
+
556
+ Uwaga na kształt odpowiedzi: SDK oddaje `location` i `viewport` jako obiekty z metodami
557
+ (`lat()`, `getNorthEast()`), a nie jako pola jak REST. Zapytania SDK **nie da się przerwać**,
558
+ więc `signal` z opcji portu zostaje bez pokrycia — odsubskrybowanie tylko przestaje słuchać.
559
+ Przykład adaptera od początku do końca: `apps/playground/src/pg-map.google-geocoder.ts`.
560
+
561
+ Silnik trafia na host jako `[data-engine]` (`leaflet` | `google`) — po tym atrybucie stylują
562
+ się nieliczne różnice, np. krąg dokładności (w Leaflecie kółko SVG, w Google element z tłem,
563
+ bo `google.maps.Circle` przyjmuje kolory w opcjach, a tu obowiązują tokeny).
564
+
565
+ Adapter Google liczy `project()`/`unproject()` **rdzeniem** (`core/geo.ts`), a nie
566
+ `map.getProjection()`: ten bywa `null` do pierwszego `idle` i tak czy owak wymaga przeliczenia
567
+ na piksele kontenera. Dzięki temu popup ląduje piksel w piksel tam samo co przy Leaflecie,
568
+ zgadza się z grupowaniem i radzi sobie z zoomem ułamkowym.
569
+
570
+ ## Rdzeń (pure TS)
571
+
572
+ `@konce-pt/angular/map` eksportuje rdzeń obliczeń — bez Angulara i bez silnika, przenośny
573
+ do portu React: `clusterLocations`, `dominantCategory`, `countByCategory`, `isSplittable`,
574
+ `project`/`unproject`/`boundsOf`/`haversine`/`metersPerPixel`/`zoomForBounds`/`isFittableBounds`,
575
+ `KPT_MAP_COUNTRIES`/`countryPreset`/`resolveCountryView`,
576
+ `renderMarkerHtml`/`renderClusterHtml`/`colorVarOf`/`contrastVarOf`/`foregroundVarOf`,
577
+ `formatDistance`/`formatLatLng` oraz godziny otwarcia: `parseTime`, `formatTime`, `isoWeekday`,
578
+ `isOpenAt`, `isAlwaysOpen`, `nextChange`, `weekdayName`, `groupWeek`, `emptyWeek`.
579
+
580
+ Model godzin: `KptMapOpeningHours` = tablica `{ day: 1–7 (ISO), closed?, ranges: [{ from, to }] }`
581
+ w `HH:MM` czasu lokalnego miejsca. `to` mniejsze albo równe `from` znaczy „przez północ";
582
+ `24:00` to prawidłowy koniec doby i przez północ **nie** przechodzi. Brakujący dzień = zamknięte.
583
+ `isOpenAt()` sprawdza dwa dni — bieżący i poprzedni — bo przedział `22:00–01:00` obejmuje
584
+ wczesne godziny dnia następnego. `groupWeek()` skleja sąsiadujące dni o identycznym rozkładzie
585
+ (`pon.–pt.`), ale **nie** zawija tygodnia przez granicę niedziela–poniedziałek: tydzień czyta się
586
+ liniowo i zawijanie byłoby mylące.
587
+
588
+ Testy: `packages/angular/map/src/core/*.test.ts` (`node --test`).
589
+
590
+ ## Grupowanie — jak działa i czego nie robi
591
+
592
+ Siatka w przestrzeni pikseli: punkty rzutowane na piksele świata dla aktualnego zoomu
593
+ i przypisane do komórek `gridSize` px. Algorytm jest O(n) i deterministyczny — ten sam
594
+ zestaw danych zawsze daje te same grupy o tych samych identyfikatorach.
595
+
596
+ Środek grupy uśredniamy **w pikselach, nie w stopniach**: Mercator rozciąga stopnie ku
597
+ biegunom, więc średnia w stopniach wypada obok wizualnego środka kropek na ekranie.
598
+
599
+ Ograniczenia (świadome):
600
+ - grupa może „przeskoczyć" przy przekroczeniu granicy komórki podczas zoomowania — to cena
601
+ za brak preprocessingu, jaki wymaga clustering hierarchiczny;
602
+ - **nie ma spiderfy.** Punkty o identycznej współrzędnej zostaną razem na każdym zoomie,
603
+ więc kliknięcie takiej grupy otwiera popup z listą pozycji zamiast zoomować w nieskończoność
604
+ (`isSplittable()` rozpoznaje ten przypadek);
605
+ - powyżej `clusterMaxZoom` (domyślnie 16) grupowanie jest wyłączone.
606
+
607
+ ## Kafle — polityka OSM
608
+
609
+ Domyślne `https://tile.openstreetmap.org/{z}/{x}/{y}.png` to usługa **best-effort bez SLA**.
610
+ Fundacja OSM wymaga widocznej atrybucji i sensownego `User-Agent`/`Referer`, zabrania
611
+ pre-fetchowania i zastrzega sobie blokowanie ruchu bez uprzedzenia — w szczególności
612
+ komercyjnego. Domyślne źródło jest po to, żeby demo działało od razu.
613
+
614
+ **Do produkcji podstaw własny lub komercyjny serwer kafli** przez `[tileUrl]` + `[attribution]`.
615
+ Atrybucji nie usuwaj — to wymóg licencyjny, nie ozdoba.
616
+
617
+ ## Tokeny
618
+
619
+ `--kpt-map-bg`, `--kpt-map-border`, `--kpt-map-radius`, `--kpt-map-popup-bg`,
620
+ `--kpt-map-popup-shadow`, `--kpt-map-popup-width`, `--kpt-map-popup-width-card`,
621
+ `--kpt-map-popup-max-height`, `--kpt-map-search-width`, `--kpt-map-search-panel-max-height`,
622
+ `--kpt-map-poi-media-radius`, `--kpt-map-poi-row-hover`,
623
+ `--kpt-map-cluster-bg`, `--kpt-map-cluster-fg`, `--kpt-map-marker-size`,
624
+ `--kpt-map-height` (z `[height]`).
625
+
626
+ Kolory znaczników i grup biorą się z ról kategorii: `--kpt-color-{success,warning,danger,
627
+ info,primary,muted}` i ich warianty `-contrast`.
628
+
629
+ ## Kontekst układania Leafleta — nie ruszać
630
+
631
+ `.kpt-map .leaflet-container` dostaje `z-index: 0`, żeby zamknąć skalę z-index Leafleta
632
+ (panes 200–700, kontrolki 800) we własnym kontekście układania. Bez tego panes konkurują
633
+ bezpośrednio z warstwą Angulara rozpiętą nad mapą — a popup (`z-index: 3`), kontrolki zoomu
634
+ (2) i menu prawego przycisku (4) przegrywają i są **malowane pod kaflami**.
635
+
636
+ Usterka jest podstępna, bo hit-test jej nie pokazuje: panes i kafle mają `pointer-events: none`,
637
+ więc `document.elementFromPoint` zwraca popup jako element na wierzchu, mimo że użytkownik go
638
+ nie widzi. Jedyny wiarygodny test to zrzut ekranu.
639
+
640
+ Skala warstwy Angulara nad mapą: kontrolki zoomu `2`, popup `3`, menu prawego przycisku `4`,
641
+ wyszukiwarka `5` (rozwinięty panel podpowiedzi ma zakrywać wszystko powyższe).
642
+
643
+ ## Mapa nie stylizuje hostów komponentów potomnych — nie „upraszczać"
644
+
645
+ Wyszukiwarkę pozycjonuje **opakowanie** `<div class="kpt-map__search">`, a nie klasa
646
+ postawiona wprost na `<kpt-map-search>`. Skrócenie tego do jednego elementu wygląda
647
+ niewinnie i **chowa pole całkowicie**.
648
+
649
+ Powód: host wyszukiwarki ma własną regułę `.kpt-map-search { position: relative }` (blok
650
+ zawierający dla panelu podpowiedzi, potrzebny też poza mapą). Obie reguły trafiają wtedy na ten
651
+ sam element z identyczną specyficznością (0,1,0) i w tej samej warstwie `kpt.components`, więc
652
+ rozstrzyga kolejność wstrzyknięcia — a Angular wstrzykuje style dziecka **po** stylach rodzica.
653
+ `position: relative` wygrywa z `absolute`, pole wypada do normalnego przepływu za
654
+ `.kpt-map__canvas` (`height: 100%`) i zostaje przycięte przez `overflow: hidden` widoku.
655
+
656
+ Usterka jest podstępna z tego samego powodu co ta z z-indexem: w DOM element jest, ma poprawne
657
+ atrybuty i przechodzi każdą kontrolę „czy się wyrenderował". Widać ją wyłącznie na zrzucie ekranu.
658
+
659
+ `class="kpt-map__legend"` na `<kpt-map-legend>` jest bezpieczne, bo `map.component.scss`
660
+ **nie ma dla tej klasy żadnej reguły** — to czysty hak do zaczepienia z zewnątrz. Reguła
661
+ dopisana tam kiedykolwiek w przyszłości wpadnie w tę samą pułapkę.
662
+
663
+ ## Odstępstwo od `@layer` — nie „naprawiać"
664
+
665
+ `map.component.scss` ma dwie części: style własne w `@layer kpt.components` oraz sekcję
666
+ nadpisań `.leaflet-*` **poza warstwą**. To jedyne takie miejsce w bibliotece i jest celowe:
667
+ `leaflet.css` dostarcza aplikacja i nie jest warstwowany, a CSS spoza warstw wygrywa
668
+ z każdą regułą w `@layer` **niezależnie od specyficzności**. Przeniesienie tych reguł
669
+ do warstwy „dla porządku" sprawi, że przestaną działać.
670
+
671
+ ## A11y
672
+
673
+ - znacznik: `role="img"` + `aria-label` = `title` lokalizacji,
674
+ - grupa: `aria-label` = `map.cluster` z liczbą,
675
+ - legenda: `role="group"`, pozycje jako `<button aria-pressed>`,
676
+ - popup: `role="dialog"` z `aria-label` = tytuł; zamyka `Esc`, kliknięcie w mapę i przycisk ×
677
+ (w `popupLayout="card"` krzyżyk daje karta, żeby nie było dwóch),
678
+ - karta miejsca: tytuł jako `role="heading" aria-level="3"`, kontakty jako `<ul>`/`<li>`,
679
+ godziny jako disclosure z `aria-expanded` i `aria-controls`, potwierdzenie kopiowania
680
+ przez `aria-live="polite"`,
681
+ - wyszukiwarka: `role="combobox"` + `aria-expanded`/`aria-controls`/`aria-autocomplete="list"`,
682
+ lista jako `<ul role="listbox">` / `<li role="option">`, aktywna pozycja przez
683
+ `aria-activedescendant` (panel stoi w DOM obok pola, więc fokus zostaje w `<input>`),
684
+ klawiatura ↑/↓/Enter, pierwszy Esc zamyka listę, drugi czyści pole; liczba wyników
685
+ i „brak wyników" idą w `aria-live="polite"`,
686
+ - kontrolki zoomu i „wyśrodkuj na mnie": `<button>` z `aria-label` ze słownika,
687
+ - menu prawego przycisku: `role="menu"` / `role="menuitem"`.
688
+
689
+ ## i18n
690
+
691
+ Klucze `map.*` w `KptMessages` (EN + PL): `addLocation`, `chooseCategory`, `myLocation`,
692
+ `locateMe`, `locationDenied`, `locationUnavailable`, `zoomIn`, `zoomOut`, `legend`,
693
+ `showCategory`, `hideCategory`, `cluster`, `clusterItems`, `fieldRequired`, `latitude`,
694
+ `longitude`, `empty`, `save`, `cancel`, `lookingUpAddress`, `addressNotFound`.
695
+
696
+ Wyszukiwarka: `map.search.*` — `placeholder`, `label`, `results`, `clear`, `noResults`,
697
+ `minLength` ({count}), `searching`, `found` ({count}).
698
+
699
+ Karta miejsca: `map.poi.*` — `distanceAway`, `openNow`, `closedNow`, `opensAt`, `closesAt`,
700
+ `alwaysOpen`, `openingHours`, `closedDay`, `call`, `sendEmail`, `openWebsite`, `coordinates`,
701
+ `copy`, `copied`, `share`, `addFavourite`, `removeFavourite`, `noPhoto`.
702
+
703
+ Nowe pola formularza: `map.field.*` — `invalidEmail`, `invalidUrl`, `imageTooLarge`, `pickImage`,
704
+ `orPasteUrl`, `removeImage`, `imagePreview`, `hoursClosed`, `hoursFrom`, `hoursTo`,
705
+ `hoursSameAllWeek`, `hoursAddRange`, `hoursRemoveRange`, `hoursIncomplete`.
706
+
707
+ Nazw dni tygodnia w słowniku **nie ma** — bierze je `Intl.DateTimeFormat` z locale, więc karta
708
+ i edytor godzin działają dla dowolnego języka bez dopisywania tłumaczeń.
709
+
710
+ ## Przykład
711
+
712
+ // main.ts
713
+ bootstrapApplication(App, {
714
+ providers: [provideKptTablerIcons(), provideKptLeafletMap()],
715
+ });
716
+
717
+ // komponent
718
+ readonly locations = signal<KptMapLocation<Site>[]>(SITES);
719
+ readonly categories: KptMapCategory[] = [
720
+ { id: 'opening', label: 'Otwarcie', color: 'success', icon: 'building-store', fields: [ … ] },
721
+ { id: 'closure', label: 'Zamknięcie', color: 'danger', icon: 'lock', fields: [ … ] },
722
+ ];
723
+
724
+ onCreate(event: KptMapCreateEvent): void {
725
+ this.locations.update((all) => [...all, toLocation(event)]);
726
+ }
727
+
728
+ // szablon
729
+ <kpt-map
730
+ [locations]="locations()"
731
+ [categories]="categories"
732
+ country="PL"
733
+ allowCreate
734
+ showUserLocation
735
+ (locationCreate)="onCreate($event)"
736
+ >
737
+ <ng-template kptMapPopup category="opening" let-loc let-d="data">
738
+ <strong>{{ loc.title }}</strong>
739
+ <p>Otwarcie: {{ d.openingDate }}</p>
740
+ </ng-template>
741
+ </kpt-map>