Leaflet源码解读(一):扩展机制、投影转换与瓦片下载

前言

leaflet是业界流行的JavaScript交互式地图开源库且仅有39kb的大小,它支持直接调用OpenStreetMap, Mapbox等主流地图数据作为辅助图层来进行地理信息数据的可视化操作。

在leaflet官方例子中的一个demo例子。

var map = L.map('map').setView([23.0632, 113.1553], 13); 
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' }).addTo(map);
复制代码

仅两句代码就能加载出地图,下面一起来看下L.map和L.tileLayer的真实面目

优秀的扩展系统

L.map()是一个工厂函数,在leaflet中大写开头就是构造函数,小写开头就是工厂函数。

L.map = function (id, option) {
    return new L.Map(id, option)
}
复制代码

Map定义在src/map/Map.js文件中

export var Map == Evented.extend({...})
复制代码

Map是继承自Evented的。

leaflet有上百个插件,这种繁华的生态得益于leaflet优秀的扩展系统。就是这个.extend()方法。

image.png

如图所示,leaflet内置的类比如Map、Layer和Control等都是使用extend方法扩展出来的。而这一切都是基于Class对象。

Class定义在src/core/Class.js文件上。

export function Class() {}
复制代码

很简单的一段代码,就是创建一个空对象作为基类使用。下面定义Class.extend()方法

Class.extend = function (props) {
    // 1、寄生组合式继承
    var NewClass = function () {
        // call the constructor
        if (this.initialize) {
                this.initialize.apply(this, arguments);
        }
        // call all constructor hooks
        this.callInitHooks();
    };
    var parentProto = NewClass.__super__ = this.prototype;
    var proto = Util.create(parentProto);
    proto.constructor = NewClass;
    NewClass.prototype = proto;
    
    // 2、各种mixin
    // inherit parent's statics
    for (var i in this) {
        if (Object.prototype.hasOwnProperty.call(this, i) && i !== 'prototype' && i !== '__super__') {
            NewClass[i] = this[i];
        }
    }
    // mix static properties into the class
    if (props.statics) {
        Util.extend(NewClass, props.statics);
        delete props.statics;
    }
    // mix includes into the prototype
    if (props.includes) {
        checkDeprecatedMixinEvents(props.includes);
        Util.extend.apply(null, [proto].concat(props.includes));
        delete props.includes;
    }
    // merge options
    if (proto.options) {
        props.options = Util.extend(Util.create(proto.options), props.options);
    }
    // mix given properties into the prototype
    Util.extend(proto, props);
    
    // 3、执行hook
    proto._initHooks = [];
    // add method for calling all hooks
    proto.callInitHooks = function () {
        if (this._initHooksCalled) { return; }
        if (parentProto.callInitHooks) {
            parentProto.callInitHooks.call(this);
        }
        this._initHooksCalled = true;
        for (var i = 0, len = proto._initHooks.length; i < len; i++) {
            proto._initHooks[i].call(this);
        }
    };
    return NewClass;
};
复制代码

该函数可以分成三个部分:

  • 寄生组合式继承
  • 使用props增强NewClass
  • 执行hooks

寄生组合式继承

寄生组合式继承即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。

其背后基本思路是不必为了指定子类型的原型而调用超类型的构造函数,所需要的无非就是超类型原型的一个副本而已。

本质上就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。

创建NewClass构造函数作为子类,超类(父类)是Class

第一步:创建超类型原型的一个副本。

var parentProto = NewClass.__super__ = this.prototype;
var proto = Util.create(parentProto);
复制代码

Util.create是一个Object.create或者是Object.create的polyfill自执行函数

export var create = Object.create || (function () {
    function F() {}
    return function (proto) {
        F.prototype = proto;
        return new F();
    };
})();
复制代码

原理很简单:借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。不了解Object.create背后原理的同学可以看polyfill函数。

在自执行函数内部,先创建一个临时性构造函数,然后将传入的对象作为这个构造函数的原型,最后返回这个临时类型的一个新实例。从本质上来看,自执行函数对传入其中的对象执行了一个简单的浅拷贝。

第二步:为创建的副本添加constructor属性,从而弥补因重写原型而失去的默认constructor属性。

proto.constructor = NewClass;
复制代码

最后一步:将新创建的对象(即副本)赋值给子类型的原型。

NewClass.prototype = proto;
复制代码

这样一来NewClass对象就能继承这个Class。

使用props增强NewClass

接下来使用props增强NewClass

  • 将Class的静态成员mixin到NewClass里,包括静态成员函数Extend。这也是为什么从继承Class的对象能使用Extend的原因
for (var i in this) {
    if (Object.prototype.hasOwnProperty.call(this, i) && i !== 'prototype' && i !== '__super__') {
        NewClass[i] = this[i];
    }
}
复制代码
  • 将参数props对象的静态成员mixin到NewClass中,实例化NewClass也能用到props对象的静态成员
if (props.statics) {
    Util.extend(NewClass, props.statics);
    delete props.statics;
}
复制代码
  • 将参数props对象的includesMixin到proto中
if (props.includes) {
    checkDeprecatedMixinEvents(props.includes);
    Util.extend.apply(null, [proto].concat(props.includes));
    delete props.includes;
}
复制代码
  • 合并options
if (proto.options) {
    props.options = Util.extend(Util.create(proto.options), props.options);
}
复制代码
  • 最后将props的属性合并在proto中
Util.extend(proto, props);
复制代码

执行hooks

proto._initHooks = [];
// add method for calling all hooks
proto.callInitHooks = function () {
    if (this._initHooksCalled) { return; }
    if (parentProto.callInitHooks) {
        parentProto.callInitHooks.call(this);
    }
    this._initHooksCalled = true;
    for (var i = 0, len = proto._initHooks.length; i < len; i++) {
        proto._initHooks[i].call(this);
    }
};
复制代码

举例

下面看一个示例:

var MyClass = L.Class.extend({
    initialize: function (greeter) {
        this.greeter = greeter;
    },
    greet: function (name) {
        alert(this.greeter + ', ' + name)
    }
});
var MyChildClass = MyClass.extend({
    initialize: function () {
        MyClass.prototype.initialize.call(this, "Yo");
    },
    greet: function (name) {
        MyClass.prototype.greet.call(this, 'bro ' + name + '!');
    }
});
var a = new MyChildClass();
a instanceof MyChildClass; // true
a instanceof MyClass; // true
a.greet('Jason'); // "Yo, bro Jason!"
复制代码

当执行new MyChildClass()时,首先检查是否有initialize函数,如果有就执行。

解密L.Map--坐标转换

Map定义在src/map/Map.js中,Map是由Evented扩展出来的,而Evented则是扩展Class的。

var Map = Evented.extend({...});

var Events = {...};
var Evented = Class.extend(Events);
复制代码

initialize构造函数

new Map('map')时,执行initialize(id, options)。第一个参数时容器id,第二个参数就是一些属性。

initialize: function (id, options) {
    options = Util.setOptions(this, options);
    ...
    this._initContainer(id);
    this._initLayout();
    ...
    this._initEvents();
    // 没有zoom
    if (options.zoom !== undefined) {
        this._zoom = this._limitZoom(options.zoom);
    }
    
    // 没有传center
    if (options.center && options.zoom !== undefined) {
        this.setView(toLatLng(options.center), options.zoom, {reset: true});
    }
    // 没有hook
    this.callInitHooks();
    ...
    // 没有传layer
    this._addLayers(this.options.layers);
},
复制代码

合并options

options = Util.setOptions(this, options);
复制代码

setOtions定义在Util文件中,值得一提的是setOtions也挂在L上,例如L.setOptions就是Util.setOptions。,除此之外还有extend, bind, stamp这三个Util的函数。

function setOptions(obj, options) {
    if (!Object.prototype.hasOwnProperty.call(obj, 'options')) {
        obj.options = obj.options ? create(obj.options) : {};
    }
    for (var i in options) {
        obj.options[i] = options[i];
    }
    return obj.options;
}
复制代码

可以看到,先判断this也就是Map实例有没有options属性。大家看源码的时候别看错了,extend的options属性是合并到this.prototype里的。所以该判断是true,然后创建一个空对象赋值给this.options。

然后轮询参数options,赋值给this.options。由于例子中没有传options,所以返回空对象。

根据id获取容器

_initContainer: function (id) {
    var container = this._container = DomUtil.get(id);
    if (!container) {
        throw new Error('Map container not found.');
    } else if (container._leaflet_id) {
        throw new Error('Map container is already initialized.');
    }
    DomEvent.on(container, 'scroll', this._onScroll, this);
    this._containerId = Util.stamp(container);
}
复制代码

id可以是string也可以是HTMLElement,如果HTMLElement则直接使用,否则通过document.getElementById(id)获取。

// DomUtil.get
export function get(id) {
    return typeof id === 'string' ? document.getElementById(id) : id;
}
复制代码

然后进行一些判断

  • 容器是否存在
  • 容器是否已经注册

接着监听scroll事件

_onScroll: function () {
    this._container.scrollTop  = 0;
    this._container.scrollLeft = 0;
},
复制代码
// DomEvent.on
export function on(obj, types, fn, context) {
    if (typeof types === 'object') {
        ...
    } else {
        types = Util.splitWords(types);
        for (var i = 0, len = types.length; i < len; i++) {
            addOne(obj, types[i], fn, context);
        }
    }
    return this;
}
复制代码

DomEvent.on函数中,types传的是字符串'scroll',所以走到else分支中。

// Util.splitWords
export function trim(str) {
    return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g, '');
}
export function splitWords(str) {
    return trim(str).split(/\s+/);
}
复制代码

可以看到,先用trim去除首尾的空格符,再把string按照空格符分割成数据。也就是说如果types参数是'scroll click'的形式,处理完后变成['scroll', 'click']。

然后执行addOne方法。望文生义,只监听一次。

// addOne
function addOne(obj, type, fn, context) {
    var id = type + Util.stamp(fn) + (context ? '_' + Util.stamp(context) : '');
    if (obj[eventsKey] && obj[eventsKey][id]) { return this; }
    var handler = function (e) {
        return fn.call(context || obj, e || window.event);
    };
    var originalHandler = handler;
    ...
    } else if ('addEventListener' in obj) {
        } else {
            obj.addEventListener(type, originalHandler, false);
        }
    }
}
复制代码

只监听一次的方法就是使用事件名称+函数名称+上下文生成一个id。通过该id去判断是否监听过。

接下来进行一些判断,最后用addEventListener监听该事件。

最后根据容器生成一个容器id,赋值给_leaflet_id属性。

初始化布局

执行_initLayout

_initLayout: function () {
    var container = this._container;
    this._fadeAnimated = this.options.fadeAnimation && Browser.any3d;
    DomUtil.addClass(container, 'leaflet-container' +
            (Browser.touch ? ' leaflet-touch' : '') +
            ...
            (this._fadeAnimated ? ' leaflet-fade-anim' : ''));

    ...
    this._initPanes();
    ...
},
复制代码
  • 给容器增加class属性。
  • 获取容器position属性,如果没有则默认为relative。
  • 执行_initPanes()

pane是用于控制地图上图层顺序的DOM元素。

_initPanes: function () {
    var panes = this._panes = {};
    this._paneRenderers = {};
    this._mapPane = this.createPane('mapPane', this._container);
    DomUtil.setPosition(this._mapPane, new Point(0, 0));
    this.createPane('tilePane');
    this.createPane('overlayPane');
    this.createPane('shadowPane');
    this.createPane('markerPane');
    this.createPane('tooltipPane');
    this.createPane('popupPane');
    if (!this.options.markerZoomAnimation) {
        DomUtil.addClass(panes.markerPane, 'leaflet-zoom-hide');
        DomUtil.addClass(panes.shadowPane, 'leaflet-zoom-hide');
    }
},
复制代码

分别创建mapPane、tilePane、overlayPane、shadowPane、markerPane、tooltipPane和popupPane。创建pane是为了方便控制不同类型的地图UI控件。

最后往marker和shadow的pane容器中增加leaflet-zoom-hide,表示当zoom低于某个阈值时,可以将其隐藏起来。

初始化交互事件

执行_initEvent()

_initEvents: function (remove) {
    this._targets = {};
    this._targets[Util.stamp(this._container)] = this;
    var onOff = remove ? DomEvent.off : DomEvent.on;
    onOff(this._container, 'click dblclick mousedown mouseup ' +
        'mouseover mouseout mousemove contextmenu keypress keydown keyup', this._handleDOMEvent, this);
    if (this.options.trackResize) {
        onOff(window, 'resize', this._onResize, this);
    }
    if (Browser.any3d && this.options.transform3DLimit) {
        (remove ? this.off : this.on).call(this, 'moveend', this._onMoveEnd);
    }
},
复制代码

总结

根据例子来说new Map('map')就是执行了

this._initContainer(id);
this._initEvents();
this._initLayout();
复制代码

执行map.setView(center, zoom)

setView方法,传入center中心点经纬度以及zoom地图层级

setView: function (center, zoom, options) {
    zoom = zoom === undefined ? this._zoom : this._limitZoom(zoom);
    center = this._limitCenter(toLatLng(center), zoom, this.options.maxBounds);
    options = options || {};
    ...
    this._resetView(center, zoom);
    return this;
},
复制代码

处理zoom和center

setView([23.0632, 113.1553], 13) 执行_limitZoom(zoom)

_limitZoom: function (zoom) {
    var min = this.getMinZoom(),
        max = this.getMaxZoom(),
        snap = Browser.any3d ? this.options.zoomSnap : 1;
    if (snap) {
        zoom = Math.round(zoom / snap) * snap;
    }
    return Math.max(min, Math.min(max, zoom));
},
复制代码

leaflet中默认定义minZoom是1, maxZoom是18,snap为1

执行this._limitCenter(toLatLng(center), zoom, this.options.maxBounds); 首先将center值初始化为LatLng对象

{
    lat: 23.0632,
    lng: 113.1553
}
复制代码

options.maxBounds值为undefined。

_limitCenter: function (center, zoom, bounds) {
    if (!bounds) { return center; }
    ...
},
复制代码

_limitCenter直接返回center值。

执行_resetView()

_resetView: function (center, zoom) {
    DomUtil.setPosition(this._mapPane, new Point(0, 0));
    var loading = !this._loaded;
    this._loaded = true;
    zoom = this._limitZoom(zoom);
    this.fire('viewprereset');
    var zoomChanged = this._zoom !== zoom;
    this._moveStart(zoomChanged, false)
        ._move(center, zoom)
        ._moveEnd(zoomChanged);
    this.fire('viewreset');
    if (loading) {
        this.fire('load');
    }
},
复制代码
  • 重置_mapPane容器的位置
  • loading值为false
  • zoom值为13
  • 触发viewprereset事件,但暂时没有监听该事件
  • zoomChanged为true
  • _moveStart函数和_moveEnd函数都是触发一些事件,但暂时都没有监听,所以略过

重点是_move函数

_move: function (center, zoom, data) {
    ...
    this._zoom = zoom;
    this._lastCenter = center;
    this._pixelOrigin = this._getNewPixelOrigin(center);
    ...
},
复制代码

该函数主要的操作是得出这三个值。来看_pixelOrigin

_getNewPixelOrigin: function (center, zoom) {
    var viewHalf = this.getSize()._divideBy(2);
    return this.project(center, zoom)._subtract(viewHalf)._add(this._getMapPanePos())._round();
},
复制代码

getSize函数是得出Point类型的容器clientWidth和ClientHeight值

getSize: function () {
    if (!this._size || this._sizeChanged) {
        this._size = new Point(
            this._container.clientWidth || 0,
            this._container.clientHeight || 0);
        this._sizeChanged = false;
    }
    return this._size.clone();
},
复制代码

_divideBy(2)就是除以2

_divideBy: function (num) {
    this.x /= num;
    this.y /= num;
    return this;
},
复制代码

所以viewHalf的值就是Point类型的容器像素中心点

接下来执行投影转换this.project(center, zoom)

project: function (latlng, zoom) {
    zoom = zoom === undefined ? this._zoom : zoom;
    return this.options.crs.latLngToPoint(toLatLng(latlng), zoom);
},
复制代码

投影转换

地球,在初中地理课有教过,它其实是一个不规则的椭球体。为了将地球的表面能够映射到平面上,一般都是将其假设为一个规则的椭球体。比如WGS84坐标系,它的前提就是将地球视为一个规则的椭球体,其中长半轴是6378137米,扁率为1/298.257223565。

而将椭球映射为平面的投影,其中最著名的就是墨卡托投影:

墨卡托投影

墨卡托投影,又称正轴等角圆柱投影,由荷兰地图学家墨卡托于1569年创拟。假设地球被套在一个圆柱中,赤道与圆柱相切,然后在地球中心放一盏灯,把球面上的图形投影到圆柱体上,再把圆柱体展开,就形成以一幅墨卡托投影的世界地图。其中,按等角条件将经纬网投影到圆柱面上,将圆柱面展为平面后,得平面经纬线网。 527375-20200210120721242-2125625239.gif

墨卡托投影的特质:在投影地图上经纬线的伸长与纬度的正割成比例变化,随纬度增高极具拉伸,到极点成为无穷大,面积的扩大更为明显。

墨卡托投影具有等角性质,即球体上的两点之间的角度方位与平面上的两点之间的角度方位保持不变,因此特别适合用于导航。

在leaflet中,墨卡托投影计算如下

export var Mercator = {
    R: 6378137,
    R_MINOR: 6356752.314245179,
    bounds: new Bounds([-20037508.34279, -15496570.73972], [20037508.34279, 18764656.23138]),
    project: function (latlng) {
        var d = Math.PI / 180,
            r = this.R,
            y = latlng.lat * d,
            tmp = this.R_MINOR / r,
            e = Math.sqrt(1 - tmp * tmp),
            con = e * Math.sin(y);

        var ts = Math.tan(Math.PI / 4 - y / 2) / Math.pow((1 - con) / (1 + con), e / 2);
        y = -r * Math.log(Math.max(ts, 1E-10));
        return new Point(latlng.lng * d * r, y);
    },
    unproject: function (point) {
        var d = 180 / Math.PI,
            r = this.R,
            tmp = this.R_MINOR / r,
            e = Math.sqrt(1 - tmp * tmp),
            ts = Math.exp(-point.y / r),
            phi = Math.PI / 2 - 2 * Math.atan(ts);
        for (var i = 0, dphi = 0.1, con; i < 15 && Math.abs(dphi) > 1e-7; i++) {
            con = e * Math.sin(phi);
            con = Math.pow((1 - con) / (1 + con), e / 2);
            dphi = Math.PI / 2 - 2 * Math.atan(ts * con) - phi;
            phi += dphi;
        }
        return new LatLng(phi * d, point.x * d / r);
    }
};
复制代码

web墨卡托投影

互联网上的大部分地图网站(百度、google、高德)却不是使用墨卡托投影,而是使用墨卡托投影的变形:web墨卡托。它接收的输入是WGS84的经纬度,但在投影时不再把地球当做椭球而当做半径为6378137米的标准球体,以简化计算。

web墨卡托投影以赤道作为标准纬线,本初子午线作为中央经线,两者交点为坐标原点,向东向北为正,向西向南为负。

  • X轴:由于赤道半径为6378137米,则赤道周长为2*PI*r = 2*20037508.3427892,因此X轴的取值范围:+-20037508.3427892。
  • Y轴:由墨卡托投影的公式可知,当纬度接近两极,即90°时,y值趋向于无穷。所以为了方便计算,也把Y轴的取值范围也限定在+-20037508.3427892之间,正好形成一个正方形。

经过反计算,可得到纬度85.05112877980659。因此纬度取值范围是+-85.05112877980659。

因此,地理坐标系(经纬度)对应的范围是:最小(-180,-85.05112877980659),最大(180, 85.05112877980659) 公式如下: image.png 在leaflet中的实现如下

var earthRadius = 6378137;
export var SphericalMercator = {
    R: earthRadius,
    MAX_LATITUDE: 85.0511287798,
    project: function (latlng) {
        var d = Math.PI / 180,
            max = this.MAX_LATITUDE,
            lat = Math.max(Math.min(max, latlng.lat), -max),
            sin = Math.sin(lat * d);

        return new Point(
            this.R * latlng.lng * d,
            this.R * Math.log((1 + sin) / (1 - sin)) / 2);
    },
    unproject: function (point) {
        var d = 180 / Math.PI;
        return new LatLng(
            (2 * Math.atan(Math.exp(point.y / this.R)) - (Math.PI / 2)) * d,
            point.x * d / this.R);
    },
    bounds: (function () {
        var d = earthRadius * Math.PI;
        return new Bounds([-d, -d], [d, d]);
    })()
};
复制代码

这也是leaflet默认的CRS(参考坐标系)所用的投影。输入参数为WGS84坐标系经纬度,得到展开后的平面坐标系坐标,这也是第一次投影转换

代码接上一节,执行this.options.crs.latLngToPoint(toLatLng(latlng), zoom);

export var CRS = {
    latLngToPoint: function (latlng, zoom) {
            var projectedPoint = this.projection.project(latlng),
                scale = this.scale(zoom);
            return this.transformation._transform(projectedPoint, scale);
    },
    ...
}
复制代码

latLngToPoint函数执行两次转换

  • 第一次就是Web墨卡托投影转换:
var projectedPoint = this.projection.project(latlng)
复制代码

默认crs是EPSG3857,在Map的option中定义的crs: EPSG3857,它的投影(projection)是SphericalMercator,就是leaflet实现的web墨卡托投影算法。

export var EPSG3857 = Util.extend({}, Earth, {
    code: 'EPSG:3857',
    projection: SphericalMercator,
    transformation: (function () {
        var scale = 0.5 / (Math.PI * SphericalMercator.R);
        return toTransformation(scale, 0.5, -scale, 0.5);
    }())
});
复制代码
  • 第二次转换是为了将投影坐标转换为像素坐标:
return this.transformation._transform(projectedPoint, scale);
复制代码

看过地图的同学都知道,地图上的坐标是需要一个比例才能换算为实际的距离。而这个比例就是比例尺(scale)。所以在这之前,需要得到比例尺(scale)才能进行转换。在web地图的比例尺就是一个像素实际上代表的是一个坐标范围,我们取其范围的中心点坐标作为此像素所代表的坐标值。

scale = this.scale(zoom);
...
scale: function (zoom) {
    return 256 * Math.pow(2, zoom);
},
复制代码

zoom是地图层级。当zoom=0时,整张地图表现为一张256*256的图片。因为很多地图厂商都将地图分成很多瓦片,每一张瓦片都是256*256的大小,因为这很好计算。

export function Transformation(a, b, c, d) {
    if (Util.isArray(a)) {
        this._a = a[0];
        this._b = a[1];
        this._c = a[2];
        this._d = a[3];
        return;
    }
    this._a = a;
    this._b = b;
    this._c = c;
    this._d = d;
}
Transformation.prototype = {
    _transform: function (point, scale) {
        scale = scale || 1;
        point.x = scale * (this._a * point.x + this._b);
        point.y = scale * (this._c * point.y + this._d);
        return point;
    },
    ...
}
复制代码

为了方便计算与展示,第一步先进行一次仿射变换。

this._a * point.x + this._b
this._c * point.y + this._d
复制代码

就像上面所说的那样,zoom为0的时候,地图就是一张瓦片。为了方便计算与展示这个仿射变换相当于将平面投影放进一张正方形瓦片中,这张瓦片的边长为1。

所以上述代码中a、b、c、d四个参数为:

transformation: (function () {
    var scale = 0.5 / (Math.PI * SphericalMercator.R); // 1 / (2 * PI * R)
    return toTransformation(scale, 0.5, -scale, 0.5);
}())
复制代码

scale表示地球周长分之一,[0.5, 0.5]就是平面投影坐标系原点在变换后的坐标系的位置。

  • this._a * point.x就是point.x在瓦片坐标系中x轴的长度
  • this._c * point.y就是point.y在瓦片坐标系中y轴的长度

再加上原点的位置就能表示点point在瓦片坐标系的位置了。

至于c为什么是负数,是因为瓦片的原点是在左上角(0, 0)向右向下为正方向,而经纬度和Web墨卡托的起始点是赤道和本初子午线的交点。纬度以上为正,下为负,所以到瓦片这负纬度坐标是要大于0.5,所以要添加负号

最后乘上比例尺算出瓦片所表示的像素范围从而算出像素坐标了。

总结

setView(center, zoom)最重要的就是算出这三个值。

this._zoom = zoom;
this._lastCenter = center;
this._pixelOrigin = this._getNewPixelOrigin(center);
复制代码

解密L.tileLayer()瓦片下载

同样tileLayer是一个工厂函数

L.tileLayer = funtion (url, option) {
    return new L.TileLayer(url, option)
}
复制代码

TileLayer的initialize函数很简单,只是合并option而已。

addTo(map)

之后执行addTo(map)。addTo函数定义Layer中,tileLayer是扩展自GridLayer,而GridLayer则扩展自Layer的。

addTo: function (map) {
    map.addLayer(this);
    return this;
},

// map.addLayer()
addLayer: function (layer) {
    ...
    this.whenReady(layer._layerAdd, layer);
    return this;
},
复制代码

whenReady函数是确保map初始化完毕后再添加layer

whenReady: function (callback, context) {
    if (this._loaded) {
        callback.call(context || this, {target: this});
    } else {
        this.on('load', callback, context);
    }
    return this;
},
复制代码

所以最后是执行_layerAdd(layer)

_layerAdd

_layerAdd: function (e) {
    var map = e.target;
    if (!map.hasLayer(this)) { return; }
    this._map = map;
    this._zoomAnimated = map._zoomAnimated;
    if (this.getEvents) {
        var events = this.getEvents();
        map.on(events, this);
        this.once('remove', function () {
            map.off(events, this);
        }, this);
    }
    this.onAdd(map);
    if (this.getAttribution && map.attributionControl) {
        map.attributionControl.addAttribution(this.getAttribution());
    }
    this.fire('add');
    map.fire('layeradd', {layer: this});
}
复制代码

getEvents函数返回的就是_resetView()所触发的事件

getEvents: function () {
    var events = {
        viewprereset: this._invalidateAll,
        viewreset: this._resetView,
        zoom: this._resetView,
        moveend: this._onMoveEnd
    };
    ...
    return events;
},
复制代码

可以看到zoom的变化都是触发_resetView。

接下来执行onAdd

onAdd: function () {
    this._initContainer();
    this._levels = {};
    this._tiles = {};
    this._resetView();
},
复制代码

首先初始化图层DOM节点

_initContainer: function () {
    if (this._container) { return; }
    this._container = DomUtil.create('div', 'leaflet-layer ' + (this.options.className || ''));
    ...
    this.getPane().appendChild(this._container);
},
复制代码

并将其放置到它所属的tile-pane容器中。

然后隐形的触发调用一次_resetView()函数。

_resetView: function (e) {
    var animating = e && (e.pinch || e.flyTo);
    // 四个参数分别是_lastCenter、_zoom、undefined、undefined
    this._setView(this._map.getCenter(), this._map.getZoom(), animating, animating);
},
_setView: function (center, zoom, noPrune, noUpdate) {
    var tileZoom = Math.round(zoom);
    ...
    var tileZoomChanged = this.options.updateWhenZooming && (tileZoom !== this._tileZoom);
    if (!noUpdate || tileZoomChanged) {
        this._tileZoom = tileZoom;
        ...
        if (tileZoom !== undefined) {
            this._update(center);
        }
        ...
    }
    this._setZoomTransforms(center, zoom);
},
复制代码

重点是_update函数

_update--瓦片下载

由于地图数据太大了,在浏览器显示的时候通常都是把整个地图数据分割成256*256的图块加载的,这些图块也称为瓦片。而且通常也不会查看整张地图,只会关注当前某个层级下的某一部分瓦片。在加载的过程中,获取用户当前关注的区域的所有瓦片并将其拼成地图,所以这种局部下载是非常合理的。

还有一个问题,将地图切块的操作是根据图层等级和位置实时切割还是提前切好的呢?

答案是后者,这种模型称为瓦片金字塔模型。它是一种多分辨率层次模型,从瓦片金字塔的底层到顶层,分辨率越来越低,但表示的地理范围是不变的。根据zoom等级来划分,zoom为0则加载塔尖的地图瓦片,zoom在18则加载塔底的地图瓦片。 image.png

与金字塔模型配合使用的就是瓦片坐标系,在不同的缩放等级下,同一块区域的瓦片个数也是不一样的。瓦片越多就代表这一区域显示越详情,缩放比例也就越大。 image.png 地图上每张瓦片有其特定编码,主流厂商大多用x,y,z表示,其中x为横轴方向的编码,y为纵轴方向的编码,z为地图缩放等级,而原点则在左上角。

每一级的瓦片数量,根据以下公式计算:

Math.pow(Math.pow(2, n), 2);
复制代码

n表示当前地图层级。

image.png 如图所示:只需知道屏幕四个点的经纬度所在范围内的瓦片,再把瓦片按照一定的偏移坐标布置即可。

实例化map时传入的中心点以及转换成的瓦片像素坐标,再利用屏幕范围可得出屏幕四个角的瓦片像素坐标。利用这四个点的瓦片坐标,可以求出当前屏幕的瓦片索引范围,加载瓦片。


_update: function (center) {
    ...
    var pixelBounds = this._getTiledPixelBounds(center),
        tileRange = this._pxBoundsToTileRange(pixelBounds),
        ...
}
_getTiledPixelBounds: function (center) {
    var map = this._map,
        mapZoom = map._animatingZoom ? Math.max(map._animateToZoom, map.getZoom()) : map.getZoom(),
        scale = map.getZoomScale(mapZoom, this._tileZoom),
        pixelCenter = map.project(center, this._tileZoom).floor(),
        halfSize = map.getSize().divideBy(scale * 2);

    return new Bounds(pixelCenter.subtract(halfSize), pixelCenter.add(halfSize));
},

_pxBoundsToTileRange: function (bounds) {
    var tileSize = this.getTileSize();
    return new Bounds(
            bounds.min.unscaleBy(tileSize).floor(),
            bounds.max.unscaleBy(tileSize).ceil().subtract([1, 1]));
},
复制代码

整个_update过程如下

_update: function (center) {
    ...
    // 1、计算可视区域的像素范围
    var pixelBounds = this._getTiledPixelBounds(center),
        // 2、将像素范围转换为瓦片格网范围
        tileRange = this._pxBoundsToTileRange(pixelBounds),
        tileCenter = tileRange.getCenter(),
        queue = [],
        margin = this.options.keepBuffer,
        noPruneRange = new Bounds(tileRange.getBottomLeft().subtract([margin, -margin]),
                                  tileRange.getTopRight().add([margin, -margin]));
    ...
    // 3、将不再当前范围内已加载的瓦片打上标签
    for (var key in this._tiles) {
        var c = this._tiles[key].coords;
        if (c.z !== this._tileZoom || !noPruneRange.contains(new Point(c.x, c.y))) {
            this._tiles[key].current = false;
        }
    }
    // 4、如果zoom发生变化,重来一遍
    if (Math.abs(zoom - this._tileZoom) > 1) { this._setView(center, zoom); return; }
    // 5、将格网范围内的瓦片放入一个数组中
    for (var j = tileRange.min.y; j <= tileRange.max.y; j++) {
        for (var i = tileRange.min.x; i <= tileRange.max.x; i++) {
            var coords = new Point(i, j);
            coords.z = this._tileZoom;
            if (!this._isValidTile(coords)) { continue; }
            var tile = this._tiles[this._tileCoordsToKey(coords)];
            if (tile) {
                tile.current = true;
            } else {
                queue.push(coords);
            }
        }
    }
    // 6、对数组进行排序,靠近中心点的先加载
    queue.sort(function (a, b) {
        return a.distanceTo(tileCenter) - b.distanceTo(tileCenter);
    });
    // 7、创建瓦片
    if (queue.length !== 0) {
        ...
        var fragment = document.createDocumentFragment();
        for (i = 0; i < queue.length; i++) {
            this._addTile(queue[i], fragment);
        }
        this._level.el.appendChild(fragment);
    }
},
复制代码

这时候得出的瓦片坐标范围是大于屏幕的坐标范围的,所以要对所有的瓦片做一些偏移。

屏幕坐标以左上点为原点,这个点对应的像素坐标已知,只要求出每个瓦片的左上角的瓦片像素坐标与屏幕左上角的瓦片像素坐标做差值,即可得出在css的translate的偏移值。

_addTile: function (coords, container) {
    // 计算瓦片在地图上的偏移量
    var tilePos = this._getTilePos(coords),
        key = this._tileCoordsToKey(coords);
    var tile = this.createTile(this._wrapCoords(coords), Util.bind(this._tileReady, this, coords));
    this._initTile(tile);
    // 得出在css的translate的偏移值
    DomUtil.setPosition(tile, tilePos);
    ...
    container.appendChild(tile);
},
createTile: function (coords, done) {
    var tile = document.createElement('img');
    ...
    tile.src = this.getTileUrl(coords);
    return tile;
},
复制代码

可以看到tileLayer加载的地图都是img元素来的

这就是两句代码背后的主要原理!

结尾

创作不易,烦请动动手指点一点赞。

更多文章请移步楼主github,如果喜欢请点一下star,对作者也是一种鼓励。

猜你喜欢

转载自juejin.im/post/7031342760804220942