Po raz kolejny tworząc aplikację do monitorowania statków napotkałem ciekawy problem. Tym razem tyczył się on pewnej biblioteki dostępnej w Angularze pozwalającej wyświetlać mapę na stronie. Chodzi oczywiście o tytułową bibliotekę Leaflet. Jej wykorzystanie było mi niezbędne, aby zaznaczać obecną pozycję statków na morzu. W przypadku wybrania danego znacznika reprezentującego statek lub ich grupę pokazywał się odpowiedni dymek wraz z podstawowymi informacjami. Nie było tam nic specjalnego poza literałami. Jednak w pewnym momencie wpadłem na pomysł, aby wyświetlać w tym miejscu również przycisk dodający wybrany statek jako ten, który chcielibyśmy śledzić. Wtedy właśnie pojawił się problem, który chciałbym opisać w tym artykule. Jednak najpierw napiszę kilka słów wyjaśnienia czym dokładnie jest biblioteka Leaflet.

Czym jest Leaflet?

Czasami jednym z wymagań stawianych aplikacji jest przedstawienie danych na mapie. W Internecie możemy znaleźć kilka rozwiązań pozwalających spełnić to oczekiwanie. Jednym z nich jest biblioteka open-source o nazwie Leaflet napisana w JavaScript. Pomimo małej wagi, bo około 39KB, posiada sporo funkcjonalności niezbędnych deweloperom do obsłużenia wielu zaistniałych potrzeb.

Wizualny przykład wykorzystania biblioteki Leaflet
Wizualny przykład wykorzystania biblioteki Leaflet

Zaprogramowanie mapy nie wydaje się, więc skomplikowanym zadaniem skoro mamy tak potężne narzędzie jak Leaflet. Gdybyśmy chcieli uzyskać efekt przedstawiony na powyższym obrazku to możemy go osiągnąć poprzez dodanie div o id map do HTML, następnie wybranie odpowiedniej warstwy (w tym przypadku OpenStreetMap) i dodanie znacznika z tekstem w chmurce. Całość tego algorytmu prezentuje się jak w kodzie poniżej.

1
2
3
4
5
6
7
8
9
var map = L.map('map').setView([51.505, -0.09], 13);

L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);

L.marker([51.5, -0.09]).addTo(map)
    .bindPopup('A pretty CSS3 popup.<br> Easily customizable.')
    .openPopup();

Skoro już wiemy do czego mniej więcej służy Leaflet oraz zobaczyliśmy przykład z dodaniem znacznika do mapy przejdźmy do przedstawienia problemu jaki napotkałem podczas implementowania swojej aplikacji.

Guzik w dymku

Wszystko wyglądało podobnie jak w powyższym przykładzie. Pobierałem z wcześniej utworzonego serwera dane statków zawierające m.in. ich identyfikatory oraz aktualne położenie. Na tej podstawie stworzyłem kawałek HTML, który umieszczałem w dymku.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Injectable({
  providedIn: 'root'
})
export class PopupService {

  constructor(private authenticationService: AuthenticationService) {
  }

  makeVesselPopup(registry: VesselRegistry): string {
    return `<div style="text-align: center;">
                <div
                    style="
                        margin-bottom: 10px;
                        font-size: 0.8rem"
                >MMSI: <strong>${registry.mmsi}</strong></div>
                <div style="text-decoration: underline;">Last position update:</div>
                <div>${moment(registry.pointInTime.timestamp).format('DD-MM-YYYY HH:mm:ss')}</div>` +
            `</div>`;
  }
}

Nie ma co ukrywać, taki kod nie należy do najczytelniejszych. Jednak na ten moment wszystko działało. Po jakimś czasie wpadłem na pomysł, o którym wspomniałem wcześniej. Chciałem, aby w dymku pojawiał się przycisk dodawania danego statku do śledzenia jego pozycji w czasie. Kod wyewoluował do następującej postaci.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Injectable({
  providedIn: 'root'
})
export class PopupService {

  constructor(private authenticationService: AuthenticationService) {
  }

  makeVesselPopup(registry: VesselRegistry): string {
    return `<div style="text-align: center;">
                <div
                    style="
                        margin-bottom: 10px;
                        font-size: 0.8rem"
                >MMSI: <strong>${registry.mmsi}</strong></div>
                <div style="text-decoration: underline;">Last position update:</div>
                <div>${moment(registry.pointInTime.timestamp).format('DD-MM-YYYY HH:mm:ss')}</div>` +
                (this.authenticationService.loggedUser ?
                  `<a style="cursor: pointer;" title="Track vessel" class="add-vessel material-icons-outlined md-24">add</a>` : ``) +
            `</div>`;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const marker = L.circleMarker(
  [lat, lon],
  {
    ...vesselMarker.markerOptions
  });
marker.bindPopup(vesselMarker.popupTemplate)
  .on("popupopen", () => {
    elementRef.nativeElement
      .querySelector(".add-vessel")
      .addEventListener("click", () => {
        this.vesselService.trackVessel(vesselMarker.data.vessels[0].mmsi)
          .subscribe();
      })
  });

  ...

Jeżeli użytkownik jest zalogowany to guzik pojawia się. Natomiast w przeciwnym przypadku zwracany jest pusty literał. Należy zwrócić uwagę, że przycisk otrzymał klasę CSS add-vessel. Ten zabieg jest niezbędny, aby w jednym z kolejnych serwisów przy obsłudze zdarzenia wyświetlenia dymku odnaleźć elementy o tej klasie i podpiąć do nich określone zachowanie. I tutaj wszystko działało prawidłowo, ale strasznie irytowała mnie świadomość, że obsługa przycisku była rozsiana pomiędzy dwa serwisy. Po dłuższej chwili poszukiwań znalazłem taki oto sposób na zmianę tego kodu.

Rozwiązanie

Na początku należało stworzyć komponent zarządzający treścią naszego dymku. W ten sposób powstał PopupComponent przyjmujący na wejściu niezbędne dane wybranego statku i przenoszący je do szablonu HTML, który wyglądał tak jak wynik metody makeVesselPopup. Powstaje teraz pytanie jak powiązać ten komponent z odpowiednim dymkiem na mapie. Sprawdźmy to.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Injectable({
  providedIn: 'root'
})
export class MarkerService {

  popupComponentFactory: ComponentFactory<PopupComponent>;

  constructor(private injector: Injector,
              private resolver: ComponentFactoryResolver) {
    this.popupComponentFactory = this.resolver.resolveComponentFactory(PopupComponent);
  }

  ...

  private convertSingleMarker(marker: SingleMarker): L.CircleMarker {
    let mapMarker = L.circleMarker(
      [marker.latitude, marker.longitude],
      {
        ...this.getSingleMarkerOptions(2, marker.active ? 'green' : 'grey', 1)
      }
    );

    const component = this.popupComponentFactory.create(this.injector);
    component.instance.singleMarker = marker;
    component.changeDetectorRef.detectChanges();

    mapMarker.bindPopup(component.location.nativeElement);

    return mapMarker;
  }

  ...

}

Niezbędny do tego zabiegu okazał się Injector oraz ComponentFactoryResolver. Dzięki nim jesteśmy w stanie utworzyć instancję wybranego komponentu, czyli w naszym przypadku PopupComponent. Gdy już ją wykreujemy to możemy dostać się do jej pól, aby przekazać niezbędne informacje o statku. Dla pewności dodatkowo warto jeszcze wywołać weryfikację zmian w komponencie. Na koniec przekazujemy uzupełniony HTML do dymka tworzonego znacznika i to tyle. W ten sposób to serwis jest odpowiedzialny za tworzenie komponentu. Nie ma między nimi kruchego kontraktu, który mógłby być łatwo zerwany i ciężko byłoby go wyłapać. Przy tym rozwiązaniu jeśli zmieni się nazwa pola singleMarker to kompilator nas o tym od razu poinformuje w porównaniu do klasy CSS.

Wygląd aplikacji z działającym dymkiem na znacznikiem statku
Wygląd aplikacji z działającym dymkiem na znacznikiem statku

Podsumowanie

Szczerze to nie wiem czy to rozwiązanie jest poprawne pod kątem złożoności czy wydajności. Na potrzeby nauki się sprawdza i według mnie jest bardziej elastyczne niż trzymanie HTML w kodzie jako literał. Jest to moja osobista opinia, z którą wiele osób może się nie zgadzać. Z tego właśnie powodu z ogromną chęcią dowiem się co mógłbym jeszcze poprawić w tym rozwiązaniu. Jeśli jesteś ciekawy działania tej aplikacji to zapraszam Cię na mojego GitHuba, gdzie znajdziesz cały kod aplikacji do monitorowania statków.