本答案基于以下资源:
这三个资源也相互关联。理解这三个示例将有助于理解下面我的示例中发生的情况。
此答案还以我正在缓慢进行的构建瓦片库为基础。
本答案的目标不是呈现一个最终的资源,而是展示如何组合相关信息的粗略演示。随着我的思考进一步深入,答案也会不断发展。
Web墨卡托瓦片
一张横跨360度经度和约170度纬度(+/- 85度)的墨卡托地图将填满一个正方形(超过85度纬度会导致失真失控,而极点在投影平面上是+/-无穷大,因此不建议包括极点)。
对于一个Web地图服务(使用墨卡托瓦片),这个世界的大部分正方形是缩放级别0。该地图横跨2^0个正方形并且高度也是2^0个正方形。
如果我们将该正方形划分为两个正方形乘以两个正方形的网格,则获得缩放级别1。该地图是2^1乘以2^1个正方形。
因此,缩放级别决定了地图的宽度和高度:2^缩放级别。如果每个正方形的像素大小相同,则每增加一个缩放级别,世界的像素宽度就会增加2倍。
幸运的是,北纬约85度以上没有陆地,而我们很少想显示南极洲,因此这个正方形适用于大多数Web地图应用程序。但是,这意味着如果我们将Web墨卡托瓦片重新投影到任何显示在这些纬度以上的内容,我们将会有一个空白区域:
![world map with bald spot](https://istack.dev59.com/UXeKZ.webp)
Web-Mercator投影的瓦片被复制以用于Mercator投影,该投影已经旋转以显示北极点。
最后,Web Mercator瓦片在相对于瓦片的可预测和规则投影空间中呈现。如果我们重新投影瓦片,则可能会扩大或缩小每个瓦片的投影空间,我们应该注意这一点。在上面的图像中,北极周围的瓦片被重新投影为比南部更小的瓦片。
投影后,瓦片不一定是均匀大小的。
Plate Carree的重新投影和重采样
重新投影网络服务瓦片的最大挑战是时间,不仅是了解投影并阅读像这样的答案所花费的时间。
投影函数是复杂的、耗时的操作,必须对呈现的每个像素进行处理。我看到的所有d3示例都使用了 此处 所见的过程(或类似变体)来进行实际的重投影和重采样。只有使用Plate Carree投影的原始图像才能使用此示例。该过程如下:
- 创建一个新的空白图像。
- 对于新图像中的每个像素,取其像素位置并反转它(使用所需的投影),以得到纬度和经度。
- 确定原始图像中与该经度和纬度重叠的像素。
- 将原始图像中该像素的信息分配给新图像中适当的像素(即步骤2中的像素)。
如果原始图像使用平面柱面投影,则不需要使用d3-geoProjection,投影坐标和未投影坐标之间的关系是线性的。例如:如果图像高度为180像素,则每个像素代表1度的纬度。这意味着步骤3与步骤2和projection.invert()相比并不需要很长时间。以下是Mike的第3步函数:
var q = ((90 - φ) / 180 * dy | 0) * dx + ((180 + λ) / 360 * dx | 0) << 2;
第二步所需的时间与重新投影图像所使用的投影方式有关。我看到的所有示例都在上述列表中的第二步中使用了d3.geoProjection.invert()
——它可以将新图像中的像素位置转换为其纬度和经度。并非所有投影方式都是相同的。圆柱形投影通常比圆锥形投影效果更好,而圆锥形投影则通常优于方位投影。我还发现,在投影.invert()的计算时间方面,d3v4和d3v5之间存在一些奇怪的速度差异:
![relative time for projection.invert](https://istack.dev59.com/wY50f.webp)
Time to unproject a point on a map with D3 (convert from pixels to lat/long). It is unclear why D3v4 would be faster.
And for completeness, here's a wider range of projections found in d3-geo-projections:
![enter image description here](https://istack.dev59.com/OU1Pf.webp)
Comparing time for projection. Invert for additional projections.
Notes.
这种方法可能会遇到这里描述的问题,这些问题可以通过那里的答案来解决 - 但不同的解决方案将需要额外的处理时间。
这种方法使用最近邻居方法 - 这可能会导致质量问题。更高级的采样,如双线性或立方,将增加处理时间,但可能会产生更理想的图像。
如果基础图像有文本,则文本可能会被旋转或以其他方式操作,使其难以阅读。
Mike的示例是针对单个图像的,而对于瓷砖,则程度上会有所改变,我们现在正在创建多个图像,这需要知道每个原始瓷砖的边界以及每个重新投影的瓷砖的边界(以度为单位)以及前者的瓷砖单位和后者的像素 - 细节问题。
Web墨卡托投影和重采样
当我开始研究这个问题时,我参考了Alan McConchie的
solution/example。花了一段时间才注意到,在此示例中的第三步(我相信Jason Davies的工作也是如此),在重采样中没有考虑Web墨卡托瓦片,只考虑了确定瓦片边界。但是,y轴上像素之间的关系不再是像Plate Carree中那样线性的。
这意味着瓦片放置在正确的位置,但在每个瓦片内部,采样处理y轴时将其视为线性。当使用低级别的瓦片缩放级别(位于瓦片中央/上方)显示整个世界时,这种失真最为明显,并且可能是Alan提到奇怪压缩时所说的。
解决方法是在上述第3步中为每个纬度/经度对正确投影纬度。这会增加时间 - 该函数涉及Math.atan和Math.exp,但差异不应太大。在Alan和Jason的工作中,这是使用一个简单的公式完成的(但仅用于瓦片边界,而不是每个像素)。
Math.atan(Math.exp(-y * Math.PI / 180)) * 360 / Math.PI - 90;
在我的下面的例子中,我只是使用了
d3.geoMercator()
来制作更清晰的比例因子,使用投影包括一个额外的操作来转换x坐标。
否则,4个步骤的过程仍然相同。
找到合适的瓦片
我只看到一种干净的方法来找到要显示的瓷砖,Jason Davies的d3.quadTile,可以在
这里看到。
我相信Alan McConchie使用未经压缩的版本,可能会被修改。还有
这个github库,提供另一个非常类似的d3.quadTiles版本。
对于McConchie/Davies,d3.quadTile将根据剪辑范围(而不是剪辑角度)和瓦片深度给出一个投影,从而提取所有与视图范围相交的瓦片。
在Alan McConchie的
solution/example中,缩放级别基于投影比例尺 - 但这未必是最明智的选择:每个投影都有不同的缩放因子,一个比例尺为100的比例尺在另一个比例尺上显示的范围将会有所不同。此外,在柱面投影中,比例尺值与地图大小之间的关系可能是线性的,而非柱面投影可能具有非线性的地图大小和比例尺之间的关系。
我稍微修改了这种方法 - 我使用一个比例因子来确定初始瓷砖深度,然后如果d3.quadTile返回的瓷砖数超过一定数量,就会减少该瓷砖深度。
geoTile.tileDepth = function(z) {
var a = [w/2-1,h/2];
var b = [w/2+1,h/2];
var dx = d3.geoDistance(p.invert(a), p.invert(b)) ;
var scale = 2/dx*tk;
var z = Math.max(Math.log(scale) / Math.LN2 - 8, 2);
z = Math.min(z,15) | 0;
var maxTiles = w*h/256/128;
var e = p.clipExtent();
p.clipExtent([[0,0],[w,h]])
while(d3.quadTiles(p, z).length > maxTiles) {
z--;
}
p.clipExtent(e);
return z;
}
然后,使用d3.quadTile函数提取相关的瓦片:
geoTile.tiles = function() {
var z = geoTile.tileDepth();
var e = p.clipExtent();
p.clipExtent([[-1,-1],[w+1,h+1]])
var set = d3.quadTiles(p, Math.max(z0,Math.min(z,z1)));
p.clipExtent(e);
return set;
}
起初我认为从多个缩放深度中提取瓷砖(以解决重新投影瓷砖大小不一致的问题)是理想的方法,但这将遇到栅格中线条粗细以及不连续注释等问题。
采用Jason和Alan的工作
我使用
geoTile.tiles()
生成上面的瓦片集,并使用瓦片坐标(在瓦片坐标、行、列、缩放深度中)作为键值,将
image
元素附加到父
g
或
svg
中,通过输入/更新/退出循环运行。加载图像时,一旦图像加载完成,我们调用一个onload函数来进行实际的重投影。这与Jason和Alan的代码基本相同,我解决了以下挑战:
- 重采样没有考虑Web墨卡托(如上所述)
- 瓦片深度选择不当(如上所述)
- 瓦片被重新投影为放置在div中的canvas,而不是SVG——创建了两个父容器,一个用于每种类型的特征:瓦片或矢量。
我相信我的示例已经解决了这些问题,只需要进行非常小的调整。我还添加了一些更详细的注释以供查看。
function onload(d, that) {
var mercatorCanvas = d3.create("canvas")
.attr("width",tileWidth)
.attr("height",tileHeight);
var mercatorContext = mercatorCanvas.node().getContext("2d");
mercatorContext.drawImage(d.image, 0, 0, tileWidth, tileHeight);
var k = d.key;
var tilesAcross = 1 << k[2];
var webMercator = d3.geoMercator()
.scale(tilesAcross/Math.PI/2)
.translate([0,0])
.center([0,0])
var reprojectedTileBounds = path.bounds(d),
x0 = reprojectedTileBounds[0][0] | 0,
y0 = reprojectedTileBounds[0][1] | 0,
x1 = (reprojectedTileBounds[1][0] + 1) | 0,
y1 = (reprojectedTileBounds[1][1] + 1) | 0;
var λ0 = k[0] / tilesAcross * 360 - 180,
λ1 = (k[0] + 1) / tilesAcross * 360 - 180,
φ1 = webMercator.invert([0,(k[1] - tilesAcross/2) ])[1],
φ0 = webMercator.invert([0,(k[1] + 1 - tilesAcross/2) ])[1];
var newCanvas = d3.create("canvas").node();
newCanvas.width = x1 - x0,
newCanvas.height = y1 - y0;
var newContext = newCanvas.getContext("2d");
if (newCanvas.width && newCanvas.height) {
var sourceData = mercatorContext.getImageData(0, 0, tileWidth, tileHeight).data,
target = newContext.createImageData(newCanvas.width, newCanvas.height),
targetData = target.data;
for (var y = y0, i = -1; y < y1; ++y) {
for (var x = x0; x < x1; ++x) {
var pt = p.invert([x, y]), λ = pt[0], φ = pt[1];
if (λ > λ1 || λ < λ0 || φ > φ1 || φ < φ0) { i += 4; targetData[i] = 0; continue; }
var top = (((tilesAcross + webMercator([0,φ])[1]) * tileHeight | 0) % 256 | 0) * tileWidth;
var q = (((λ - λ0) / (λ1 - λ0) * tileWidth | 0) + (top)) * 4;
targetData[++i] = sourceData[q];
targetData[++i] = sourceData[++q];
targetData[++i] = sourceData[++q];
targetData[++i] = 255;
}
}
if(target) newContext.putImageData(target, 0, 0);
}
d3.select(that)
.attr("xlink:href", newCanvas.toDataURL())
.attr("x", x0)
.attr("width", newCanvas.width)
.attr("height",newCanvas.height)
.attr("y", y0);
}
将其放入更大的结构中。
具有覆盖功能的常规瓷砖地图具有几个坐标系:
- 瓦片单位(3D),它标记每个瓦片的列、行和缩放级别(分别为x、y、z)
- 地理坐标(3D),它标记三维球体上点的纬度和经度。
- 缩放单位(3D),它跟踪缩放平移(x、y)和缩放比例(k)。
- 投影单位(2D),像素单位,其中纬度和经度被投影到。
任何滑动地图的目标都是在可用的系统中统一这些坐标。
当我们重新投影瓷砖时,我们需要添加一个坐标空间:
我觉得示例没有很清楚地展示所有坐标系的联系。因此,我将上述方法放在了一个geoTile对象中,正如你可能已经看到的,该对象来自于一个
瓦片库的个人项目。这样做的目的是为了更加平滑地协调不同的单元。我并不想推销它,实际上它仍在开发中(只是太忙了,无法完成它); 然而,我会尝试利用
d3-tile来制作一个示例,如果时间允许的话。
前进时面临的挑战
缩放速度和响应能力是我看到的最大的挑战。为了解决这个问题,我设置了缩放函数在缩放结束时触发——这在平移事件中最明显,因为通常平移会连续触发缩放函数,这可以通过转换现有图像来解决。然而,使用这种方式最可靠的方法是在静态地图上。对于已经绘制的图像实现平移将是处理平移事件的理想选择,而不是当前的重新采样方法。
动画这样的地图可能是不可能的。
也许有优化将像素转换为纬度的计算的空间,但这可能会很困难。
示例
不幸的是,代码太长了,无法在片段中展示,因此我制作了一些示例来演示。
这些内容只经过了最少的测试,如果我能完成基础瓦片库,我会为此目的派生它,同时它可以作为一个示例。代码的核心在于d3-reprojectSlippy.js文件中的
geoTile.tile()
,其中包含进入/更新/退出循环(相当基本)和上述的onload函数。由于我会在一边处理瓦片,所以我会保持这个答案的更新。
备选方案
重新投影瓦片很麻烦且耗时。如果可能的话,另一种选择是在所需的投影中生成瓦片集。这已经在
OSM tiles中完成,但同样麻烦和耗时 - 只适用于地图制作者,而不是浏览器。
简而言之
重新投影的墨卡托瓦片需要时间,你应该阅读上面的内容。