embeepea
embeepea

Reputation: 667

how to retain (state of) non-reactive DOM elements in meteorjs?

I'm trying to understand options for using a stateful / non-reactive DOM component in a Meteor template, in a way that allows the component to retain its state as Meteor updates the DOM.

One specific example involves Leaflet.js: I have an application that includes a Leaftlet map, and I want the user to be able to switch between a display of the map, and some other content. The map is interactive --- the user can pan and zoom in the map --- and I'd like the current zoom/pan state of the map to be retained if/when the user switches away from the map to other content, and then back to the map.

My first attempt at doing this is to put the map in one template, and the other content in another template, and use conditional logic in the containing template to determine which template is rendered:

HTML:

<body>
  <div>
    <input type="submit" id="mapbutton" value="Display Map">
    <input type="submit" id="otherbutton" value="Display Other Stuff">
  </div>
  {{#if showmap}}
    {{> map}}
  {{else}}
    {{> otherstuff}}
  {{/if}}
</body>

<template name="map">
  <div id="map"></div>
</template>

<template name="otherstuff">
  <p>Here is some other stuff</p>
</template>

JS:

Template.map.rendered = function() {
    var map = L.map('map', {
        doubleClickZoom: false
    }).setView([38.0, -98.0], 5);

    L.tileLayer('https://{s}.tiles.mapbox.com/v3/{id}/{z}/{x}/{y}.png', {
        maxZoom: 18,
        id: 'examples.map-i875mjb7'
    }).addTo(map);
};

Session.setDefault("showmap", true);

Template.body.helpers({
    "showmap" : function() { 
        return Session.get("showmap");
    }
});

Template.body.events({
    "click input#mapbutton": function() {
        Session.set("showmap", true);
    },
    "click input#otherbutton": function() {
        Session.set("showmap", false);
    }
});

The problem with this approach is that every time the user switches to the map display, Meteor re-renders the map template, creating a new Leaflet map (and associated DOM component), which is initialized from scratch. This means that whatever pan and/or zoom settings the user had previously made in the map are lost. It also involves a short delay in the display while the Leaflet map is constructed. I'd like the Leaflet map to get created one time only, the first time it is displayed, and then saved somewhere off-screen when the user swiches to other content, so that it can be immediately swapped back in later, without incurring the construction delay, and retaining its previous pan/zoom state.

I know that one way to accomplish this would be to design my HTML templates to keep the map div in the DOM when switching displays, and to use CSS to hide it when necessary. Something like the following:

HTML:

<body>
  <div>
    <input type="submit" id="mapbutton" value="Map">
    <input type="submit" id="otherbutton" value="Other Stuff">
  </div>
  <div id="map" class="{{#if showmap}}visible{{else}}hidden{{/if}}"></div>
  {{#if showother}}
    {{> otherstuff}}
  {{/if}}
</body>

<template name="otherstuff">
  <p>Here is some other stuff</p>
</template>

JS:

Template.body.rendered = function() {
    var map = L.map('map', {
        doubleClickZoom: false
    }).setView([38.0, -98.0], 5);

    L.tileLayer('https://{s}.tiles.mapbox.com/v3/{id}/{z}/{x}/{y}.png', {
        maxZoom: 18,
        id: 'examples.map-i875mjb7'
    }).addTo(map);
};

Session.setDefault("showmap", true);

Template.body.helpers({
    "showmap" : function() { 
        return Session.get("showmap");
    },
    "showother" : function() { 
        return !Session.get("showmap");
    }
});

Template.body.events({
    "click input#mapbutton": function() {
        Session.set("showmap", true);
    },
    "click input#otherbutton": function() {
        Session.set("showmap", false);
    }
});

CSS:

#map.visible {
  display: block;
}

#map.hidden {
  display: none;
}

This works fine for this simple example, but in reality my application (and the associated templates and resulting DOM) is much more complex. What I REALLY want is to be able to move the map component around arbitrarily in the DOM. For example, depending on the context, the map might appear inside a table, or full-screen, or not at all, and I'd like to retain the map's internal state between all of these contexts. Using a Meteor template for the map with conditional logic that determines where it is included seems like a natural way to structure this kind of thing, but that returns to the above problem that every time the map template is rendered, the map is rebuilt from scratch and reset to its initial state.

Is there a way to tell Meteor to "cache" its rendering of a particular template, and to hang on to the associated DOM element, so that subsequent times when that template is used in the rendering of other content, the previously constructed DOM element is used? I realize this goes against the grain of the reactive approach, but this is a situation where I'm trying to use a complex non-reactive component, and it seems like support for such things could be useful in many contexts.

This issue isn't specific to Leaftlet.js, by the way. I have other non-reactive, stateful components that I would like to use in my Meteor application, and I'd love to find a graceful way to solve this problem for all of them.

Does anyone know if there is a way to do this, or have ideas for a better approach?

Thanks!

Upvotes: 1

Views: 121

Answers (2)

embeepea
embeepea

Reputation: 667

Thanks @Billybobbonnet. Your comment to keep the values you need and re-use them when rendering the template gave me the idea to try this:

HTML:

<body>                                                                                                      
  <div>                                                                                                     
    <input type="submit" id="mapbutton" value="Map">                                                        
    <input type="submit" id="otherbutton" value="Other Stuff">                                              
  </div>                                                                                                    
  {{#if showmap}}                                                                                           
    {{> map}}                                                                                               
  {{else}}                                                                                                  
    {{> otherstuff}}                                                                                        
  {{/if}}                                                                                                   
</body>                                                                                                     

<template name="map">                                                                                       
  <div id="mapcontainer">                                                                                   
    <div id="map"></div>                                                                                    
  </div>                                                                                                    
</template>                                                                                                 

<template name="otherstuff">                                                                                
  <p>Here is some other stuff</p>                                                                           
</template> 

JS:

var $mapdiv = undefined;                                                                                    
Template.map.rendered = function() {                                                                        
    if ($mapdiv === undefined) {                                                                            
        // if this is the first time the map has been rendered, create it                                   
        var map = L.map('map', {                                                                            
            doubleClickZoom: false                                                                          
        }).setView([38.0, -98.0], 5);                                                                       
        L.tileLayer('https://{s}.tiles.mapbox.com/v3/{id}/{z}/{x}/{y}.png', {                               
            maxZoom: 18,                                                                                    
            id: 'examples.map-i875mjb7'                                                                     
        }).addTo(map);                                                                                      
        // and hang on to the map's div element for re-use later                                            
        $mapdiv = $("#map");                                                                                
    } else {                                                                                                
        // map has already been created, so just empty out the container                                    
        // and re-insert it                                                                                 
        $("#mapcontainer").empty();                                                                         
        $("#mapcontainer").append($mapdiv);                                                                 
    }                                                                                                       
};                                                                                                          

Session.setDefault("showmap", true);                                                                        

Template.body.helpers({                                                                                     
    "showmap" : function() {                                                                                
        return Session.get("showmap");                                                                      
    }                                                                                                       
});                                                                                                         

Template.body.events({                                                                                      
    "click input#mapbutton": function() {                                                                   
        Session.set("showmap", true);                                                                       
    },                                                                                                      
    "click input#otherbutton": function() {                                                                 
        Session.set("showmap", false);                                                                      
    }                                                                                                       
});                                                                                                         

This seems to be working well. It feels a little kludgy, but I like the fact that it lets me put the map in a template which I can use anywhere, just like any other template, and yet the map is only created once.

Upvotes: 0

Billybobbonnet
Billybobbonnet

Reputation: 3226

I don't think you can keep a rendered item ready for hiding/displaying without any re-rendering, except if you use CSS.

Blaze (the component taking care of rendering templates) can't do that (yet). Have a look at this topic where they basically say the same, but it comes from a meteor dev: https://github.com/meteor/meteor/issues/4351

Either you rely on CSS, either you keep the values you need in for example a reactive dictionary and use them when you render your map template.

Upvotes: 1

Related Questions