@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.
- package/CHANGELOG.md +317 -1
- package/fesm2022/konce-pt-angular-map.mjs +4568 -0
- package/fesm2022/konce-pt-angular-map.mjs.map +1 -0
- package/fesm2022/konce-pt-angular.mjs +515 -77
- package/fesm2022/konce-pt-angular.mjs.map +1 -1
- package/map/src/lib/llms.txt +741 -0
- package/package.json +17 -4
- package/src/lib/app-shell/llms.txt +70 -10
- package/src/lib/breakpoint/llms.txt +45 -0
- package/src/lib/datepicker/llms.txt +3 -0
- package/src/lib/dialog/llms.txt +62 -21
- package/src/lib/form-field/llms.txt +19 -1
- package/src/lib/icon/llms.txt +2 -1
- package/src/lib/input/llms.txt +1 -1
- package/src/lib/select/llms.txt +9 -0
- package/src/lib/sidenav/llms.txt +29 -20
- package/src/lib/toolbar/llms.txt +31 -23
- package/types/konce-pt-angular-map.d.ts +1585 -0
- package/types/konce-pt-angular-map.d.ts.map +1 -0
- package/types/konce-pt-angular.d.ts +304 -20
- package/types/konce-pt-angular.d.ts.map +1 -1
|
@@ -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>
|