Leaflet/docs/examples/extending/extending-2-layers.md
2018-05-04 15:21:43 +03:00

10 KiB

layout title
tutorial_v2 Extending Leaflet, New Layers

This tutorial assumes you've read the theory of Leaflet class inheritance.

In Leaflet, a "layer" is anything that moves around when the map is moved around. Before seeing how to create them from scratch, it's easier to explain how to do simple extensions.

"Extension methods"

A few of the Leaflet classes have so-called "extension methods": entry points for writing code for sub-classes.

One of them is L.TileLayer.getTileUrl(). This method is called internally by L.TileLayer whenever a new tile needs to know which image to load. By making a subclass of L.TileLayer and rewriting its getTileUrl() function, we can create custom behaviour.

Let's illustrate with a custom L.TileLayer that will display random kitten images from PlaceKitten:

L.TileLayer.Kitten = L.TileLayer.extend({
    getTileUrl: function(coords) {
        var i = Math.ceil( Math.random() * 4 );
        return "https://placekitten.com/256/256?image=" + i;
    },
    getAttribution: function() {
        return "<a href='https://placekitten.com/attribution.html'>PlaceKitten</a>"
    }
});

L.tileLayer.kitten = function() {
    return new L.TileLayer.Kitten();
}

L.tileLayer.kitten().addTo(map);

{% include frame.html url="kittenlayer.html" %}

Normally, getTileLayer() receives the tile coordinates (as coords.x, coords.y and coords.z) and generates a tile URL from them. In our example, we ignore those and simply use a random number to get a different kitten every time.

Splitting away the plugin code

In the previous example, L.TileLayer.Kitten is defined in the same place as it's used. For plugins, it's better to split the plugin code into its own file, and include that file when it's used.

For the KittenLayer, you should create a file like L.KittenLayer.js with:

L.TileLayer.Kitten = L.TileLayer.extend({
    getTileUrl: function(coords) {
        var i = Math.ceil( Math.random() * 4 );
        return "https://placekitten.com/256/256?image=" + i;
    },
    getAttribution: function() {
        return "<a href='https://placekitten.com/attribution.html'>PlaceKitten</a>"
    }
});

And then, include that file when showing a map:

<html>
…
<script src='leaflet.js'>
<script src='L.KittenLayer.js'>
<script>
	var map = L.map('map-div-id');
	L.tileLayer.kitten().addTo(map);
</script>
…

L.GridLayer and DOM elements

Another extension method is L.GridLayer.createTile(). Where L.TileLayer assumes that there is a grid of images (as <img> elements), L.GridLayer doesn't assume that - it allows creating grids of any kind of HTML Elements.

L.GridLayer allows creating grids of <img>s, but grids of <div>s, <canvas>es or <picture>s (or anything) are possible. createTile() just has to return an instance of HTMLElement given the tile coordinates. Knowing how to manipulate elements in the DOM is important here: Leaflet expects instances of HTMLElement, so elements created with libraries like jQuery will be problematic.

An example of a custom GridLayer is showing the tile coordinates in a <div>. This is particularly useful when debugging the internals of Leaflet, and for understanding how the tile coordinates work:

L.GridLayer.DebugCoords = L.GridLayer.extend({
	createTile: function (coords) {
		var tile = document.createElement('div');
		tile.innerHTML = [coords.x, coords.y, coords.z].join(', ');
		tile.style.outline = '1px solid red';
		return tile;
	}
});

L.gridLayer.debugCoords = function(opts) {
	return new L.GridLayer.DebugCoords(opts);
};

map.addLayer( L.gridLayer.debugCoords() );

If the element has to do some asynchronous initialization, then use the second function parameter done and call it back when the tile is ready (for example, when an image has been fully loaded) or when there is an error. In here, we'll just delay the tiles artificially:

createTile: function (coords, done) {
	var tile = document.createElement('div');
	tile.innerHTML = [coords.x, coords.y, coords.z].join(', ');
	tile.style.outline = '1px solid red';

	setTimeout(function () {
		done(null, tile);	// Syntax is 'done(error, tile)'
	}, 500 + Math.random() * 1500);

	return tile;
}

{% include frame.html url="gridcoords.html" %}

With these custom GridLayers, a plugin can have full control of the HTML elements that make up the grid. A few plugins already use <canvas>es in this way to do advanced rendering.

A very basic <canvas> GridLayer looks like:

L.GridLayer.CanvasCircles = L.GridLayer.extend({
	createTile: function (coords) {
		var tile = document.createElement('canvas');

		var tileSize = this.getTileSize();
		tile.setAttribute('width', tileSize.x);
		tile.setAttribute('height', tileSize.y);

		var ctx = tile.getContext('2d');

		// Draw whatever is needed in the canvas context
		// For example, circles which get bigger as we zoom in
		ctx.beginPath();
		ctx.arc(tileSize.x/2, tileSize.x/2, 4 + coords.z*4, 0, 2*Math.PI, false);
		ctx.fill();

		return tile;
	}
});

{% include frame.html url="canvascircles.html" %}

The pixel origin

Creating custom L.Layers is possible, but needs a deeper knowledge of how Leaflet positions HTML elements. The abridged version is:

  • The L.Map container has "map panes", which are <div>s.
  • L.Layers are HTML elements inside a map pane
  • The map transforms all LatLngs to coordinates in the map's CRS, and from that into absolute "pixel coordinates" (the origin of the CRS is the same as the origin of the pixel coordinates)
  • When the L.Map is ready (has a center LatLng and a zoom level), the absolute pixel coordinates of the top-left corner become the "pixel origin"
  • Each L.Layer is offset from its map pane according to the pixel origin and the absolute pixel coordinates of the layer's LatLngs
  • The pixel origin is reset after each zoomend or viewreset event on the L.Map, and every L.Layer has to recalculate its position (if needed)
  • The pixel origin is not reset when panning the map around; instead, the whole panes are repositioned.

This might be a bit overwhelming, so consider the following explanatory map:

{% include frame.html url="pixelorigin.html" %}

The CRS origin (green) stays in the same LatLng. The pixel origin (red) always starts at the top-left corner. The pixel origin moves around when the map is panned (map panes are repositioned relative to the map's container), and stays in the same place in the screen when zooming (map panes are not repositioned, but layers might redraw themselves). The absolute pixel coordinate to the pixel origin is updated when zooming, but is not updated when panning. Note how the absolute pixel coordinates (the distance to the green bracket) double every time the map is zoomed in.

To position anything (for example, a blue L.Marker), its LatLng is converted to an absolute pixel coordinate inside the map's L.CRS. Then the absolute pixel coordinate of the pixel origin is subtracted from its absolute pixel coordinate, giving an offset relative to the pixel origin (light blue). As the pixel origin is the top-left corner of all map panes, this offset can be applied to the HTML element of the marker's icon. The marker's iconAnchor (dark blue line) is achieved via negative CSS margins.

The L.Map.project() and L.Map.unproject() methods operate with these absolute pixel coordinates. Likewise, L.Map.latLngToLayerPoint() and L.Map.layerPointToLatLng() work with the offset relative to the pixel origin.

Different layers apply these calculations in different ways. L.Markers simply reposition their icons; L.GridLayers calculate the bounds of the map (in absolute pixel coordinates) and then calculate the list of tile coordinates to request; vector layers (polylines, polygons, circle markers, etc) transform each LatLng to pixels and draw the geometries using SVG or <canvas>.

onAdd and onRemove

At their core, all L.Layers are HTML elements inside a map pane, their positions and contents defined by the layer's code. However, HTML elements cannot be created when a layer is instantiated; rather, this is done when the layer is added to the map - the layer doesn't know about the map (or even about the document) until then.

In other words: the map calls the onAdd() method of the layer, then the layer creates its HTML element(s) (commonly named 'container' element) and adds them to the map pane. Conversely, when the layer is removed from the map, its onRemove() method is called. The layer must update its contents when added to the map, and reposition them when the map view is updated. A layer skeleton looks like:

L.CustomLayer = L.Layer.extend({
	onAdd: function(map) {
		var pane = map.getPane(this.options.pane);
		this._container = L.DomUtil.create(…);

		pane.appendChild(this._container);

		// Calculate initial position of container with `L.Map.latLngToLayerPoint()`, `getPixelOrigin()` and/or `getPixelBounds()`

		L.DomUtil.setPosition(this._container, point);

		// Add and position children elements if needed

		map.on('zoomend viewreset', this._update, this);
	},

	onRemove: function(map) {
		L.DomUtil.remove(this._container);
		map.off('zoomend viewreset', this._update, this);
	},

	_update: function() {
		// Recalculate position of container

		L.DomUtil.setPosition(this._container, point);        

		// Add/remove/reposition children elements if needed
	}
});

How to exactly position the HTML elements for a layer depends on the specifics of the layer, but this introduction should help you to read Leaflet's layer code, and create new layers.

Using the parent's onAdd

Some use cases don't need the whole onAdd code to be recreated, but instead the code for the parent can be reused, then some specifics can be added before or after that initialization (as needed).

To give an example, we can have a subclass of L.Polyline that will always be red (ignoring the options), like:

L.Polyline.Red = L.Polyline.extend({
	onAdd: function(map) {
		this.options.color = 'red';
		L.Polyline.prototype.onAdd.call(this, map);
	}
});