需求
有些系统里有添加图片到地图(2D/3D)上显示的需求,这些图片知道地理范围,但也只是普通图片不是严格意义上的栅格地理数据,另外又不想做几何校正等数据处理,然后发布地图服务这些复杂操作。
前段时间实现了后台接口模拟 ArcGIS Map Service 让各端通过
MapImageLayer
加载,但也想尝试有没有纯属前端(JS)的解决方案。虽然BaseDynmicLayer支持扩展,但官方文档明确指出不支持3D模式。
分析
之前通过后台服务模拟Map Service时就知道了MapImageLayer是支持2D/3D地图加载的,而大概知道了JS API 中MapImageLayer的基本原理,所以针对这个问题还要首先想到了从MapImageLayer下手。MapImageLayer的基本实现如下:
- 调用地图服务元数据查询接口,获取图层的元数据,这些元数据里包含了坐标系,图层的地图范围等信息,对于图片类型的加载,坐标系与地图范围这两个元数据特别重要,不可缺。
- 图层显示或地图显示区域变化 时,图层会调取地图服务一个名为 export 的接口,服务端会根据回传的参数生成图片,png、jpe或其它格式,将图片返回给调用端。调用端传递的参数包括了当前地图范围、需要的图片尺寸、像素密度、图片格式等等。
- 在JS API中,MapImageLayer 获取到服务端返回的图片后,把图片返回给了View层,由View层完成在地图上的绘制。
实现
源码分析
虽然 ArcGIS JS API的代码是混淆,但格式化后还是能有所发现的。
在MapImageLayer.js文件两个函数引起了我的注意,感觉有戏,一个是fetchImage,一个是_fetchService,很明显fetchImage是在向服务端请求图片,十之八九就是返回给View层渲染的图片,而fetchService很有可能是查询元数据的方法。
- _fetchService
c.prototype._fetchService = function (a) {
return k(this, void 0, void 0, function () {
var b, c, d;
return f(this, function (e) {
switch (e.label) {
case 0:
return this.sourceJSON ? (this.read(this.sourceJSON, {
origin: "service",
url: this.parsedUrl
}), [2]) :
[4, m(this.parsedUrl.path, {query: p({f: "json"}, this.parsedUrl.query), signal: a})];
case 1:
b = e.sent();
c = b.data;
if (d = b.ssl) this.url = this.url.replace(/^http:/i, "https:");
this.sourceJSON = c;
this.read(c, {origin: "service", url: this.parsedUrl});
return [2]
}
})
})
};
这个函数里大致的意思就是根据一些条件决定是否去后台取数据,通过调试跟踪代码发现它调的接口就是地图服务的元数据查询接口。看到代码里有一段 this.sourceJSON=C ,初步推断 this.sourceJSON就图层的元数据信息,为了验证这个猜测,构建了一个元数据模板,在load方法里直接把元数据赋给了this.sourceJSON,图层在地图上正常加载了,那么实锤this.sourceJSON就是图层元数据对象。
c.prototype.load =function (a) {
this.sourceJSON = this._setupSourceJSON(this.spatialReference, this.pictureExtent, this.units);
……
};
- fetchImage
c.prototype.fetchImage =
function (a, b, c, d) {
var e = { responseType: "image" };
d && d.timestamp && (e.query = { _ts: d.timestamp });
d && d.signal && (e.signal = d.signal);
var f, h = this.getImageUrl(a, b, c, d);
if (h) a = g.when(h).then(function (a) {
f = a;
return m(f, e)
});
else {
f = this.parsedUrl.path + "/export";
a = p({}, this.parsedUrl.query, this.createExportImageParameters(a, b, c, d), {
f: "image",
_ts: this.alwaysRefetch ? Date.now() : null});
if (null != a.dynamicLayers &&!this.capabilities.exportMap.supportsDynamicLayers)
return g.reject(new w("mapimagelayer:dynamiclayer-not-supported",
"service " + this.url + " doesn't support dynamic layers, which is required to be able to change the sublayer's order, rendering, labeling or source.", { query: a }));
e.query = e.query ? p({}, a, e.query) : a;
a = m(f, e)
}
return a.then(function (a) {
return a.data
}).catch(function (a) {
if (g.isAbortError(a)) throw a;
throw new w("mapimagelayer:image-fetch-error", "Unable to load image: " + f, { error: a });
})
};
在代码里发现了 /export 字样,果然这个方法是用来获取渲染图片的,调试跟踪代码发现 a是一个Extent类数据,那么它应该就是获取图版时的地图范围,b、c的值都是2048,那么这两个参数很有可参对应width、height两个参数。这个方法最终返回一个Promise对象,并在then里返回了后数据,调试时也确认返回的数据就是一个img。
有了上面这些结果,下面要做事情就是两件:
- 一是根据图层配制的一些参数或属性,自动填充图层元数据;
- 另一件就是根据获取图片的参数,直接在前端生View层渲染需要的图片;
自动填充图层元数据
ArcGIS Map Service的元数据结构示例:
{
"currentVersion": "10.7",
"serviceDescription": "",
"mapName": "Layers",
"description": "",
"copyrightText": "",
"supportsDynamicLayers": true,
"singleFusedMapCache": false,
"minScale": 0,
"maxScale": "0",
"units": "esriMeters",
"supportedImageFormatTypes": "PNG32,PNG24,PNG,JPG,DIB,TIFF,EMF,PS,PDF,GIF,SVG,SVGZ,BMP",
"capabilities": "Map,Query,Data",
"supportedQueryFormats": "JSON, AMF, geoJSON",
"exportTilesAllowed": false,
"supportsDatumTransformation": true,
"maxRecordCount": 1000,
"maxImageHeight": 4096,
"maxImageWidth": 4096,
"supportedExtensions": "KmlServer",
"layers": [
{
"id": 0,
"name": "82f22214-12da-433e-a2eb-f9c1acaa3718",
"parentLayerId": -1,
"subLayerIds": [],
"minScale": 0,
"maxScale": 0,
"type": "Raster Layer"
}
],
"tables": [],
"spatialReference": {
"wkid": 102100,
"latestWkid": 3857
},
"initialExtent": {
"xmin": 7792364.355529149,
"ymin": -7.081154551613622e-10,
"xmax": 16697923.618991036,
"ymax": 4865942.279503176,
"spatialReference": {
"wkid": 102100,
"latestWkid": 3857
}
},
"fullExtent": {
"xmin": 7792364.355529149,
"ymin": -7.081154551613622e-10,
"xmax": 16697923.618991036,
"ymax": 4865942.279503176,
"spatialReference": {
"wkid": 102100,
"latestWkid": 3857
}
},
"documentInfo": {
"Title": "",
"Author": "",
"Comments": "",
"Subject": "",
"Category": "",
"AntialiasingMode": "None",
"TextAntialiasingMode": "Force",
"Keywords": ""
},
"datumTransformations": null
}
其实在这些数据中影像图片到地图上加载的参数就是fullExtent、initExtent、spatialReference、units,所以在新定义的PictureLayer中新添加了pictureExtent、units两个属性,spatialReference是图层本身就有的属性。
在图层load的时候就把图层的元数据构建好并赋值给this.sourceJSON。
c.prototype.load =function (a) {
this.sourceJSON = this._setupSourceJSON(this.spatialReference, this.pictureExtent, this.units);
var b = this, c = h.isSome(a) ? a.signal : null;
this.addResolvingPromise(this.loadFromPortal({supportedTypes: ["Map Service"]}, a).then(function () {
return b._fetchService(c);
}));
return this.when();
};
c.prototype._setupSourceJSON = function (spatialReference, extent, units) {
let json = {
currentVersion: "10.7",
serviceDescription: "",
spatialReference: spatialReference,
initialExtent: extent,
fullExtent: extent,
units: units,
……
};
return json;
};
前端生成图片
首先要计算图片是否在当前地图显示区域
在fetchImage函数里传了一个参数a
,它实际上就是当前地图范围,另外在图层属性中定义了图片的显示范围,那么在fetchImage函数中做的第一件事就是判定这两个范围是否有重叠部分,如果有侧计算出重叠的范围。
设定两个变量geo_map_extent{xmin,ymin,xmax,ymax}、geo_picture_extent{xmin,ymin,xmax,ymax},以上面的示意图来看当两个变量相应值的最大值比最小值小或最小值比最大值 大,那么这两个范围间没有重叠区域,而在之外的情况下,两个范围是有重叠的。所以判定是否有重置区域的函数定义如下:
c.prototype.isRectCross = function (a, c) {
return (a[0] > c[2] || a[2] < c[0] || a[1] > c[3] || a[3] < c[1]) ?
false :
true;
};
a、c为两个数组变量,结构为:[xmin,ymin,xmax,yma]
根据图形重置关系,计算重叠区的函数定义如下:
c.prototype.crossRect = function (a, c) {
let left = Math.max(a[0], c[0]);
let right = Math.min(a[2], c[2]);
let top = Math.min(a[3], c[3]);
let bottom = Math.max(a[1], c[1]);
return [left, bottom, right, top];
};
根据重叠范围生成图片
图片生成最终是调用了canvas.drawImage方法
context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height);
参数 | 描述 |
---|---|
img | 规定要使用的图像、画布或视频。 |
sx | 可选。开始剪切的 x 坐标位置。 |
sy | 可选。开始剪切的 y 坐标位置。 |
swidth | 可选。被剪切图像的宽度。 |
sheight | 可选。被剪切图像的高度。 |
x | 在画布上放置图像的 x 坐标位置。 |
y | 在画布上放置图像的 y 坐标位置。 |
width | 可选。要使用的图像的宽度。(伸展或缩小图像) |
height | 可选。要使用的图像的高度。(伸展或缩小图像) |
第一步:计算出原始图片和生成的图片单位像素的地理距离
使用地图、图片的地图范围和地图图版、原始图片的尺寸计算出单位像素的地理距离,代码如下:
/**计算原始图片单位像素的地理距离*/
let mapBox = [a.xmin, a.ymin, a.xmax, a.ymax];
let imgBox = [pl.pictureExtent.xmin, pl.pictureExtent.ymin, pl.pictureExtent.xmax, pl.pictureExtent.ymax];
let image = new Image();
image.src = pl.url;
let imgWidth = image.width;
let imgHeight = image.height;
let imgDx = imgWidth / (imgBox[2] - imgBox[0]);
let imgDy = imgHeight / (imgBox[3] - imgBox[1]);
/**计算地图图片单位像素的地理距离*/
let mapDx = width / (mapBox[2] - mapBox[0]);
let mapDy = height / (mapBox[3] - mapBox[1]);
第二步:计算出地图图版上的绘制范围与图片上的裁切范围
let crossBox = pl.crossRect(mapBox, imgBox);
/**计算地图图片的绘制区域*/
let imgLeft = Math.ceil(imgDx * (crossBox[0] - imgBox[0]));
let imgRight = Math.ceil(imgDx * (crossBox[2] - imgBox[0]));
let imgTop = Math.ceil(imgDy * (imgBox[3] - crossBox[3]));
let imgBottom = Math.ceil(imgDy * (imgBox[3] - crossBox[1]));
/**计算原始图片的裁剪区域*/
let mapLeft = Math.ceil(mapDx * (crossBox[0] - mapBox[0]));
let mapRight = Math.ceil(mapDx * (crossBox[2] - mapBox[0]));
let mapTop = Math.ceil(mapDy * (mapBox[3] - crossBox[3]));
let mapBottom = Math.ceil(mapDy * (mapBox[3] - crossBox[1]));
第三步:在地图图版上绘制图片
let data = new Image(); //返回的数据
data.crossOrigin = "Anonymous";
data.alt = "map-picture";
canvas.context.drawImage(image, imgLeft, imgTop, imgRight - imgLeft, imgBottom - imgTop, mapLeft, mapTop, mapRight - mapLeft, mapBottom - mapTop);
data.src = overlayCanvas.toDataURL("image/png");
resolve(data);
如果图片与当前地图返回没有重叠,那么返回一张透明的空间图片,
let data = new Image(); //返回的数据
data.crossOrigin = "Anonymous";
data.alt = "map-picture";
canvas.context.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
data.src = overlayCanvas.toDataURL("image/png");
resolve(data);
以上就是实现地图加载图片显示的思路了,完整代码及使用示例到Github上获取吧 ags-picture-layer 。