1

I'm working with a Leaflet map that displays marker data based on a MongoDB query. The query results are saved into a variable (I know, bad form for large volumes of info but okay if you only have ~25 pieces) as an array, and then I've iterated over that variable and it's stored information using a for loop to create my leaflet map markers and populate their popups with the information specific to each entry. This part works great.

    this.autorun(function(){

    fsqresults = FsqResults.find().fetch({});

    container = $('<div />');

    for (i=0; i < fsqresults.length; i++) {

        marker = L.marker([fsqresults[i].geometry.coordinates[1], fsqresults[i].geometry.coordinates[0]], {icon: violetIcon}).addTo(mymap);

        container.html("<b>" + "Name: " + "</b>" + fsqresults[i].properties.name + "<br>" +
                        "<b>" + "Address: " + "</b>" + fsqresults[i].properties.address + "<br>" +
                        "<b>" + "Checkins: " + "</b>" + fsqresults[i].properties.checkIns + "<br>" +
                        "<b>" + "Users: " + "</b>" + fsqresults[i].properties.usersCount + "<br>" +
                        "<b>" + "Tips: " + "</b>" + fsqresults[i].properties.tips + "<br>");

        marker.bindPopup(container[0]);

    } // end for loop

For each marker, there is a button to log a "checkin" event to another Mongo collection to house the checkin entries. The button fires an event successfully, and creates the entry into the second database, but will not bind the dynamically populated data to the entry so I can see which marker the user has clicked on.

    container.append($('<button class="btn btn-sm btn-outline-primary js-checkin">').text("Check In"));

    container.on('click', '.js-checkin', function() {

        var currentVenue = fsqresults[i].properties.name;
        console.log(currentVenue);

        console.log("You clicked the button!");
        if (!Meteor.user()) {
            alert("You need to login first!");
        }

        if (Meteor.user()) {
            console.log("Meteor User Verified");
            Checkins.insert({user: Meteor.user(), name: currentVenue});
        }
    });

}); // end this.autorun

The console tells me that currentVenue is undefined. I know this has something to do with the fact that fsqresults is a dynamically populated variable. I have tried to find ways to "solidify" the information in it (i.e. - creating a second variable with an empty array, pushing the data from fsqresults into it, and then having the markers iterate over that variable) but that hasn't worked as the MongoDB query results, despite being in an array format themselves, will not push or concat into the variable with an empty array successfully.

I've been searching for an answer to this problem and I'm coming up short. I'm lost; is there any other solution which could be staring me in the face?

Some things to note: All of this code lives in the Template.map.onRendered() function. Leaflet has scoping issues if I delegate the code into helpers and events, which is why I haven't created a {markers} template and just done {{#each markers}} over it for iteration. Therefore I am relegated to jQuery style coding for creating DOM elements and firing event triggers. The code above is wrapped in a this.autorun function to ensure it does indeed run upon map rendering. I don't think this is the issue (although one can never rule it out!).

  • 1
    `var currentVenue = fsqresults[i].properties.name;` You're using `i` outside of the for-loop - what did you expect it to be? If you can't use template events, could you not set a `data-` attribute to the button with the value you are looking for? (with the caveat that it'd be better to figure out how to get templates/helpers/events working the proper way even if it takes longer - perhaps those scoping issues are what we could solve as part of your problem) – chazsolo Jun 04 '18 at 17:35
  • 2
    I would be really interested in the "scoping" issues. For now I got literally every non-Meteor / non-Blaze package integrated into my Blaze templates. So I think yours will also be possible. – Jankapunkt Jun 04 '18 at 17:37
  • Chazsolo I agree - outside of the for loop it wouldn't work. I tried it without the i, using a static number instead between the square brackets, but still no dice. I also tried omitting the brackets and just using `fsqresults.properties.name`. Setting a data attribute sounds like a workaround, as it would statically bind the data to the button to be submitted. I'll have to look up some examples of setting data attribute to fully understand in context what data I need to bind and how (unless you have an example that you could show me?). – Sarah Mattar Jun 04 '18 at 17:52
  • Jankapunkt the discrepancies of scope between events and helpers is that if the variable containing the map instance is created in the helpers (or even in onCreated or onRendered), creating a function in events will return an error that "mymap is undefined" because it can't see the variable that the map is stored in. If I list the variable twice (once in events and once in helpers), it will give me an error that the map is already defined. I've tried declaring it as `var mymap`, `const mymap` and even just `mymap` and it is same issue. – Sarah Mattar Jun 04 '18 at 17:58

2 Answers2

1

As pointed out in the question comments, you have a scope issue with your i index iterator, and there should be no technical problem in integrating Leaflet with Meteor (although with Blaze that may not be totally trivial nor interesting).

1. Iteration scope issue

The console tells me that currentVenue is undefined.

That is because you try to access fsqresults[i].properties.name in your container.on('click' event listener / callback, which will be called on user click, i.e. after your for loop is complete, hence your i index iterator variable will be equal to fsqresults.length.

You are in the case of Example 6 of the accepted answer of: How do JavaScript closures work?

2. Leaflet integration with Meteor (Blaze)

Since you mention having tried helpers, events, and {{#each markers}}, I assume you use Blaze as your Meteor rendering engine.

While React-Leaflet and Vue2Leaflet indeed offer the possibility to use a kind of "<Marker>" component (same for other types of Leaflet Layer), the latter is only for template declaration purpose, i.e. it does not directly render any DOM / HTML, but only calls some Leaflet methods, which will be in charge of manipulating the DOM. As stated on React-Leaflet limitations:

The components exposed are abstractions for Leaflet layers, not DOM elements.

Side note: interesting to see that angular-leaflet-directive and @asymmetrik/ngx-leaflet did not fall into the same temptation and sticked to JS declaration of Leaflet layers.

Therefore trying to create a Template.Marker (used as {{> Marker}}) in Blaze might be overkill, as you would basically just call some Leaflet factories (like L.marker) within your Template.Marker.onCreated (and needing to access somehow the parent map object to add your Marker into…), without rendering any DOM node yourself (i.e. you would have an HTML file with empty <template name="Marker"></template>).

While we forget about a Marker template in Blaze (as you have already done), we can still leverage Blaze events management to handle user clicks in your Leaflet Popup. For that, we would need a few Blaze features, that I admit could benefit being better documented:

  1. We can attach arbitrary JS data to our template instance.
  2. Template events are delegated, hence we do not need to attach them to each <button> before hand.
  3. We can easily access the template instance in event handlers (as the 2nd argument of the event listener).

2.1. Attaching arbitrary JS data to our template instance

As stated in the Template Instances API:

[…] you can assign additional properties of your choice to the object. Use the onCreated and onDestroyed methods to add callbacks performing initialization or clean-up on the object.

Therefore you can store your fsqresults on your Template instance, so that you can access it later on (typically in your event listener):

Template.myTemplate.onRendered(function () {
  this.autorun(() => { // Using an arrow function to keep the same `this`, but you could do `const self = this` beforehand.
    const fsqresults = this.fsqresults = FsqResults.find().fetch();
  });
});

But since we want to access specific Features later on, it might be more interesting to convert fsqresults to a dictionary. Since your ID seems to be feature.properties.name, you could do:

Template.myTemplate.onCreated(function () {
  this.autorun(() => {
    const fsqresults = this.fsqresults = FsqResults.find().fetch();
    const markersDict = this.markersDict = {};

    L.geoJSON(fsqresults, {
      pointToLayer(feature, latlng) {
        const props = feature.properties;
        const markerName = props.name;

        // Save a direct reference to the Feature data,
        // using the `markerName` as key (ID).
        markersDict[markerName] = feature;

        // Store the `markerName` in the button `dataset`
        // (i.e. as a `data-` attribute),
        // as already suggested in the question comments,
        // so that we can easily retrieve the ID / key
        // of the Marker data associated with the button the user clicked on.
        return L.marker(latlng).bindPopup(`
          <p>${markerName}</p>
          <button role="popupClick" data-marker-name="${markerName}">
            Popup action
          </button>
        `);
      },
    });
  });
});

2.2. Template event handler delegation

As stated on the Blaze Overview Details:

DOM engine […] which features […] event delegation

(sorry there does not seem to be any other mention of this feature in the official doc… let me know if you find a better one!)

Therefore, as long as we create a Template event handler with the appropriate selector, we do not need to attach the event listener on each button, which anyway we may not create as a Node but leave it as String passed to Leaflet .bindPopup (as done in the above code sample).

For example:

Template.myTemplate.events({
  // Even if the `<button role="popupClick">` are not DOM nodes yet
  // (because Leaflet will create them from the HTML String
  // only when the user opens the popup by clicking on the Marker),
  // the "click" event will bubble up to the template instance,
  // which will call this event handler if it matches the selector.
  'click button[role="popupClick"]'() {
    console.log('clicked on a button that has been built in a Leaflet Popup');
  }
});

2.3. Access the template instance, and our Feature data

The Blaze event handler are called with an extra 2nd argument, which is the current template instance:

The handler function receives two arguments: event, an object with information about the event, and template, a template instance for the template where the handler is defined.

Therefore in our case we can easily retrieve the markersDict variable that we have defined in onCreated, and use it to retrieve the exact Marker's Feature data associated with the button the user clicked on:

Template.myTemplate.events({
  'click button[role="popupClick"]'(event, templateInstance) {
    const button = event.currentTarget;
    const markerName = button.dataset.markerName;

    const markerFeature = templateInstance.markersDict[markerName];

    // Do something with `markerFeature`…
    console.log(markerFeature);
  }
});

If you only need the property name, then you could even skip step 2.1 and directly use the markerName string retrieved from the <button> dataset.

ghybs
  • 47,565
  • 6
  • 74
  • 99
  • Holy crap - thank you for this detailed response! You're right, I'm running Blaze as the rendering engine and Meteor as the framework. I'm going to have to take some time to look through and digest this and then I will let you know how I make out with it. Right now I've got things working (the roadblock above was actually scope based) and although it's hack-y, it is functional and that is acceptable for course project submission this weekend. I do have a long list of things I want to fix so they work in a more traditional, robust manner but that's going to have to be next week's job! :) – Sarah Mattar Jun 07 '18 at 20:48
0

I've come up with a solution to the first part of my issue - at first a javascript closure/scope issue to the inner and outer function scopes. I spent about 2 days wrapping my head around this SO answer: the concept of using the first for loop to produce individual instances of the function (if this were a play, the first for loop would "set the stage" for the show), and using the second for loop to execute each instance of the function ("lights, camera, action!").

I also decided that I could maintain scope if I declared my variables inside the first for loop - but I still had this issue of it only pulling the last value. Then I tried simply redeclaring my variables as constants. To my surprise, using const allowed me to write each instance to each map marker, and I could reliably access the correct iteration of the data upon each correspondent map marker! So no need for a second for loop.

this.autorun(function(){

    fsqresults_fetch = FsqResults.find().fetch({});

    // console.log(fsqresults_fetch);

    for (i = 0; i < fsqresults_fetch.length; i++) {

        container = $('<div />');

        const fsq_marker = L.marker([fsqresults_fetch[i].geometry.coordinates[1], fsqresults_fetch[i].geometry.coordinates[0]], {icon: blueIcon}).addTo(mymap);
        const fsq_venueAddress = fsqresults_fetch[i].properties.address;
        const fsq_venueName = fsqresults_fetch[i].properties.name;
        const fsq_geometry = {type: "Point", 
                    coordinates: [fsqresults_fetch[i].geometry.coordinates[0], fsqresults_fetch[i].geometry.coordinates[1]]};

        container.html("<b>" + "Name: " + "</b>" + fsqresults_fetch[i].properties.name + "<br>" +
                    "<b>" + "Address: " + "</b>" + fsqresults_fetch[i].properties.address + "<br>");

        container.append($('<button class="btn btn-sm btn-outline-primary" id="js-checkin">').text("Check In"));

        fsq_marker.bindPopup(container[0]);

        container.on('click', '#js-checkin', function() {

            console.log("You clicked the button!");
                if (!Meteor.user()) {
                    alert("You need to login first!");
                }

                if (Meteor.user()) {
                    console.log("Meteor User Verified");
                    Checkins.insert({type: "Feature", geometry: fsq_geometry, properties: {name: fsq_venueName, address: fsq_venueAddress, user: Meteor.user()}});
                }
            }); //end container.on

    } //end for loop

}); //end this.autorun

As I said in the comment on the last response, it's a bit hack-y, but functional enough to do the job successfully.

Now what I'm really curious to try is the solution that @ghybs posted so I have my events grouped and firing as Blaze is supposed to work!