bluehalo / ngx-leaflet

Core Leaflet package for Angular.io
MIT License
777 stars 127 forks source link

Custom Components in marker popups. #178

Open SimonSch opened 6 years ago

SimonSch commented 6 years ago

It would be great if there is a possibility to customize the marker popups with custom angular components.

zellb commented 6 years ago

You can do this by using Angular 6 NgElement

marker.bindPopup( layer => { const popupEl: NgElement & WithProperties<SomeComponent> = document.createElement('popup-element') as any; popupEl.somePropery = someValue; return popupEl; }, options);

zachatrocity commented 6 years ago

Expanding on @zellb 's comment, here is how I achieved custom angular components in leaflet popups..

popup.component.ts:

import { Component, OnInit, Input } from '@angular/core';

@Component({
  selector: 'app-popup',
  template: '<p> {{ message }}</p>',
  styleUrls: ['./popup.component.scss']
})
export class PopupComponent implements OnInit {

  @Input() message = 'Default Pop-up Message.';

  constructor() { }

  ngOnInit() {
  }

}

In your app.modules.ts declare your pop up component, set it as an entryComponent and finally register the custom element with the browser:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule, Injector } from '@angular/core';
import { createCustomElement } from '@angular/elements';
import { AppComponent } from './app.component';
import { LeafletModule } from '@asymmetrik/ngx-leaflet';

// services
import { LeafletMapService } from './gis/services/leaflet-map.service';

import { LeafletMapComponent } from './gis/components/leaflet-map/leaflet-map.component';
import { PopupComponent } from './gis/components/popup/popup.component';

@NgModule({
  declarations: [
    AppComponent,
    LeafletMapComponent,
    PopupComponent
  ],
  imports: [
    BrowserModule,
    LeafletModule.forRoot()
  ],
  providers: [
    LeafletMapService
  ],
  bootstrap: [AppComponent],
  entryComponents: [PopupComponent]
})
export class AppModule {
  constructor(private injector: Injector) {
    const PopupElement = createCustomElement(PopupComponent, {injector});
    // Register the custom element with the browser.
    customElements.define('popup-element', PopupElement);
  }
 }

Then given my leaflet-map.component.ts:

import { Component, OnInit } from '@angular/core';
import * as L from 'leaflet';
import * as esri from 'esri-leaflet';
import { LeafletMapService } from '../../services/leaflet-map.service';

@Component({
  selector: 'app-leaflet-map',
  templateUrl: './leaflet-map.component.html',
  styleUrls: ['./leaflet-map.component.scss']
})
export class LeafletMapComponent implements OnInit {

  constructor(
    public _leafletSvc: LeafletMapService
  ) { }

  ngOnInit() {
  }

  public onMapReady(map: L.Map) {
    // map is now loaded, add custom controls, access the map directly here
  }

}

and my leaflet.service.ts:

import { Injectable, ElementRef, EventEmitter, Injector } from '@angular/core';
import { NgElement, WithProperties } from '@angular/elements';
import * as L from 'leaflet';
import * as esri from 'esri-leaflet';
import { PopupComponent } from '../components/popup/popup.component';

@Injectable()
export class LeafletMapService {

  public options: L.MapOptions;
  // for layers that will show up in the leaflet control
  public layersControl: any;
  // for layers not shown in the leaflet control
  public layers: any = [];

  constructor(injector: Injector) {
    this.options = {
      layers: [
        esri.basemapLayer('Streets')
      ],
      zoom: 5,
      center: L.latLng(39.8, -97.77)
    };

    this.layersControl = {
      baseLayers: {
        'Streets': esri.basemapLayer('Streets'),
        'Topographic': esri.basemapLayer('Topographic')
      },
      overlays: {
        'State Cities': this.addFeatureLayer(),
        'Big Circle': L.circle([ 46.95, -122 ], { radius: 5000 }),
        'Big Square': L.polygon([[ 46.8, -121.55 ], [ 46.9, -121.55 ], [ 46.9, -121.7 ], [ 46.8, -121.7 ]])
      }
    };
  }

  public addFeatureLayer() {
    const features = esri.featureLayer({
      url: 'https://sampleserver6.arcgisonline.com/arcgis/rest/services/USA/MapServer/0',
      pointToLayer: function (geojson, latlng) {
        return new L.CircleMarker(latlng, {
          color: 'green',
          radius: 1
        });
      },
      onEachFeature: function (feature, layer) {
        layer.bindPopup( fl => {
          const popupEl: NgElement & WithProperties<PopupComponent> = document.createElement('popup-element') as any;
          // Listen to the close event
          popupEl.addEventListener('closed', () => document.body.removeChild(popupEl));
          popupEl.message = `${feature.properties.areaname}, ${feature.properties.st}`;
          // Add to the DOM
          document.body.appendChild(popupEl);
          return popupEl;
        });
      }
    });

    return features;
  }
}

You can see that I can add the component dynamically using const popupEl: NgElement & WithProperties<PopupComponent> = document.createElement('popup-element') as any;

@reblace if you would like I could add a more generic example to the docs and open a PR for it.

throwawaygit000 commented 6 years ago

Expanding on @zellb 's comment, here is how I achieved custom angular components in leaflet popups..

and my leaflet.service.ts:

import { Injectable, ElementRef, EventEmitter, Injector } from '@angular/core';
import { NgElement, WithProperties } from '@angular/elements';
import * as L from 'leaflet';
import * as esri from 'esri-leaflet';
import { PopupComponent } from '../components/popup/popup.component';

@Injectable()
export class LeafletMapService {

  public options: L.MapOptions;
  // for layers that will show up in the leaflet control
  public layersControl: any;
  // for layers not shown in the leaflet control
  public layers: any = [];

  constructor(injector: Injector) {
    this.options = {
      layers: [
        esri.basemapLayer('Streets')
      ],
      zoom: 5,
      center: L.latLng(39.8, -97.77)
    };

    this.layersControl = {
      baseLayers: {
        'Streets': esri.basemapLayer('Streets'),
        'Topographic': esri.basemapLayer('Topographic')
      },
      overlays: {
        'State Cities': this.addFeatureLayer(),
        'Big Circle': L.circle([ 46.95, -122 ], { radius: 5000 }),
        'Big Square': L.polygon([[ 46.8, -121.55 ], [ 46.9, -121.55 ], [ 46.9, -121.7 ], [ 46.8, -121.7 ]])
      }
    };
  }

  public addFeatureLayer() {
    const features = esri.featureLayer({
      url: 'https://sampleserver6.arcgisonline.com/arcgis/rest/services/USA/MapServer/0',
      pointToLayer: function (geojson, latlng) {
        return new L.CircleMarker(latlng, {
          color: 'green',
          radius: 1
        });
      },
      onEachFeature: function (feature, layer) {
        layer.bindPopup( fl => {
          const popupEl: NgElement & WithProperties<PopupComponent> = document.createElement('popup-element') as any;
          // Listen to the close event
          popupEl.addEventListener('closed', () => document.body.removeChild(popupEl));
          popupEl.message = `${feature.properties.areaname}, ${feature.properties.st}`;
          // Add to the DOM
          document.body.appendChild(popupEl);
          return popupEl;
        });
      }
    });

    return features;
  }
}

You can see that I can add the component dynamically using const popupEl: NgElement & WithProperties<PopupComponent> = document.createElement('popup-element') as any;

@reblace if you would like I could add a more generic example to the docs and open a PR for it.

Where is that esri-leaflet from?

zachatrocity commented 6 years ago

It's this library: https://esri.github.io/esri-leaflet/

Should probably take that out for a more generic example.

throwawaygit000 commented 6 years ago

It's this library: https://esri.github.io/esri-leaflet/

Should probably take that out for a more generic example.

Thank you. It is possible to solve the problem without that library? I saw that you have called esri.featureLayer function

throwawaygit000 commented 6 years ago

@zachatrocity Are you going to provide a more generic example here?

zachatrocity commented 6 years ago

sure, you don't have to use esri.FeatureLayer, you could just use anything that accepts a L.Popup like a marker:

 public onMapReady(map: L.Map) {
    // Do stuff with map
    let popup = this.createPopupComponentWithMessage('Test popup!');

    let marker = new L.CircleMarker(L.latLng(46.879966, -121.726909), {
      color: 'green',
      radius: 1,
    })

    marker.bindPopup(fl => this.createPopupComponentWithMessage('test popup'));
    marker.addTo(map);
  }

  public createPopupComponentWithMessage(message: any) {
    const popupEl: NgElement & WithProperties<PopupComponent> = document.createElement('popup-element') as any;
    // Listen to the close event
    popupEl.addEventListener('closed', () => document.body.removeChild(popupEl));
    popupEl.message = message;
    // Add to the DOM
    document.body.appendChild(popupEl);
    return popupEl;
  }
mcurtis22 commented 6 years ago

I have an example of having markers themselves as Angular components. Here is that repo. It's pretty similar to the code provided here.

throwawaygit000 commented 6 years ago

Thanks to your @zachatrocity and @mcurtis22 help I was abble to do waht I needed. This tutorial (https://www.techiediaries.com/angular-elements-web-components/) was also very useful to learn how to create custom elements. I was missing the @angular/elements install. cheers!

throwawaygit000 commented 6 years ago

@zachatrocity @mcurtis22 Have you tried to use reactive forms on your custom element ?

zachatrocity commented 6 years ago

@lourencoGit like inside the custom popup component? No i haven't. What issues are you having?

throwawaygit000 commented 6 years ago

@zachatrocity the component dom was not being updated after some action, like error messages, ng-if condition changes, I solved it calling this.changeDetectorRef.detectChanges();

JamesHearts commented 6 years ago

can someone provide a solution for Angular 4/5?

mcurtis22 commented 6 years ago

@JamesHearts the repo I linked should be Angular 4 compatible, the original code was taken from an Angular 2 project.

@lourencoGit sorry for the delay, I did not use reactive forms, and had to also manually invoke change detection.

jziggas commented 5 years ago

When I follow these examples I get the following console error?

Uncaught TypeError: Failed to construct 'HTMLElement': Please use the 'new' operator, this DOM object constructor cannot be called as a function.

Edit: apparently it is necessary to npm i document-register-element@^1.8.1 even though it's mentioned nowhere in the Angular docs for custom elements 🤷‍♂

azizkhani commented 4 years ago

+1

zachatrocity commented 4 years ago

@JamesHearts see my comment above:

https://github.com/Asymmetrik/ngx-leaflet/issues/178#issuecomment-430001326

zachatrocity commented 4 years ago

@reblace pretty sure this issue could be closed with some documentation.

newmanw commented 4 years ago

It's not clear what fires the 'closed' event.

tuscaonline commented 3 years ago

on angular 12 I need to install @angular/elements with ng add @angular/elements

abhishekvaze26 commented 3 years ago

@zachatrocity I tried your solution, and it worked for me. I am able to show my custom component inside the popup. But I still have one issue. The popup does not open after page refresh ! So when I load the web app for first time, it works fine. But after refresh the popup just won't open ! PS: I'm opening the popup on marker click. I'm using ionic+angular

Any suggestion ?

Morstis commented 2 years ago

Hi everyone. For the last week or so I was trying to get this feature working with @angular/elements v13, leaflet v1.8 and this library v13.0.2

Whatever I tried, it did not work. For example, the popup got removed after clicking twice on it. However, the idea @mcurtis22 proposed does work. So I want to generalize it a bit:

Solution

At first, you do not need @angular/elements. The ComponentFactoryResolver works great and for me more reliable than elements. The Sulution should work with angular 13.

Creating the Component:

Create a template for a popup. This component needs to be added in the declarations array of your module.

popup.component.ts

import { Component, Input } from '@angular/core';
import { MapPopup } from './popup.interface';

@Component({
  template: `
    <div class="spotShortInfo" [routerLink]="['/map', popup.id]">
      <img [src]="popup.image" />
      <div class="body">
        <h3>{{ popup.name }}</h3>
        <div class="stars">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="14"
            height="14"
            viewBox="0 0 14.835 14.094"
            *ngFor="let s of ratingArr()"
          >
            <path
              id="Pfad_1504"
              data-name="Pfad 1504"
              d="M-1130.216-14.013l4.584,2.767-1.217-5.215,4.05-3.508-5.333-.453-2.084-4.918-2.085,4.918-5.333.453,4.05,3.508-1.216,5.215Z"
              transform="translate(1137.634 25.34)"
              fill="#00b9b9"
            />
          </svg>
          <span>{{ popup.rating.total }}</span>
        </div>
        <span class="mat-body-2">{{ popup.info }}</span>
      </div>
    </div>
  `,
})
export class PopupComponent {
  @Input() popup!: MapPopup;

  ratingArr() {
    return Array(this.popup.rating.avg).fill('');
  }
}

PopupService

Use a service methode to get the HTMLElement

popup.service.ts

import {
  ApplicationRef,
  ComponentFactoryResolver,
  Injectable,
  Injector,
} from '@angular/core';
import { PopupComponent } from './popup.component';
import { MapPopup } from './popup.interface';

@Injectable({ providedIn: 'root' })
export class PopUpService {
  constructor(
    private injector: Injector,
    private applicationRef: ApplicationRef,
    private componentFactoryResolver: ComponentFactoryResolver
  ) {}

  returnPopUpHTML(popupData: MapPopup): HTMLElement  {
    // Create element
    const popup = document.createElement('popup-component');

    // Create the component and wire it up with the element
    const factory =
      this.componentFactoryResolver.resolveComponentFactory(PopupComponent);
    const popupComponentRef = factory.create(this.injector, [], popup);

    // Attach to the view so that the change detector knows to run
    this.applicationRef.attachView(popupComponentRef.hostView);

    // Set the message
    popupComponentRef.instance.popup = popupData;

    // Return rendered Component
    return popup;
  }
}

Call the methode and bindPopup

Now you can generate a popup with given data.

app.component.ts

import {
  marker,
  Map,
  Marker,
} from 'leaflet';

  constructor(
    private popupService: PopUpService
  ) {}
  map!: Map;

...

  onMapReady(map: Map): void {
    this.map = map;
        const { name, info, image, id, rating, coordinates } = spotData;
        const popupEl = this.popupService.returnPopUpHTML({
          name,
          info,
          image,
          id,
          rating
        });

        marker(coordinates)
          .bindPopup(popupEl)
          .addTo(this.map);
}
akikesulahti commented 2 years ago

Thank you for sharing your solution @Morstis ! It works flawlessly with Angular v14 and the solution is super nice and elegant.

lowickert commented 2 years ago

Hi everyone. For the last week or so I was trying to get this feature working with @angular/elements v13, leaflet v1.8 and this library v13.0.2

Whatever I tried, it did not work. For example, the popup got removed after clicking twice on it. However, the idea @mcurtis22 proposed does work. So I want to generalize it a bit:

Solution

At first, you do not need @angular/elements. The ComponentFactoryResolver works great and for me more reliable than elements. The Sulution should work with angular 13.

Creating the Component:

Create a template for a popup. This component needs to be added in the declarations array of your module.

popup.component.ts

import { Component, Input } from '@angular/core';
import { MapPopup } from './popup.interface';

@Component({
  template: `
    <div class="spotShortInfo" [routerLink]="['/map', popup.id]">
      <img [src]="popup.image" />
      <div class="body">
        <h3>{{ popup.name }}</h3>
        <div class="stars">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="14"
            height="14"
            viewBox="0 0 14.835 14.094"
            *ngFor="let s of ratingArr()"
          >
            <path
              id="Pfad_1504"
              data-name="Pfad 1504"
              d="M-1130.216-14.013l4.584,2.767-1.217-5.215,4.05-3.508-5.333-.453-2.084-4.918-2.085,4.918-5.333.453,4.05,3.508-1.216,5.215Z"
              transform="translate(1137.634 25.34)"
              fill="#00b9b9"
            />
          </svg>
          <span>{{ popup.rating.total }}</span>
        </div>
        <span class="mat-body-2">{{ popup.info }}</span>
      </div>
    </div>
  `,
})
export class PopupComponent {
  @Input() popup!: MapPopup;

  ratingArr() {
    return Array(this.popup.rating.avg).fill('');
  }
}

PopupService

Use a service methode to get the HTMLElement

popup.service.ts

import {
  ApplicationRef,
  ComponentFactoryResolver,
  Injectable,
  Injector,
} from '@angular/core';
import { PopupComponent } from './popup.component';
import { MapPopup } from './popup.interface';

@Injectable({ providedIn: 'root' })
export class PopUpService {
  constructor(
    private injector: Injector,
    private applicationRef: ApplicationRef,
    private componentFactoryResolver: ComponentFactoryResolver
  ) {}

  returnPopUpHTML(popupData: MapPopup): HTMLElement  {
    // Create element
    const popup = document.createElement('popup-component');

    // Create the component and wire it up with the element
    const factory =
      this.componentFactoryResolver.resolveComponentFactory(PopupComponent);
    const popupComponentRef = factory.create(this.injector, [], popup);

    // Attach to the view so that the change detector knows to run
    this.applicationRef.attachView(popupComponentRef.hostView);

    // Set the message
    popupComponentRef.instance.popup = popupData;

    // Return rendered Component
    return popup;
  }
}

Call the methode and bindPopup

Now you can generate a popup with given data.

app.component.ts

import {
  marker,
  Map,
  Marker,
} from 'leaflet';

  constructor(
    private popupService: PopUpService
  ) {}
  map!: Map;

...

  onMapReady(map: Map): void {
    this.map = map;
        const { name, info, image, id, rating, coordinates } = spotData;
        const popupEl = this.popupService.returnPopUpHTML({
          name,
          info,
          image,
          id,
          rating
        });

        marker(coordinates)
          .bindPopup(popupEl)
          .addTo(this.map);
}

This solution has the problem that componentFactoryResolver is deprecated up from Angular 13 (see: Documentation). In the documentation they propose to use ViewContainerRef.createComponent(). The problem with this is that this function does not support the rootSelectorOrNode property that @Morstis used in the factory.create function. I did not manage to bind the created CompoentRef to the HTML element, therefore this approach did not work for me in Angular 14. The approach using angular elements mentioned earlier worked nevertheless.

neuged commented 1 year ago

@lowickert or whoever is interested: The solution by @Morstis works (again?) by now with some modifications. We can use the function createComponent, importable directly from core, no need for ViewContainerRef. The PopupService above could be written as:

import {ApplicationRef,createComponent, EnvironmentInjector, Injectable, Injector} from '@angular/core';
import {PopupComponent} from '../components/popup.component';

@Injectable({ providedIn: 'root' })
export class PopupService {

  constructor(
    private injector: Injector,
    private environmentInjector: EnvironmentInjector,
    private applicationRef: ApplicationRef
  ) { }

  returnPopUpHTML(popupData: MapPopup): HTMLElement  {
    const element = document.createElement("div")
    const component = createComponent(PopupComponent, {
      elementInjector: this.injector,
      environmentInjector: this.environmentInjector,
      hostElement: element
    });
    this.applicationRef.attachView(component.hostView);
    component.instance.popup = popupData;
    return element;
  }
}
johanndev commented 1 year ago

Works like a charm!

Thanks @Morstis and @neuged!

KonWys01 commented 1 year ago

@Morstis and @neuged solution also works great on newest Angular v16.2.12 Thank you guys <3

jakebeinart commented 11 months ago

@neuged, I tried this approach (Angular 14.3.0) and it seems to work, but it also seems to create a memory leak when removing and redrawing markers.

In the Chrome heap snapshots you can see the amount of Detached HTMLDivElements rising when markers are removed and redrawn. image

Anyone else run into this? It's creating some performance problems when removing and redrawing a sizeable number of markers.

neuged commented 11 months ago

@jakebeinart Yes, if you redraw the markers, it is probably necessary to manually destroy the refs you created once you do not need them anymore, you might also want to remove the HTML elements.

We actually ended up collecting all the components/elements in two arrays in the service and removing them in a cleanup() method you can call when you need to redraw the map or in an onDestroy.

class PopupService {
  private elements: HTMLElement[] = [];
  private refs: ComponentRef<unknown>[] = [];

  returnPopUpHTML(popupData: MapPopup): HTMLElement  {
    ...
    this.refs.push(component);
    this.elements.push(element);
    return element;
  }

  cleanup(): void {
    this.refs.splice(0).forEach((ref) => ref.destroy());
    this.elements.splice(0).forEach((element) => element.remove());
  }
}

(For a full example with Marker and Popup components, you can see this gist btw)

jwallmu commented 6 months ago

We are using a version of the solution @Morstis and @neuged suggested with one major difference: We have images in our popups and potentially a lot of markers with popups in a map view. So we want to avoid loading any images before the popup is shown for the first time.

Instead of marker(coordinates).bindPopup(popupEl).addTo(this.map);

we do something like this

const m = marker(coordinates).addTo(this.mapMarkerLayer);

m.on('mouseover', () => {
  let popupElement: HTMLElement | undefined;
  if (!m.getPopup()) {
    popupElement = this.getMarkerPopup(location);
   }
  if (popupElement) {
    m.bindPopup(popupElement);
  }
  if (!!m.getPopup()) {
    m.openPopup();
  }
});

Unfortunately, this breaks the change detection. Initial hover over the marker shows only a small white box and only clicking somewhere else renders the popup correctly. After this initial hickup, the popup works as intended (with button clicks etc).

Adding createdComponent.changeDetectorRef.detectChanges(); in PopupService before returning the element fixes the initial change detection aka. (most) contents of the popup are rendered on first hover, but breaks any further change detection completely (e.g. buttons are no longer working).

Does anyone know of this problem and can provide a possible solution?