Open SimonSch opened 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);
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.
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?
It's this library: https://esri.github.io/esri-leaflet/
Should probably take that out for a more generic example.
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
@zachatrocity Are you going to provide a more generic example here?
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;
}
I have an example of having markers themselves as Angular components. Here is that repo. It's pretty similar to the code provided here.
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!
@zachatrocity @mcurtis22 Have you tried to use reactive forms on your custom element ?
@lourencoGit like inside the custom popup component? No i haven't. What issues are you having?
@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();
can someone provide a solution for Angular 4/5?
@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.
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 🤷♂
+1
@JamesHearts see my comment above:
https://github.com/Asymmetrik/ngx-leaflet/issues/178#issuecomment-430001326
@reblace pretty sure this issue could be closed with some documentation.
It's not clear what fires the 'closed' event.
on angular 12 I need to install @angular/elements with ng add @angular/elements
@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 ?
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:
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.
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('');
}
}
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;
}
}
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);
}
Thank you for sharing your solution @Morstis ! It works flawlessly with Angular v14 and the solution is super nice and elegant.
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.
@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;
}
}
Works like a charm!
Thanks @Morstis and @neuged!
@Morstis and @neuged solution also works great on newest Angular v16.2.12 Thank you guys <3
@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.
Anyone else run into this? It's creating some performance problems when removing and redrawing a sizeable number of markers.
@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)
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?
It would be great if there is a possibility to customize the marker popups with custom angular components.