GoJS Leaflet layer zooming

I’m trying to create a custom Leaflet layer that shall enable the usage of the GoJS library. I’ve manged most of my main problems such as:

  • Reposition the diagram based on the layer translation
  • Draw elements outside the diagrams viewport
  • Reposition diagram nodes when the map is beeing draged

But I’m stuck with the problem of resizing the the nodes while zooming. I’m calculating a scaleFactor and change the location of the nodes. The approach works so far, but as far as the map is zoomed out to level 0 and the user zooms back into the location is calculated incorrect. The location of the y-axis is completly wrong. I’ve also set up a fiddle so you can easily play arroud with the source.

(function () {
    if (typeof(L) !== 'undefined' && typeof(go) !== 'undefined') {
        L.GoJsLayer = L.Class.extend({
            includes: [L.Mixin.Events],

            options: {
                "animationManager.isEnabled": false,
                allowZoom: false,
                allowHorizontalScroll: false,
                hasHorizontalScrollbar: false,
                allowVerticalScroll: false,
                hasVerticalScrollbar: false,
                padding: 0
            },

            initialize: function (options) {
                L.setOptions(this, options);
            },

            onAdd: function (map) {
                this._map = map;

                if (!this.diagram) {
                    this._initDiagram();
                }

                this._map
                    .on('viewreset', this._reset, this)
                    .on('moveend', this._updateViewport, this);
            },

            onRemove: function (map) {
                this._map
                    .getPanes()
                    .overlayPane
                    .removeChild(this._el);

                this._map
                    .off('moveend', this._updateViewport, this);
            },

            addTo: function (map) {
                map.addLayer(this);

                return this;
            },

            _initDiagram: function () {
                this._initElement();
                this._viewport = this._map.getBounds();

                this.diagram = new go.Diagram(
                    this._el.getAttribute('id')
                );
                this._setFixedBounds();
                this.diagram.setProperties(this.options);
                
                this._setCanvas();
            },

            _initElement: function () {
                var size = this._map.getSize();

                this._el = L
                    .DomUtil
                    .create('div', 'leaflet-layer');
                this._el.setAttribute(
                    'id',
                    'leaflet-gojs-diagram-' + L.Util.stamp(this)
                );
                this._el
                    .setAttribute('style', this._getElementStyle());
                
                L.DomUtil.addClass(this._el, 'leaflet-zoom-hide');

                this._map
                    .getPanes()
                    .overlayPane
                    .appendChild(this._el);
            },

            _getElementStyle: function (options) {
                var size = this._map.getSize(),
                    paneTranslation,
                    vpOffset,
                    translation;

                if (this._canvas) {
                    // This is a dirty solution due to the pressure of time.
                    // This needs to be refractored!
                    paneTranslation = L.DomUtil
                        .getStyle(this._map.getPanes()
                            .mapPane, 'transform')
                        .match(/\-?\d+px/g)
                        .map(function (value) {
                            return parseInt(value);
                        });

                    vpOffset = L.point(paneTranslation[0], paneTranslation[1]);

                    translation = L
                        .DomUtil
                        .getTranslateString(vpOffset.multiplyBy(-1));

                    return ''
                        .concat('width: ' + size.x + 'px;')
                        .concat('height: ' + size.y + 'px;')
                        .concat('transform: ' + translation);
                } else {
                    translation = L.DomUtil.getTranslateString(L.point(0, 0));

                    return ''
                        .concat('width: ' + size.x + 'px;')
                        .concat('height: ' + size.y + 'px;')
                        .concat('transform: ' + translation);
                }
            },

            _setFixedBounds: function () {
                var width = parseInt(L.DomUtil.getStyle(this._el, 'width')),
                    height = parseInt(L.DomUtil.getStyle(this._el, 'height'));

                this.diagram.setProperties({
                    fixedBounds: new go.Rect(0, 0, width, height)
                });
            },

            _setCanvas: function () {
                var canvasElements = this._el.getElementsByTagName('canvas');

                if (canvasElements.length) {
                    this._canvas = canvasElements.item(0);
                    return true;
                }

                return false;
            },

            _reset: function () {
            	this._resizeNodes();
            },

            _resizeNodes: function () {
            	var scale = this._map.options.crs.scale,
                	currentScale = scale(this._map.getZoom()),
                    previousScale = scale(this._calcPreviousScale()),
                    scaleFactor = currentScale / previousScale;

				this.diagram.startTransaction('reposition');
                this.diagram.nodes.each(this._resizeNode.bind(this, scaleFactor));
                this.diagram.commitTransaction('reposition');	
            },
            
             _calcPreviousScale: function () {
                var vp = this._viewport,
                    vpNw = vp.getNorthWest(),
                    vpSw = vp.getSouthWest(),
                    mb = this._map.getBounds(),
                    mbNw = mb.getNorthWest(),
                    mbSw = mb.getSouthWest(),
                    currentScale = this._map.getZoom(),
                    previousScale;

                if (mbNw.distanceTo(mbSw) > vpNw.distanceTo(vpSw)) {
                    previousScale = currentScale + 1;
                } else {
                    previousScale = currentScale - 1;
                }

                return previousScale;
            },
            
            _resizeNode: function (scaleFactor, node) {
                node.location = new go.Point(
                    node.location.x * scaleFactor, 
                    node.location.y * scaleFactor
                );
            },
            
            _updateViewport: function (options) {
                this._el.setAttribute('style', this._getElementStyle(options));
                this._setFixedBounds();

                this._repositionNodes();
                this._viewport = this._map.getBounds();
            },
            
            _repositionNodes: function () {
                this.diagram.startTransaction('reposition');
                this.diagram.nodes.each(this._repositionNode.bind(this));
                this.diagram.commitTransaction('reposition');
            },

            _repositionNode: function (node) {
                var vp = this._viewport,
                    vpNw = vp.getNorthWest(),
                    vpOffset = this._map.latLngToContainerPoint(vpNw),
                    vpOffsetInverse = vpOffset.multiplyBy(-1),
                    newX = node.location.x - vpOffsetInverse.x,
                    newY = node.location.y - vpOffsetInverse.y;

                node.location = new go.Point(newX, newY);
            }
        });

        L.goJsLayer = function (options) {
            return new L.GoJsLayer(options);
        };
    }
}());

var $ = go.GraphObject.make,
  	nodeTemplate, 
    linkTemplate, 
    model, 
    canvasLayer, 
    map;
    
// the node template describes how each Node should be constructed
nodeTemplate = $(go.Node, 'Auto',  
	$(go.Shape, 'Rectangle',  
    	{
        	fill: '#FFF',
            width: 10,
            height: 10
        }
    ),
    new go.Binding('location', 'loc', go.Point.parse)
);

// the linkTemplates describes how each link should be constructed
linkTemplate = $(go.Link, $(go.Shape));

// the Model holds only the essential information describing the diagram
model = new go.GraphLinksModel(
    [ 
    	{ key: 1, loc: '320 100' },
      	{ key: 2, loc: '320 300' }
    ],
    [ 
    	{ from: 1, to: 2 }
    ]
);

// Caution: The model property has to be set after the template properties
canvasLayer = L.goJsLayer({
	nodeTemplate: nodeTemplate,
    linkTemplate: linkTemplate,
    model: model
});

map = L.map('map', {
	zoom: 	4,
    center:	[51.505, -0.09],
    layers: 	[
    	L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', {noWrap: true}),
        canvasLayer
    ],
    //dragging: false
});
html, body, .map {
    padding: 0px;
    margin: 0px;
    height: 100%;
}

div canvas {
    outline: none;
}
<link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet-0.7/leaflet.css" />
<script type="text/javascript" src="http://gojs.net/latest/release/go-debug.js"></script>
<script type="text/javascript" src="http://cdn.leafletjs.com/leaflet-0.7/leaflet.js"></script>

<div id="map" class="map"></div>

Interesting work.

Consider my changes: Edit fiddle - JSFiddle - Code Playground

I greatly simplified the node positioning by using a data binding that updates the location of hte node based off of two things:

First, a given lat-long that must be supplied for every node (I assume this
makes more sense in the end than using pixel positions). This means I updated the data to look like:

model = new go.GraphLinksModel(
    [
      { key: 1, loc: '320 100', latlong: [51.507884, -0.087765] }, // london bridge
      { key: 2, loc: '320 100', latlong: [48.853039, 2.349952] }, // Notre-Dame cathedral
    ],
    [
      { from: 1, to: 2 }
    ]
);

And a data-binding that will always place it on the correct spot in the map:

new go.Binding('location', 'latlong', function(data) {
  var point = myLeafletMap.latLngToContainerPoint(data);
  return new go.Point(point.x, point.y);
})

These mean you won’t have to try and update the node locations yourself. I tried to make as few changes to your code as possible, so there may be more code worth removing, and I am making use of a global that you’ll probably want to replace with something else.

What I didn’t do is make the location binding two-way, which might require a little more work. So if you drag a node to a new location, its latlong data value will not update appropriately.


Aside: I see you’ve disabled the scrollbars, you may also want to investigate using Infinite Scroll, which you can see in action here: Scroll Modes GoJS Sample. This may or may not be right for your needs.

Consider also this much simpler Leaflet + GoJS sample: [EDIT: now a standard sample] Leaflet.js and GoJS

Thanks a lot for your contributions :+1: . Kudos to the approach using the GoJS binding mechanisms. I’ve updated a few other lines and came to the following results. It’s still not perfect, but it’ll be a good starting point for other developers who try work in this field.

Fiddle: Edit fiddle - JSFiddle - Code Playground

(function () {
    if (typeof(L) !== 'undefined' && typeof(go) !== 'undefined') {
        L.GoJsLayer = L.Class.extend({
            includes: [L.Mixin.Events],

            options: {
                "animationManager.isEnabled": false,
                allowZoom: false,
                allowHorizontalScroll: false,
                hasHorizontalScrollbar: false,
                allowVerticalScroll: false,
                hasVerticalScrollbar: false,
                padding: 0
            },

            initialize: function (options) {
                L.setOptions(this, options);
            },

            onAdd: function (map) {
                this._map = map;

                if (!this.diagram) {
                    this._initDiagram();
                }

                this._map
                    .on('viewreset', this._reset, this)
                    .on('moveend', this._updateViewport, this);
            },

            onRemove: function (map) {
                this._map
                    .getPanes()
                    .overlayPane
                    .removeChild(this._el);

                this._map
                    .off('moveend', this._updateViewport, this);
            },

            addTo: function (map) {
                map.addLayer(this);

                return this;
            },

            _initDiagram: function () {
                this._initElement();

                this.diagram = new go.Diagram(
                    this._el.getAttribute('id')
                );

                this._setFixedBounds();
                this.diagram.setProperties(this.options);

                this._setCanvas();
            },

            _initElement: function () {
                var size = this._map.getSize();

                this._el = L
                    .DomUtil
                    .create('div', 'leaflet-layer');
                this._el.setAttribute(
                    'id',
                    'leaflet-gojs-diagram-' + L.Util.stamp(this)
                );
                this._el
                    .setAttribute('style', this._getElementStyle());

                L.DomUtil.addClass(this._el, 'leaflet-zoom-hide');

                this._map
                    .getPanes()
                    .overlayPane
                    .appendChild(this._el);
            },

            _getElementStyle: function (options) {
                var size = this._map.getSize(),
                    panePosition,
                    transform;

                if (this._canvas) {
                    panePosition = this._map._getMapPanePos();
                    
                    transform = L
                        .DomUtil
                        .getTranslateString(panePosition.multiplyBy(-1));
                } else {
                    transform = L
                        .DomUtil
                        .getTranslateString(L.point(0, 0));
                }
                
                return L.Util.template(
                    'width: {width}px; ' +
                    'height: {height}px; ' +
                    'transform: {transform}', 
                    {
                        width: size.x, 
                        height: size.y, 
                        transform: transform
                    }
                );
            },

            _setFixedBounds: function () {
                var width = parseInt(L.DomUtil.getStyle(this._el, 'width')),
                    height = parseInt(L.DomUtil.getStyle(this._el, 'height'));

                this.diagram.setProperties({
                    fixedBounds: new go.Rect(0, 0, width, height)
                });
            },

            _setCanvas: function () {
                var canvasElements = this._el.getElementsByTagName('canvas');

                if (canvasElements.length) {
                    this._canvas = canvasElements.item(0);
                    return true;
                }

                return false;
            },

            _reset: function () {
                this.diagram.updateAllTargetBindings('latlong')
            },

            _updateViewport: function (options) {
                this._el.setAttribute('style', this._getElementStyle(options));
                this._setFixedBounds();
                
                this.diagram.updateAllTargetBindings('latlong');
            }
        });

        L.goJsLayer = function (options) {
            return new L.GoJsLayer(options);
        };
    }
}());

var $ = go.GraphObject.make,
    map,
    calcDiagramLocation,
    nodeTemplate,
    linkTemplate,
    model,
    canvasLayer;
    
map = L.map('map', {
    zoom: 	4,
    center:	[51.505, -0.09],
    layers: 	[
        L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', {noWrap: true})
    ],
    dragging: true
});

calcDiagramLocation = function(map, data) {
    var point = map.latLngToContainerPoint(data);
    return new go.Point(point.x, point.y);
};

// the node template describes how each Node should be constructed
nodeTemplate = $(go.Node, 'Auto',  
    $(go.Shape, 'Rectangle',  
        {
            fill: '#FFF',
            width: 10,
            height: 10
        }
    ),
    new go.Binding('location', 'latlong', calcDiagramLocation.bind(this, map))
);

// the linkTemplates describes how each link should be constructed
linkTemplate = $(go.Link, $(go.Shape));

// the Model holds only the essential information describing the diagram
model = new go.GraphLinksModel(
    [
        { key: 1, latlong: [51.507884, -0.087765] }, // london bridge
        { key: 2, latlong: [48.853039, 2.349952] }, // Notre-Dame cathedral
    ],
    [ 
        { from: 1, to: 2 }
    ]
);

// Caution: The model property has to be set after the template properties
canvasLayer = L.goJsLayer({
    nodeTemplate: nodeTemplate,
    linkTemplate: linkTemplate,
    model: model
}).addTo(map);
html, body, .map {
    padding: 0px;
    margin: 0px;
    height: 100%;
}

div canvas {
    outline: none;
}
<link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet-0.7/leaflet.css" />
<script type="text/javascript" src="http://gojs.net/latest/release/go-debug.js"></script>
<script type="text/javascript" src="http://cdn.leafletjs.com/leaflet-0.7/leaflet.js"></script>

<div id="map" class="map"></div>

Is Leaflet supposed to work on Android tablets? No touch events seem to have any effect in any browser.

Maybe the approach of having GoJS handling all input events has merit after all.

@walter
You seem to be right I’ve encountered the same problems on mobile devices. Can I register to the touch events of the diagram and delegate those to leaflet?

That was how we first implemented the integration – GoJS would handle all events, and some GoJS events would call Leaflet methods, such as for panning or zooming.

But that has some downsides, which is why the most recent samples have GoJS inside a Leaflet layer.

Which apparently has other downsides.

By default, GoJS prevents touchmove events from bubbling and prevents their default behavior so that panning on the Diagram does not also pan the webpage at the same time.

If a Diagram is on top of Leafleft, though, you may need to allow this to happen so that the events get passed on to leaflet.

Try adding these:

// This is to fix issues with touch, in spite of the names!
myDiagram.doMouseMove = function() {
  myDiagram.lastInput.bubbles = true;
  myDiagram.currentTool.doMouseMove();
};

myDiagram.doMouseUp = function() {
  myDiagram.lastInput.bubbles = true;
  myDiagram.currentTool.doMouseUp();
};

It should work, at least it does in my simple test, but there may be more that needs to be done.

We’ve made some changes to the GoJS library to more easily allow events that hit GoJS to bubble. This makes it easier to choose which events are handled by GoJS or DOM elements underneath. It also simplifies the sample code.

Here is the sample: Leaflet.js and GoJS

A custom tool is inserted as the first mouseDownTool in GoJS, which does nothing but bubbles the events so that Leaflet can use them. Otherwise, the events are kept for GoJS.

Data-binding on the Node’s locations is also two-way.

Your changes helped me a lot. I’ve updated my code and came to the following result.