12

I've been a long Angular 1.x user and now I'm working on make a new app using Angular 4. I still don't grasp most of the concepts but I finally have something working really nice. However, I'm having an issue where I need to display an Angular 4 component (although in 1.x I just used directives) inside a Marker's popup using Leaflet.

Now, in Angular 1.x I could just use $compile against a template with the directive inside it (`<component>{{ text }}</component>`) with buttons and such and it would work, but Angular 4 is totally different with its AoT thing and compiling at runtime seems to be really hard and there's no easy solution for it.

I asked a question here and the author says I could use a directive. I'm not sure if this is the correct approach or even how to mix my own code with his proposed solution... so I made a small npm-based project with Angular 4 and Leaflet already set up in case you know how to help me or want to give it a try (I greatly appreciate it!). I've been banging my head around this for maybe a week and I'm really tired of trying a lot of alternatives without success :(

Here's the link of my repo in GitHub: https://github.com/darkguy2008/leaflet-angular4-issue

The idea is to spawn PopupComponent (or anything similar to it) inside a Marker, code which you can find in src/app/services/map.service.ts, line 38.

Thanks in advance! :)

EDIT

I managed to solve it :) see the marked answer for details, or this diff. There are a few caveats and the procedure for Angular 4 and Leaflet is a bit different and it doesn't require as much changes: https://github.com/darkguy2008/leaflet-angular4-issue/commit/b5e3881ffc9889645f2ae7e65f4eed4d4db6779b

I've also made a Custom Compile Service out of this solution explained here and uploaded to the same GitHub repo. Thanks @yurzui! :)

DARKGuy
  • 843
  • 1
  • 12
  • 34
  • Have you tried https://stackoverflow.com/questions/40922224/angular2-component-into-dynamicaly-created-element ? – ghybs Jul 14 '17 at 03:57
  • Hello @ghybs, I did a few days ago but I didn't have much success. Guess what, I was trying to add the solution into a Service. It doesn't seem to work with Services but with Components instead. I have made a commit to the GitHub repo with the solution... but I don't think it'd be fair to answer my own question since you did provide a key link with information. Do you want to enter an answer so I can mark it as such, or shall I answer my own question? THANKS! – DARKGuy Jul 14 '17 at 05:27
  • Well, way to go @yurzui, I could've answered my own question and at least get a few rep points and clarify stuff for next readers, now I can't aside from just editing the main question. Awesome. Leaflet != Google Maps, just for future reference... – DARKGuy Jul 14 '17 at 15:24
  • 1
    @DARKGuy Sorry, just do it – yurzui Jul 14 '17 at 15:27
  • Oh haha, I thought this change wasn't reversible, thanks @yurzui! Will do :D – DARKGuy Jul 14 '17 at 15:33

2 Answers2

15

Alright, so thanks to @ghybs's suggestion I gave that link another try and managed to solve the issue :D. Leaflet is a bit different from Google Maps (it's also shorter) and the proposed solution there could be a bit smaller and easier to understand, so here's my version using Leaflet.

Basically, you need to put your popup component in the main app module's entryComponents field. The key stuff is in m.onclick(), there, we create a component, render it inside a div and then we pass that div's content to the leaflet popup container element. A bit tricky, but it works.

I got some time and converted this solution to a new $compile for Angular 4. Check the detailed info here. Thanks @yurzui! :)

This is the core code... The other stuff (css, webpack, etc.) is in the same repo as the OP, simplified into few files: https://github.com/darkguy2008/leaflet-angular4-issue but you just need this example to make it work:

import 'leaflet';
import './main.scss';
import "reflect-metadata";
import "zone.js/dist/zone";
import "zone.js/dist/long-stack-trace-zone";
import { BrowserModule } from "@angular/platform-browser";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { Component, NgModule, ComponentRef, Injector, ApplicationRef, ComponentFactoryResolver, Injectable, NgZone } from "@angular/core";

// ###########################################
// App component
// ###########################################
@Component({
    selector: "app",
    template: `<section class="app"><map></map></section>`
})
class AppComponent { }

// ###########################################
// Popup component
// ###########################################
@Component({
    selector: "popup",
    template: `<section class="popup">Popup Component! :D {{ param }}</section>`
})
class PopupComponent { }

// ###########################################
// Leaflet map service
// ###########################################
@Injectable()
class MapService {

    map: any;
    baseMaps: any;
    markersLayer: any;

    public injector: Injector;
    public appRef: ApplicationRef;
    public resolver: ComponentFactoryResolver;
    public compRef: any;
    public component: any;

    counter: number;

    init(selector) {
        this.baseMaps = {
            CartoDB: L.tileLayer("http://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", {
                attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="http://cartodb.com/attributions">CartoDB</a>'
            })
        };
        L.Icon.Default.imagePath = '.';
        L.Icon.Default.mergeOptions({
            iconUrl: require('leaflet/dist/images/marker-icon.png'),
            shadowUrl: require('leaflet/dist/images/marker-shadow.png')
        });
        this.map = L.map(selector);
        this.baseMaps.CartoDB.addTo(this.map);
        this.map.setView([51.505, -0.09], 13);

        this.markersLayer = new L.FeatureGroup(null);
        this.markersLayer.clearLayers();
        this.markersLayer.addTo(this.map);
    }

    addMarker() {
        var m = L.marker([51.510, -0.09]);
        m.bindTooltip('Angular 4 marker (PopupComponent)');
        m.bindPopup(null);
        m.on('click', (e) => {
            if (this.compRef) this.compRef.destroy();
            const compFactory = this.resolver.resolveComponentFactory(this.component);
            this.compRef = compFactory.create(this.injector);

            this.compRef.instance.param = 0;
            setInterval(() => this.compRef.instance.param++, 1000);

            this.appRef.attachView(this.compRef.hostView);
            this.compRef.onDestroy(() => {
                this.appRef.detachView(this.compRef.hostView);
            });
            let div = document.createElement('div');
            div.appendChild(this.compRef.location.nativeElement);
            m.setPopupContent(div);
        });
        this.markersLayer.addLayer(m);
        return m;
    }
}

// ###########################################
// Map component. These imports must be made
// here, they can't be in a service as they
// seem to depend on being loaded inside a
// component.
// ###########################################
@Component({
    selector: "map",
    template: `<section class="map"><div id="map"></div></section>`,
})
class MapComponent {

    marker: any;
    compRef: ComponentRef<PopupComponent>;

    constructor(
        private mapService: MapService,
        private injector: Injector,
        private appRef: ApplicationRef,
        private resolver: ComponentFactoryResolver
    ) { }

    ngOnInit() {
        this.mapService.init('map');
        this.mapService.component = PopupComponent;
        this.mapService.appRef = this.appRef;
        this.mapService.compRef = this.compRef;
        this.mapService.injector = this.injector;
        this.mapService.resolver = this.resolver;
        this.marker = this.mapService.addMarker();
    }
}

// ###########################################
// Main module
// ###########################################
@NgModule({
    imports: [
        BrowserModule
    ],
    providers: [
        MapService
    ],
    declarations: [
        AppComponent,
        MapComponent,
        PopupComponent
    ],
    entryComponents: [
        PopupComponent
    ],
    bootstrap: [AppComponent]
})
class AppModule { }

platformBrowserDynamic().bootstrapModule(AppModule);
DARKGuy
  • 843
  • 1
  • 12
  • 34
  • 1
    thank you so much for this. Using Angular2/4 with MapboxGL. Angular needs a f***ing compile function – ohjeeez Sep 21 '17 at 00:17
  • 1
    Why not create a separate function for loading the component; which returns the HTMLElement directly into the marker.bindPopup function? I'm a little confused why you're setting bindPopup to null and handling inside of .on('click'). Couldn't you just do something like this: m.bindPopup(this.loadDynamicComponent()); – Douglas Tober Mar 07 '18 at 03:32
  • 1
    I agree with @DouglasTober, not setting the bindPopup to null results in a more stable function(I had some strange results when setting it first to null). But other than that thanks for the answer! It really saved me! – Sim_on Jul 31 '18 at 21:33
  • 1
    hope you guys are still around. I was using this solution to generate a popup. It works "okay" for a single popup. However what if I wanted to do multiple popups. Also could someone provide code as to what it would look like if the code was not in a service but int he component itself? Or does it NEED to be in a service? – James Mak Aug 10 '18 at 16:43
  • I'm trying to do the same thing as well but with multiple popups as well @JamesMak .. Does anyone know how to get this working if you are constantly changing the component? I'm getting issues where previous popup data appears even though I click on a different point that has different metadata – fairlyMinty Sep 08 '21 at 23:45
3
  1. You will need to create a component for the popup content in case you don't have it already. Lets assume it is called MycustomPopupComponent.
  2. Add your component to the app.module.ts in the entry components array. This is needed when creating components dynamically:
   entryComponents: [
      ...,
      MycustomPopupComponent
   ],
  1. In your screen, add these two dependencies to the constructor:
constructor(
    ...
    private componentFactoryResolver: ComponentFactoryResolver,
    private injector: Injector
) {
  1. Now in that screen we can define a function that creates the component dynamically.
private createCustomPopup() { 
    const factory = this.componentFactoryResolver.resolveComponentFactory(MycustomPopupComponent);
    const component = factory.create(this.injector);

    //Set the component inputs manually 
    component.instance.someinput1 = "example";
    component.instance.someinput2 = "example";

    //Subscribe to the components outputs manually (if any)        
    component.instance.someoutput.subscribe(() => console.log("output handler fired"));

    //Manually invoke change detection, automatic wont work, but this is Ok if the component doesn't change
    component.changeDetectorRef.detectChanges();

    return component.location.nativeElement;
}
  1. Finally, when creating the Leaflet popup, pass that function as parameter to bindPopup. That function also accepts a second parameter with options.:
const marker = L.marker([latitude, longitude]).addTo(this.map);
marker.bindPopup(() => this.createCustomPopup()).openPopup();
Mister Smith
  • 27,417
  • 21
  • 110
  • 193