Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom Components in marker popups. #178

Open
SimonSch opened this issue Aug 3, 2018 · 30 comments
Open

Custom Components in marker popups. #178

SimonSch opened this issue Aug 3, 2018 · 30 comments

Comments

@SimonSch
Copy link

SimonSch commented Aug 3, 2018

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

@zellb
Copy link

zellb commented Oct 3, 2018

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
Copy link

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
Copy link

throwawaygit000 commented Oct 15, 2018

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
Copy link

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

Should probably take that out for a more generic example.

@throwawaygit000
Copy link

throwawaygit000 commented Oct 15, 2018

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
Copy link

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

@zachatrocity
Copy link

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
Copy link
Contributor

mcurtis22 commented Oct 16, 2018

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
Copy link

throwawaygit000 commented Oct 16, 2018

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
Copy link

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

@zachatrocity
Copy link

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

@throwawaygit000
Copy link

@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
Copy link

can someone provide a solution for Angular 4/5?

@mcurtis22
Copy link
Contributor

mcurtis22 commented Nov 6, 2018

@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
Copy link

jziggas commented Sep 9, 2019

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
Copy link

+1

@zachatrocity
Copy link

@JamesHearts see my comment above:

#178 (comment)

@zachatrocity
Copy link

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

@newmanw
Copy link

newmanw commented Jul 21, 2020

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

@tuscaonline
Copy link

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

@abhishekvaze26
Copy link

@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
Copy link

Morstis commented May 29, 2022

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
Copy link

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

@lowickert
Copy link

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
Copy link

neuged commented May 2, 2023

@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
Copy link

Works like a charm!

Thanks @Morstis and @neuged!

@KonWys01
Copy link

KonWys01 commented Nov 5, 2023

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

@jakebeinart
Copy link

@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
Copy link

neuged commented Nov 28, 2023

@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
Copy link

jwallmu commented Apr 26, 2024

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests