Mapy OpenStreetMap na stronie — czego nie widać w tutorialu
Tworzenie stron wwwJavascriptOptymalizacja strony www

Mapy OpenStreetMap na stronie — czego nie widać w tutorialu

Pierwsza wersja mapy w tym projekcie miała dwie linijki: <iframe> z openstreetmap.org/export/embed.html. Działała. Zniknęła po niecałej dobie, bo okazało się, że mapa ma pokazywać nie jeden punkt, a kilkadziesiąt, w dwóch motywach kolorystycznych, z panelem „ile mi zajmie dojazd". Dziesięć dni później mieliśmy własną warstwę map na Leaflecie, cztery usługi OpenStreetMap (trzy z nich schowane za naszym proxy) i model czasu dojazdu dopasowany do 460 prawdziwych tras. Ten wpis jest o tych dziesięciu dniach — bo pół godziny z tutoriala to naprawdę pół godziny, a resztę czasu zjadają rzeczy, o których nikt nie pisze.

Serwisu nie nazwiemy i szczegółów klienta nie podamy — z tego samego powodu, z którego nie publikujemy konfiguracji serwera. Skala, żeby było wiadomo, o czym mówimy: 61 punktów sprzedaży w 38 miejscowościach, mapa w czterech miejscach na stronie (osobna podstrona, zakładka w katalogu, profil pojedynczego punktu i filtr „najbliżej Ciebie"), wszystko na jednym wspólnym module.

Dlaczego OpenStreetMap, a nie Google Maps

Trzy powody, w tej kolejności.

Pierwszy: zero konta, zero klucza API, zero karty w formularzu. Leaflet 1.9.4 z npm i kafle z tile.openstreetmap.org to cały stos. Nie ma progu darmowych wyświetleń, po którym przychodzi faktura, i nie ma sytuacji, w której klientowi wyłącza się mapa, bo komuś wygasła karta.

Drugi: prywatność i baner zgody. Google Maps to skrypt trzeciej strony z własnymi cookies — czyli osobna zgoda w banerze i osobny akapit w polityce prywatności. Kafle OSM pobiera przeglądarka i tyle; nie zapisują niczego w przeglądarce czytelnika.

Trzeci, mniej oczywisty: licencja jest uczciwym układem. ODbL wymaga w zamian jednej rzeczy — widocznego kredytu „© OpenStreetMap". To jest tanie.

Cena za to wszystko istnieje i warto ją znać od razu: bariery, których u dostawcy komercyjnego pilnuje ktoś za ciebie, musisz zbudować sam. Limity zapytań, kolejkę, cache, sensowne komunikaty błędów. Połowa tego wpisu jest właśnie o tym.

Kafle: jeden zestaw, dwa motywy, jeden filtr CSS

OpenStreetMap daje jeden raster i jest jasny. Wersji ciemnej nie ma. Standardowe wyjście to szukanie dostawcy dark tiles — czyli wracamy do konta i klucza, od którego uciekaliśmy. Zaczynaliśmy zresztą od kafli CARTO, ale zrezygnowaliśmy: drugie źródło to drugi kredyt i drugi link wychodzący z mapy, a tego serwis nie chciał.

Ciemny motyw robi więc filtr CSS na .leaflet-tile-pane:

/* jasny */
--map-tile-filter: saturate(0.5) brightness(1.04) contrast(0.92);

/* ciemny */
--map-tile-filter: invert(0.93) hue-rotate(180deg) saturate(0.42) brightness(0.92) contrast(0.95);

Kwadrat mapy ulic przecięty w pionie: po lewej jasna wersja z ciemnymi liniami ulic, po prawej te same ulice odwrócone w nocny motyw z granatowym tłem, zieloną plamą parku i turkusową rzeką

Sedno siedzi w hue-rotate. Samo invert daje fioletowe lasy i rdzawą wodę — wygląda jak zepsuty monitor. Obrót barwy o 180 stopni po inwersji wraca zieleń na zieleń, a błękit na błękit. invert(0.93) zamiast pełnego 1, żeby kafle nie były czarniejsze od tła strony. Cztery linijki zamiast integracji z kolejnym dostawcą; sam bym się dziś nie zamienił.

Mapa w środku długiej strony to problem UX, nie techniczny

Ten jest klasyczny i nadal spotykam go na co drugiej stronie z „mapką dojazdu": mapa na pełną szerokość w środku treści łapie przewijanie i czytelnik na telefonie zostaje uwięziony. Przewija palcem, mapa się przesuwa, strona nie. Skopiowaliśmy zachowanie Google Maps, bo ludzie je znają:

  • Dotyk: jeden palec przewija stronę, dwa ruszają mapę. Sztuczka polega na dragging: false na urządzeniach pointer: coarse. Bez tego Leaflet sam ustawia na kanwie touch-action: pan-x pan-y, więc formalnie oddaje przewijanie stronie, ale gest nadal łapie.
  • Kółko myszy: scrollWheelZoom: false, własny listener na wheel, zoom tylko przy ctrlKey || metaKey. Szczypanie na gładziku przychodzi do przeglądarki jako Ctrl+wheel, więc pinch działa za darmo — trzeba tylko pamiętać o preventDefault(), inaczej przeglądarka powiększy całą stronę.
  • Cooldown 80 ms zamiast liczenia pikseli. event.deltaY ma inną wartość w każdej przeglądarce i na każdym ekranie, a gładzik sypie dziesiątkami drobnych zdarzeń na sekundę. Liczenie czasu, nie delty, daje przewidywalne „jeden ząbek kółka = jeden poziom zoomu".

I rzecz, którą uważam za najważniejszą w całej tej sekcji: gest, który nic nie zrobił, musi się wytłumaczyć. Po nieudanej próbie kadr pokazuje na 1,8 sekundy „Przytrzymaj ⌘ i przewiń, aby powiększyć mapę" albo „Przesuń mapę dwoma palcami" (klawisz podmieniany na Ctrl poza Makiem). Bez tego komunikatu mapa po prostu wygląda na zepsutą. Komunikat ma aria-hidden, bo dotyczy gestów — czytnik ekranu obsługuje mapę klawiaturą Leafletu i nie ma po co słuchać o palcach.

Dwa telefony z tą samą mapą: po lewej jeden palec na ekranie i strzałka przewijania całej strony, po prawej dwa palce i strzałka przesuwania samej mapy

Leaflet nie może zabić pierwszego malowania

Leaflet plus pierwsza porcja kafli to kilkaset kilobajtów. Trzy warstwy obrony, w kolejności ważności:

  1. import("leaflet") dynamicznie, po stronie klienta. Leaflet i tak dotyka window, więc do SSR nie wejdzie.
  2. IntersectionObserver z rootMargin: "300px" — mapa buduje się, dopiero gdy zbliża się do ekranu. Gdy IntersectionObserver nie istnieje, odpalamy od razu; wolę mapę załadowaną za wcześnie niż mapę, która po cichu nie istnieje.
  3. Poprawka, którą dopisaliśmy dwa dni później, i to jest tu pointa. Zapas 300 px sięgał pod krawędź ekranu telefonu już przy pierwszym renderze, więc Leaflet budował warstwy i zamawiał kilkadziesiąt kafli, zanim przeglądarka namalowała cokolwiek. Kadr poniżej ekranu czeka teraz na load i requestIdleCallback(…, { timeout: 2000 }). Kadr widoczny od razu startuje bez zwłoki, bo tam mapa jest treścią.

Do tego ResizeObserver wołający map.invalidateSize(). Mapa wstawiona przed ustaleniem layoutu ma dziury w kaflach, dopóki nie przeliczy rozmiaru — objaw wygląda na błąd sieci, a jest błędem kolejności. Więcej takich rzeczy zbieramy w optymalizacji stron.

Geolokalizacja na iPhonie to osobna dyscyplina

Przycisk „moja lokalizacja" działał wszędzie poza Safari na iOS, gdzie zostawał w stanie „pytamy…" do końca wizyty. Kod siedział wtedy w trzech kopiach, każda z tymi samymi czterema błędami. Dziś jest jeden hook i cztery poprawki. Jeśli dotykasz Geolocation API, to są te cztery:

getCurrentPosition na iOS potrafi nie wywołać żadnego callbacku. Ani sukcesu, ani błędu. Przycisk zostaje w stanie „pracuję" na zawsze. Lekarstwo: watchPosition(), który dowozi pierwszy odczyt, jaki urządzeniu udało się zdobyć, i clearWatch() natychmiast po nim.

timeout w opcjach odlicza także czas czytania okienka zgody. Zegar startuje w chwili wywołania, więc tyka, gdy czytelnik czyta systemowy prompt. Efekt: błąd „timeout" dla kogoś, kto właśnie kliknął „Zezwól". Dlatego timeout nie jest w ogóle przekazywany, a cierpliwość liczymy sami: 45 sekund na całość i 15 sekund od chwili, gdy Permissions API zgłosi granted.

denied z Permissions API nie może być ostatnim słowem. Wcześniejsza wersja sprawdzała navigator.permissions.query({ name: "geolocation" }) i kończyła sprawę bez próby. To młody interfejs, w Safari młodszy niż sama geolokacja. Teraz stan zgody służy wyłącznie do wyboru komunikatu: denied znaczy „masz to zablokowane w ustawieniach witryny", a brak jakiegokolwiek zapisu znaczy najczęściej „jesteś w przeglądarce wbudowanej w Facebooka albo Instagrama, która pytania nie pokaże — otwórz stronę w Safari".

POSITION_UNAVAILABLE przy odczycie zgrubnym. Pierwsza próba idzie z enableHighAccuracy: false, bo do zdania „24 km stąd" maszty i wi-fi wystarczą, a GPS-u nie ma po co budzić. Jeśli wróci POSITION_UNAVAILABLE, druga próba prosi o dokładną pozycję. Na iPhonie to bywa różnica między brakiem odpowiedzi a odpowiedzią.

Do tego drobiazgi, bez których cały ten mechanizm nadal potrafi skłamać: rosnący licznik prób (odpowiedź po porzuconej próbie nie wraca na ekran), flaga „już obsłużone" per nasłuch, maximumAge: 60_000 (drugie kliknięcie odpowiada natychmiast) i nasłuch na zmianę zgody, bo czytelnik może przestawić przełącznik w trakcie wizyty. Zasada, którą przypiąłbym nad biurkiem: żadna ścieżka nie może się skończyć bez wyczyszczenia stanu „zajęty".

Nominatim nie jest geokoderem do wszystkiego

To najczęstszy błąd w integracjach z OSM, a rozwiązuje się go raz na projekt. OpenStreetMap ma kilka usług i one nie są wymienne:

UsługaDo czegoDlaczego nie inna
Nominatimzatwierdzony adres → współrzędne i droga odwrotnalimit 1 zapytanie na sekundę, a polityka wprost zabrania używania go do autouzupełniania
Photonpodpowiedzi w trakcie pisaniate same dane OSM, ale silnik zrobiony pod wpisywanie znak po znaku
Overpassjednorazowy zrzut danych do pliku w repopytanie zadajesz raz, przy buildzie, nie na produkcji
OSRMprawdziwy przebieg trasy samochodemta sama instancja, która obsługuje openstreetmap.org

Podział ról wyszedł tak: podpowiedzi z Photona (tanie, wiele na wyraz), zatwierdzony adres z Nominatim (jedno zapytanie, dokładne). Lista miejscowości to plik w repo — Overpass odpowiedział raz, na pytanie o place=city|town w Polsce (wsi nie pytamy, to czterdzieści tysięcy wpisów), i mamy 1020 pozycji, około 55 kB. Deduplikacja po nazwie zostawia większą miejscowość, sortowanie idzie malejąco po liczbie mieszkańców, żeby „wa" dawało Warszawę, a nie Wąchock, a współrzędne są przycięte do czterech miejsc po przecinku, czyli około 11 metrów.

Jeden szczegół z tego pliku wart osobnego zdania, bo raz już zapłaciliśmy za tę pomyłkę w innym miejscu serwisu: plik jest osobną trasą /miasta.json, a nie importem w komponencie. 55 kB dopięte do payloadu każdej podstrony to dokładnie ten sam błąd, który popełniliśmy kiedyś z indeksem podpowiedzi wyszukiwarki. Teraz plik leci raz na kartę, przy pierwszym kliknięciu w pole.

Wszystkie zapytania idą przez nasz serwer

Żadne z tych zapytań nie leci z przeglądarki. Trzy trasy pomocnicze na naszym backendzie, każda z osobnym powodem:

  1. OSM wymaga własnego, identyfikującego User-Agent. Przeglądarka go nie ustawi.
  2. Trzeba trzymać limit zapytań i pamiętać wyniki. Przeglądarka nie zrobi ani jednego, ani drugiego.
  3. Do OpenStreetMap nie trafia wtedy IP czytelnika, tylko nasze.

Cała warstwa ochronna to sto linijek i trzy prymitywy. Grzeczna kolejka — łańcuch promisów, zapytania idą pojedynczo, z odstępem liczonym od startu poprzedniego (bo tak brzmi reguła „jedno na sekundę"): 1100 ms dla Nominatim, 300 ms dla routera. Limiter — okno przesuwne po IP, od 15 do 40 zapytań na minutę zależnie od trasy, plus drugi limit globalny na podpowiedziach adresowych, żeby jeden crawler nie zrobił z nas młotka na cudzy serwer. Cache LRU w pamięci — doba dla adresów, kwadrans dla tras, i zapamiętany brak wyniku też liczy się jako wynik, bo oszczędza zapytanie.

Wszystkie trzy trasy to POST, nie GET. Nie dlatego, że tak ładniej: wpisany adres i współrzędne czytelnika nie mają czego szukać w logach serwera ani w nagłówku Referer. Odpowiedzi lecą z cache-control: no-store i x-robots-tag: noindex.

Uczciwe ograniczenie, bo obiecałem sobie pisać też o tym: to wszystko żyje w pamięci procesu. Przy kilku instancjach każda liczy po swojemu, więc realne limity są luźniejsze, niż mówią liczby. Świadoma cena za brak Redisa pod dwiema pomocniczymi trasami — i pierwsza rzecz, którą bym zmienił, gdyby ruch wzrósł dziesięciokrotnie.

Trasa: prawdziwa geometria i szacunek, który pada od razu

Panel „oblicz czas dojazdu" ma jedną zasadę: wynik pojawia się w tej samej klatce, w której czytelnik kliknął. Nie po sekundzie oczekiwania na router.

Da się to zrobić, bo szacunek liczy przeglądarka z dwóch krzywych:

roadKm  = straightKm * (1.3157 + 0.0959 * Math.exp(-straightKm / 10.096));
speed   = 36.131 + 70.836 * roadKm / (roadKm + 134.047);
minutes = 0.533 + roadKm / speed * 60;

Sens fizyczny jest prosty: krótka trasa wije się po mieście i bywa o 41% dłuższa od linii prostej, długa idzie ekspresówką i zbliża się do 32% nadmiaru. Średnia prędkość rośnie z długością, od około 36 km/h w mieście do stu kilku w granicy.

Te stałe nie są zgadnięte. Skrypt kalibracyjny dopasowuje je do 460 prawdziwych tras policzonych przez OSRM po całej Polsce, metodą Neldera-Meada (cztery parametry, prosty simplex, żadnej biblioteki). Rzeczy, które w tej kalibracji okazały się ważniejsze od samego algorytmu:

  • Skład próbki. Cele to nasze zweryfikowane punkty, a starty w 55% inne prawdziwe adresy, w 45% losowe współrzędne w granicach kraju. Same losowe punkty trafiają w polne drogi i zrobiłyby z modelu pesymistę; same prawdziwe adresy stoją przy porządnych drogach i zrobiłyby optymistę.
  • Losowanie log-równomierne po odległości (0,8–520 km), bo krótkich dojazdów jest w życiu najwięcej, ale model musi się trzymać także na trasie przez pół Polski.
  • Wagi po półdekadach odległości. Bez tego setka dojazdów miejskich przegłosowuje trasy dalekie.
  • Ocena końcowa liczy czas z przewidzianej długości drogi, nie z prawdziwej — dokładnie tak, jak zrobi to przeglądarka, więc błędy obu krzywych się kumulują. Tu najłatwiej oszukać samego siebie i wyjść z pracy zadowolonym z nieprawdziwej liczby.

Wynik: mediana błędu czasu 13,6%, na trasach spoza uczenia 13,5%. Połowa szacunków mieści się w sześciu minutach od tego, co liczy prawdziwy router. Najsłabiej wychodzi poniżej pięciu kilometrów, gdzie rzeka albo jednokierunkowa potrafi podwoić drogę — ale tam pomyłka to minuta, nie godzina.

Zaraz po szacunku (oznaczonym „≈" i etykietą „szacunek") leci jedno zapytanie do OSRM. Gdy router odpowie, jego liczba zastępuje szacunek — nie stoją obok siebie, bo dwie liczby na jednym ekranie to pytanie „której wierzyć". Gdy router milczy, zostaje szacunek z jawnym zdaniem, że router nie odpowiedział. Powyżej dwudziestu minut zaokrąglamy do pięciu, bo to szacunek, nie rozkład jazdy.

Samo rysowanie trasy ma jeszcze trzy pułapki. overview=full, nie simplified — OSRM upraszcza pod poziom zoomu, nie pod nasz kadr, i na dalekiej trasie oddaje kilkadziesiąt wierzchołków, na których linia ścina całe zakręty; bierzemy pełną geometrię i przerzedzamy ją sami do 200 punktów. GeoJSON podaje [lng, lat], Leaflet chce [lat, lng] — obracamy raz, na serwerze. I code: "NoRoute" to poprawna odpowiedź, nie awaria: wyspa albo punkt bez drogi. Zwracamy wtedy 404 z ludzkim komunikatem, a 502 zostawiamy na prawdziwą awarię routera. Dwie różne sytuacje, dwa różne zdania dla czytelnika.

Cztery rzeczy, które zjadły najwięcej czasu przy najmniejszym efekcie

Ta sekcja jest po to, żebyś ich nie powtarzał.

Minifikator zepsuł nam innerHTML. Dymek z kredytem mapy był sklejany ze template stringa z ${} w środku. Na produkcji minifikator ściął to tak, że zniknęło zamknięcie cudzysłowu i dwa linki zlały się w jeden zepsuty href, coś w rodzaju openstreetmap.org/copyright%3Ca%20href=. Lokalnie oczywiście działało. Naprawa: document.createElement zamiast sklejania stringów. Wszystko, co idzie do dymka z CMS-a, przechodzi jeszcze przez własne escapowanie.

Każdy marker Leafletu to dla czytnika ekranu role="button" bez nazwy. Cała mapa czytała się jako lista bezimiennych „przycisk, przycisk, przycisk". Wystarczył title z nazwą punktu i miastem; razem z poprawą kontrastu tekstu podniosło to ocenę dostępności w Lighthouse z 96 na 100 na telefonie i z 92 na 100 na desktopie.

Kolizja CSS o równej specyficzności. Klasa dymka ustawiała position: relative później w kaskadzie niż klasa przycisku mapy ustawiała position: absolute. Przy równej specyficzności wygrywa późniejsza, więc oba przyciski zjechały pod mapę. Godzina szukania w JS-ie problemu, którego w JS-ie nie było.

Odwzorowanie. Stopień długości geograficznej to mniej kilometrów niż stopień szerokości, i to zależnie od szerokości. Bez cos(lat) w wyliczaniu marginesu „100 km wokół punktu" kadr wychodzi ściśnięty. Ta sama rodzina błędów co większa pinezka z domyślnym iconAnchor, która wskazuje obok swojego punktu.

Co jeszcze da się dobudować na OSM

Tego już nie budowaliśmy, bo mapa nie musiała tego umieć. Ale wszystko poniżej stoi na tym samym stosie i nie wymaga zmiany dostawcy:

  • Klastrowanie markerów — przy 61 punktach zbędne, przy kilkuset obowiązkowe (Leaflet.markercluster). Mapa Polski z setkami pinezek robi się kaszą, zanim zrobi się wolna.
  • Isochrony — zamiast „24 km stąd" obszar „wszystko, do czego dojedziesz w 20 minut". Liczy to Valhalla albo OSRM, ale to już usługa, którą trzeba postawić u siebie.
  • Macierz odległości (/table w OSRM) — jedno zapytanie zamiast pięciu, gdy chcesz uszeregować kilka punktów po prawdziwym czasie dojazdu, nie po linii prostej.
  • Własne kafle — Protomaps i format PMTiles pozwalają serwować Polskę z jednego pliku na własnym S3. Sensowne z dwóch powodów: kafle nie wychodzą już na obce serwery (czyli IP czytelnika zostaje u nas) i wygląd mapy projektuje się od zera, zamiast filtrować rastr.
  • Statyczny obraz mapy do miniatur i OG image — mapa w karcie linku na Facebooku nie musi być interaktywna, a nie chcesz ładować Leafletu dla obrazka.
  • Godziny otwarcia z OSM — dane bywają w tagu opening_hours, tylko trzeba je traktować jako podpowiedź do weryfikacji, nie jako prawdę.
  • Trasa pieszo i rowerem — OSRM ma osobne profile; przy punktach w centrach miast „12 minut piechotą" jest bardziej użyteczne niż „4 km".

Kiedy OpenStreetMap nie wystarczy

Uczciwie, bo to nie jest wybór bez wad.

Nie ma Street View. Są społecznościowe odpowiedniki, Mapillary i KartaView, ale pokrycie jest nierówne i nie zbudujesz na nim produktu, w którym klient ma zobaczyć witrynę lokalu.

Nie ma danych o ruchu. OSRM policzy trasę na pustych drogach, więc czas dojazdu przez Warszawę w piątek o 16 jest z definicji zbyt optymistyczny. Nasze „≈" jest tu uczciwsze niż wygląda.

Numery domów bywają, ale nie wszędzie. Przy 61 adresach dostaliśmy współrzędne do wszystkich, ale trzy trafienia poszły do ręcznej decyzji redakcji, a osobnym problemem były adresy opisowe. Redakcja pisze coś w rodzaju „Krakowska 20 (galeria handlowa, poziom −1)", a Nominatim dopasowuje wtedy całą ulicę i stawia pinezkę nawet dwa kilometry obok. Bez wycięcia nawiasów i numerów pawilonów ten skrypt kłamie i wygląda przy tym na pewnego siebie.

Publiczne instancje mają limity i prawo leżeć. Przy realnym ruchu Nominatim i OSRM stawia się u siebie w kontenerze, a tile.openstreetmap.org przestaje być opcją, bo regulamin użycia wprost wyłącza ruch masowy. Nasze liczby (kilka zapytań na kliknięcie, cache, kolejka) mieszczą się w „uprzejmym użyciu"; sklep z tysiącem punktów i mapą na stronie głównej już nie.

Jedna rzecz do zrobienia dziś

Za każdą poprawką w tym projekcie stał ten sam odruch: nie zostawiać człowieka przed ekranem, który wygląda na zepsuty, skoro wystarczy jedno zdanie wyjaśnienia. To zdanie jest tańsze od każdego refaktoru.

Jeśli masz na stronie mapę wklejoną z iframe'a, zrób dziś jeden test. Wejdź na telefonie, przewiń stronę tak, żeby mapa zajęła cały ekran, i spróbuj przewinąć dalej jednym palcem. Jeśli utknąłeś — masz już swoje pierwsze zadanie, i to takie, które da się zamknąć jednym dragging: false. Resztę tej listy możesz robić po kolei, tak jak my: przez dziesięć dni i kilka niepotrzebnych godzin w devtoolsach.

Podobnym trybem powstawał ten blog — o tym pisaliśmy w migracji z WordPressa na własne API, a więcej rzeczy z tej półki leży w tworzeniu stron www.

Powiązane wpisy

więcej →