@Lenciel

打造一张自驾地图(2)

接着上一篇,继续画地图…

感觉上要把剩下的东西弄出来不是那么麻烦,但其实从这里开始,就没有太多 D3 自己原生支持的东西可以用,真的变成自己生成数据然后去「画」了。

这篇先说说等高线怎么画。

目录

数据获取和处理

获得原始数据

国内免费的等高线数据(DEM 数据)比较难找。

实际上,注册一个 NASA 的账号,就可以到 earthdata 上面去下载 ASTER GDEM 数据这是日本和美国在 2019 年联合发布的一个数据集。

search_earthdata_nasa

图 1. 查询感兴趣的位置并下载数据

下载的数据是 TIF 格式的,可以把它合并成一个文件:

gdal_merge.py refs/contour/*.tif -o refs/contour/new.tif

0...10...20...30...40...50...60...70...80...90...100 - done.

可以先到这个网站去检查一下合并后的 TIF 文件是不是包含了所有感兴趣的区域的 DEM 数据:

validation_new_projected_tif

图 2. 检查合并后的 TIF 覆盖范围

gdalsrsinfo 去查看这个文件的一些属性数据:

gdalsrsinfo refs/contour/new.tif

PROJ.4 : +proj=longlat +datum=WGS84 +no_defs

OGC WKT2:2019 :
GEOGCRS["WGS 84"
    ...
    ID["EPSG",4326]]

而前面准备的 GeoJSON 文件里面的声明是:

{
	"type": "FeatureCollection",
	"name": "merged",
	"crs": { 
		"type": "name",
		"properties": { "name":"urn:ogc:def:crs:OGC:1.3:CRS84" } 
	},
	...

它们的坐标体系一不一样?是不是需要对齐?

数据对齐

WGS84

WGS84 是「World Geodetic System 1984」的缩写,它可能会带来最多的困惑。因为它实际上由四个东西组成:

  • 一个椭球体:由于地球不是完美的球形,地图需要创建一个近似地球曲率的椭球模型。各种系统根据该椭球体的形状而有所不同——赤道处的半径和两极处的平坦度是两个主要差异。所以如果上下文是关于这个的,那么 WGS84 可以简单理解为一个特定形状的椭球体。
  • 一个水平基准:水平基准用来描述如何用坐标系的两根轴来定义经纬度。通常会把赤道作为零线来描述南北(纬度),把格林威治子午线作为零线来描述东西(经度)。所以 WGS84 还可以指特定形状椭球体以及它上面锚定的锚点系统。
  • 一个垂直基准:地球上的点相对于 WGS84 定义的椭球体还会有高度上的起伏。因此 WGS84 还定义了一个用来计算这个高度差的参考水平面,这个就是垂直基准。比如有时候在无人机上拍照,会有一个 WGS84 海拔高度,这其实就是用这个基准计算的。
  • 一个坐标系:最后,我们看到的 WGS84 也可能是在说一个完整的「地理坐标系」。一个地理坐标系由「水平基准+零线+角度单位」构成,并且在 EPSGEPSG 是欧洲石油测量组织(European Petroleum Survey Group),专门维护了一个庞大的数据库,让每个坐标系、椭球体和单位都被分配唯一的编号,便于使用和转换。 系统里面还有一个唯一的码号:4326。人们经常说 GPS 导航是基于 WGS84 的,说的其实就是 EPSG:4326。

再看看 gdalsrsinfo 命令的完整输出,会发现它很显然是一个坐标系的定义:

gdalsrsinfo refs/contour/new.tif

PROJ.4 : +proj=longlat +datum=WGS84 +no_defs

OGC WKT2:2019 :
GEOGCRS["WGS 84",
    ENSEMBLE["World Geodetic System 1984 ensemble",
        MEMBER["World Geodetic System 1984 (Transit)"],
        MEMBER["World Geodetic System 1984 (G730)"],
        MEMBER["World Geodetic System 1984 (G873)"],
        MEMBER["World Geodetic System 1984 (G1150)"],
        MEMBER["World Geodetic System 1984 (G1674)"],
        MEMBER["World Geodetic System 1984 (G1762)"],
        MEMBER["World Geodetic System 1984 (G2139)"],
        # 这里是椭球体的定义
        ELLIPSOID["WGS 84",6378137,298.257223563,
            LENGTHUNIT["metre",1]],
        ENSEMBLEACCURACY[2.0]],
    # 这里是零线和角度单位的定义
    PRIMEM["Greenwich",0,
        ANGLEUNIT["degree",0.0174532925199433]],
    CS[ellipsoidal,2],
        AXIS["geodetic latitude (Lat)",north,
            ORDER[1],
            ANGLEUNIT["degree",0.0174532925199433]],
        AXIS["geodetic longitude (Lon)",east,
            ORDER[2],
            ANGLEUNIT["degree",0.0174532925199433]],
    USAGE[
        SCOPE["Horizontal component of 3D system."],
        AREA["World."],
        BBOX[-90,-180,90,180]],
    # 这里是 EPSG 的代码,4326
    ID["EPSG",4326]]
SRS 与 CRS

然后, GeoJSON 里的 CRS:84 是什么?

在处理 GIS 信息的时候,会大量看到 CRS(Coordinate Reference System)和 SRS(Spatial Reference System),字面上理解它们分别是「坐标参考系」和「空间参考系」。

但就像前面说的,WGS84 表示的东西可能在这两个体系里来回跳。比如 EPSG:4326 是一个 WGS84 定义的 CRS,它由 WGS84 Geodetic Datum(EPSG:6326)和 椭球体坐标系统(EPSG:6422)组成,后面两者都是 SRS 体系的。

CRS:84,实际上跟 EPSG:4326 是对齐的

另外,对于不同的 geodetic CRS,例如 OSGB 1936 (EPSG:4277),使用相同的投影参数,是可以得到一个有效的坐标的。但这类 CRS 大部分会被赋予较高的 EPSG 编号,因为它们经常是为了特殊用途临时的 ad-hoc,并未被 EPSG 正式采用。比如前面画地图用的投影方法,是 Google 的 Web Mercator,一开始就编号为 EPSG:900913,直到它被采用为 EPSG:3857目前除开 Google 地图, CARTO、Mapbox、Bing Maps、OpenStreetMap 和 Esri 等等都是用这个标准。要处理小范围的数据并且保留要素的形状,EPSG:3857 一般都是正确的选择。

EPSG:4326 和 EPSG:3857

现在手里的等高线数据是 EPSG:4326 的,GeoJSON 是 CRS:84 的,然后画图又用的是 EPSG:3857。要不要做一次转换?实际上,下面的命令是可以运行的,转换出来的数据也是对的:

gdalwarp -s_srs "+proj=longlat +datum=WGS84 +no_defs" -t_srs EPSG:3857 refs/contour/new.tif refs/contour/new_projected.tif

但需要理解,转换后的数据其实已经从地理坐标(经纬度)变成了平面坐标(x , y),并且采用的投影方式是 Web Mercator 。如果还要完成大量基于经纬度的操作,这个转换可能就做早了GDAL 和 D3 都提供了很多基于经纬度操作数据的辅助函数,包括各种投影的 wrapper,就是这个原因。

比如目前等高线范围是个长方形。如果要基于 GeoJSON 里面定义的行政区域边界去做一个裁剪,使用这个转换后的 TIF,就需要把 GeoJSON 里的边界也算成投影后的数值,再绘制边界进行切割,这肯定是整麻烦了。

裁剪目标区域

以凉山自治州的边界来裁剪等高线图为例。首先获取 GeoJSON 数据的 Extent:

ogrinfo refs/raw_data/main/凉山彝族自治州边界.geojson -so -al | grep Extent

Extent: (100.060168, 26.049272) - (103.875427, 29.306115)

然后就可以在这个矩形范围内进行裁剪了:

gdal_translate -a_ullr 100.060168 26.049272 103.875427 29.306115 refs/contour/new.tif refs/contour/ls_box.tif

gdalwarp -cutline refs/raw_data/main/凉山彝族自治州边界.geojson refs/contour/ls_box.tif refs/contour/ls_cutout.tif

Creating output file that is 7746P x 6612L.
Processing refs/contour/ls_box.tif [1/1] : 0Warning 1: the source raster dataset has a SRS, but the cutline features not.  We assume that the cutline coordinates are expressed in the destination SRS.
If not, cutline results may be incorrect.
...10...20...30...40...50...60...70...80...90...100 - done.

这里的警告信息其实是因为 GeoJSON 里面没有显式声明 SRS(虽然它们是对齐的)。如果有洁癖,可以编辑 GeoJSON 把它加上。

生成、调试与合并

因为高度差比较大,不想线太密,所以取了 150 米的步长:


gdal_contour -a elev -3d -i 150.0 -f "GeoJSON" refs/contour/ls_cutout.tif  refs/contour/contours_3d_ls_box.geojson

0...10...20...30...40...50...60...70...80...90...100 - done.

liangshan_dem

图 4. 凉山州原始等高线

把凉山、丽江和攀枝花的等高线都用这个办法生成之后,可以在 Mapshaper 上进行拼接并简化如果只是拼接命令行就够了。这里核心是要用 Mapshaper 提供的「simplify」功能,把等高线的数据做一些简化,不然得到的地图上就密密麻麻全是等高线了。

merge_dem_geojson

图 5. 合并后的原始等高线

绘制等高线

有了等高线的 GeoJSON 数据,可以做各种样式的绘制。甚至我觉得用 blender 渲染成 Fjelltopp 的那种 3D 海报都是可以的:

fjelltopp_poster_3

图 6. Geiranger 海报

但这个可以改天来,还是先回到我们的性冷淡风地图。不要使用 D3 提供的类似 d3.contours() 方法,而直接用同样的风格和 clipPath 来绘制:


// contour
drawPath(geoData[4], {
    projection: geoProjectionLS,
    stroke: COLOR_CONTOURS,
    strokeWidth: 0.2,
    clipId: OUTTER_CLIP_FRAME_ID,
    // shadowId: SHADOW_FILTER_ID,
    mapRectId: MAIN_MAP_ID
});

得到的带等高线的地图如下:

how_to_map_7.png

图 7. 带等高线的地图

接下来还需要完成:

  • 区域的地名;
  • 比例尺等图例;
  • 实际的自驾轨迹;
  • 关键景点的标记;

打造一张自驾地图(1)

这是「兔子洞」 系列之,如何画地图的第一部分…

前几天瞎逛,看到个北欧的公司 Fjelltopp,它的海报作品很有设计感:

fjelltopp_poster_1

图 1. 赫马万和塔纳比山谷

fjelltopp_poster_2

图 2. Galdhøpiggen 的等高线

当我看到它网站上的口号时人家的口号是:「Find your favorite mountain and bring the serenity of the mountains home with you.」 ,就想:「木里周围海拔差也很大,说不定,我也可以画出一些挺好看的地图…」。

事后发现,这个兔子洞还真是够深的。

目录

Toolchain

一些 all-in-one 的 GIS 软件,比如 QGIS,显然是能拿来做地图的。但是如果对里面大量的要素(包括比例、中心位置、不同元素的颜色、湖泊和边界有没有阴影等等)都希望自己可控,使用一个陌生而复杂的软件就很容易让人感觉无从下手。

因此我决定用 D3 :虽然少了很多脚手架,但是好处是可以在 SVG 或者 Canvas 上一点点把地图画出来,整个过程完全是通过代码就可以控制的。

至于具体是 SVG 还是 Canvas,在这个场景里面偏个人喜好。比如我就很喜欢 Canvas 里可以用类似于context.shadowBlurcontext.shadowColor 这样的声明直接控制阴影宽度和大小,但我很不喜欢 Canvas 里做各种切割和覆盖的时候要自己去控制 saverestore 的顺序。

反正最后选了 SVG。

准备画布

海报一般按照长、宽和 DPI 来确定画布的大小和清晰度。对应到屏幕上的画布,需要一个从折算。

比如,我希望以 300 的 DPI 来打印这张海报,海报的大小是 60cm x 70cm,留的边框是 3cm,那么应该是这么去声明:


const W = 708.7 //60cm
const H = 826.8 //70cm
const CM = 11.8 //300dpi下的1cm

const MARGIN = 3 * CM
const WIDTH = W - 2 * MARGIN
const HEIGHT = H - 2 * MARGIN

然后用这些数值来画出画布和画框。这里用 clipPath 去声明画框是因为后面绘制的超出画框范围内的东西可以更方便地切掉:


function drawFrame(clipId) {

    const g = svg.append("g").attr("id", MAIN_MAP_ID + '-frame')
    g.append("clipPath")
        .attr("id", clipId)
        .append("rect")
        .attr("x", MARGIN)
        .attr("y", MARGIN)
        .attr("width", W - 2 * MARGIN)
        .attr("height", H - 2 * MARGIN)
    g.append("rect")
        .attr("x", MARGIN)
        .attr("y", MARGIN)
        .attr("width", W - 2 * MARGIN)
        .attr("height", H - 2 * MARGIN)
        .attr('fill', COLOR_INNER_BACKGROUND);
}

得到的是下面的结果:

图 3. 70cm x 60cm 画布

绘制主要行政区域

这次自驾主要是在「泸沽湖-木里」这条线上,在丽江、凉山、攀枝花三个行政区域内,所以首先要把这片区域画出来。

D3 的 geo 模块是专门处理相关功能的模块,使用之前需要搞清楚三个东西:

  • Data
  • Projection
  • Path Generator

Data

D3 原生支持的数据格式是 GeoJSON。如果下载的是 TopoJSON可以理解为加入了拓扑信息的 GeoJSON 的扩展。 ,D3 提供了一些帮助函数

另外,无论 GeoJSON 还是 TopoJSON,对它们的切割和合并最好不要直接编辑文件,尽量使用 GDAL这是一个 OSGEO 基金会的开源项目,提供了很多操作数据的函数。 这样的包,里面已经处理了各种 edge case。

这里,我先在 OSM 上下载了三个区域的 GeoJSON 数据。

每个 GeoJSON 里面的原子数据集是一个个的 feature,每个 feature 主要是两部分数据:

  • geometry:多边形和点构成;
  • properties:各种附加信息,比如名称、ID、数据;

D3 已经处理了大部分 GeoJSON 的细节,所以只需要对它有基础了解就可以使用数据了。

最新版的 D3 支持了 Promise,所以加载数据可以用下面的方式:


var promises = [];

DATA_FILES.forEach(function (url) {
    promises.push(d3.json(url))
});

Promise.all(promises).then(function (values) {
    //do something with the data values
});

另外,虽然你可以单独加载和绘制每个区域,用 GDAL 里面提供的 ogrmerge.py 合并这几个城市的数据会更好:


> ogrmerge.py *.geojson -o merged_distinct.geojson -f GeoJSON -single`

Projection

要在一个平面上绘图,就需要一个从纬度/经度坐标到 (x,y) 坐标的转换,这就是一个 projection 函数。

问题是,人类已经发明了很多办法来干这个事情,光是 D3 支持的就有三大类总共十来种:

不存在完美的投影函数,因为在形状、面积、距离和/或方向这些要素里,每种算法都是对某个或某些属性精确度的选择和权衡的结果。在实际的选择中,除开去对应自己的需要,还得注意两个地方。

首先是适配数据本身的投影格式。比如打开我们下载的 GeoJSON 文件,可以看到它里面有一个WSG84的声明,这其实就对应了 geoMercator 的投影。如果你想要使用其他投影方式来展示这份数据,需要先通过工具改变它的投影(后面会说到)。

然后就是这个投影函数的一些可调参数,一般用来指定数据投影的范围等等。

有两种做法。一种是 D3 提供的 fitExtent 或者 clipExtent 函数,它们可以直接让你把数据用选中的投影函数填充或者是以某个路径切割成对应的形状:


var geoProjectionLS = d3.geoMercator()
          .fitExtent([[3 * MARGIN, 3 * MARGIN], [WIDTH, HEIGHT]], land);
//or
var geoProjectionLS = d3.geoMercator()
          .clipExtent([[MARGIN, MARGIN], [WIDTH, HEIGHT]], land);

另一种是直接指定 scale/center/translate/rotate 这些参数来更细颗粒度地控制:


const SCALE = 6587 // for print just increase this
const CENTER = [101.02, 26.53] // [lon,lat]

var geoProjectionLS = d3.geoMercator()
    .scale(SCALE * 1.5)
    .center(CENTER)
    .translate([WIDTH * 0.5, HEIGHT * 0.5])

Path Generator

这个函数负责将 GeoJSON 转换为 SVG 下的 path 。通常的做法是使用 d3.geoPath() 并指定一个 projection 来获得:

  var geoGenerator = d3.geoPath(projection);

最终渲染到 SVG 的时候我写了一个 warpper,封装掉细节,只暴露样式:

function drawMap(
  geoData,
  {
    width,
    height,
    marginTop = 1,
    marginLeft = 1,
    marginBottom = 1,
    marginRight = 1,
    padding = 30,
    projection = d3.geoIdentity().reflectY(true),
    fill = "none",
    stroke = "black",
    strokeWidth = 0.75,
    strokeLinejoin = "round",
    clipId = "none",
    mapRectId = "none",
    shadowId = "none",
    debug = false,
  } = {}
) {
  var geoGenerator = d3.geoPath(projection);
  var mapRect = d3.select("svg").append("g").attr("id", mapRectId);

  const canvas = mapRect.append("g").attr("class", "features");

  if (debug) {
    canvas
      .append("rect")
      .attr("fill", "none")
      .attr("stroke", "#f0f")
      .attr("x", marginLeft)
      .attr("y", marginTop)
      .attr("width", width - (marginLeft + marginRight))
      .attr("height", height - (marginTop + marginBottom));
    canvas
      .append("rect")
      .attr("fill", "none")
      .attr("stroke", "#f0f")
      .attr("x", marginLeft + padding)
      .attr("y", marginTop + padding)
      .attr("width", width - (marginLeft + marginRight + 2 * padding))
      .attr("height", height - (marginTop + marginBottom + 2 * padding));
  }

  canvas
    .append("path")
    .datum(geoData)
    .attr("clip-path", "url(#" + clipId + ")")
    .attr("filter", "url(#" + shadowId + ")")
    .attr("fill", fill)
    .attr("stroke", stroke)
    .attr("stroke-width", strokeWidth)
    .attr("stroke-linejoin", strokeLinejoin)
    .attr("d", geoGenerator);

  return Object.assign(mapRect, {
    props: {
      projection,
      width,
      height,
      marginTop,
      marginLeft,
      marginBottom,
      marginRight,
      padding,
      geoData,
      clipId,
      mapRectId,
    },
  });
}

到这一步,地图出来了:

how_to_map_2

图 4. 绘制主要区域

绘制水域和道路

OSM 或者国内的一些公开数据集里有到区县一级的道路和水系水域,下载之后可以直接使用。颜色上,蓝色表示水系水域,白色表示道路:

how_to_map_3

图 5. 绘制水域和道路

添加阴影

Fjelltopp 的地图之所以好看,一个很重要的原因是对阴影的使用。

在 SVG 上面添加阴影有很多办法。我选择先声明一个带 id 的阴影定义:

<svg>
    <defs>
        <filter id='shadow' color-interpolation-filters="sRGB">
            <feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.5" />
        </filter>
    </defs>
</svg>

这样我在调用 wrapper 的时候只需要通过是否指定,指定哪个 id 的阴影,就可以控制阴影的样式:

// lake
drawPath(geoData[1], {
    projection: geoProjectionLS,
    stroke: COLOR_WATER,
    strokeWidth: 0.5,
    clipId: OUTTER_CLIP_FRAME_ID,
    shadowId: SHADOW_FILTER_ID, //湖有阴影
    mapRectId: MAIN_MAP_ID,
})

// river
drawPath(geoData[2], {
    projection: geoProjectionLS,
    stroke: COLOR_WATER,
    strokeWidth: 0.5,
    clipId: OUTTER_CLIP_FRAME_ID,//河没有阴影
    mapRectId: MAIN_MAP_ID,
})

how_to_map_4

图 6. 添加阴影

绘制画中画

前面的地图应该给人一种下面很空的感觉。这其实是通过调整投影中心点专门空出来了一块,用来绘制画中画。

因为实际上主要的活动轨迹虽然横跨三个区域,但核心就在丽江宁蒗,凉山木里和盐源这几个地方。所以下面的位置用来放这个区域放大的画中画,以及一些图例。

整个画中画区域从形成边界到绘制道路和水系,跟主体类似。需要注意的是一些坐标的转换,因为这里投射的中心点,以及画框,都不是从常规意义上 (0,0) 坐标点开始的:


const MULI_FRAME_WIDTH = 20 * CM;
const MULI_FRAME_HEIGHT = 22 * CM;
const MULI_FRAME_MARGIN = 2 * CM;

const DEGREE_STEP_MULI = [0.5, 0.5]
const GRATICULE_INNER_PRECISION = 0.4

const CENTER_MULI = [101.03, 29.85] // [lon,lat]

const INNER_CLIP_FRAME_ID = 'id-inner-clip-frame'
const MULI_MAP_ID = 'id-muli-map'

var geoProjectionMuLi = d3.geoMercator()
    .scale(SCALE * 1.8)
    .center(CENTER_MULI)
    .translate([WIDTH * 0.22, HEIGHT * 0.24])
//for better control we use projection above instead of fitExtent
// .fitExtent([[3 * MARGIN, 3 * MARGIN], [WIDTH, HEIGHT]], land)

这样我们就可以得到一个带画中画的地图了:

how_to_map_5

图 7. 添加画中画

绘制经纬线和坐标轴

画框里面没有地图的部分,虽然会被最后加上的图例等等零星填充,但还是有大片空白,显得有点无聊,加上经线和纬线作为标记会精神很多。

这里核心的思路是通过指定起始点的坐标和间距,在画布上进行经纬线的绘制(为了美观画了一粗一细两根):

function drawBiGraticules(
  mapRect,
  {
    step = [1, 1],
    graticulePrecision = 2.5,
    stroke = "#999",
    minorStrokeWidth = 0.15,
    minorOpacity = 0.4,
    majorStrokeWidth = 0.25,
    majorOpacity = 0.6,
    debug = false,
  } = {}
) {
  const {
    projection,
    marginTop,
    marginRight,
    marginLeft,
    marginBottom,
    height,
    width,
    padding,
    mapRectId,
    clipId,
  } = mapRect.props;

  var graticuleRect = d3
    .select("svg")
    .append("g")
    .attr("id", mapRectId + "-graticule-rect");

  var geoGenerator = d3.geoPath().projection(projection);

  // Thinner lines at half the step intervals
  var graticulesMinor = d3
    .geoGraticule()
    .step([step[0] / 2, step[1] / 2])
    .precision(graticulePrecision)();

  graticuleRect
    .append("g")
    .attr("class", "graticules")
    .append("path")
    .attr("class", "minor-graticules")
    .attr("class", "graticule")
    .attr("clip-path", "url(#" + clipId + ")")
    .attr("stroke", stroke)
    .attr("fill", "none")
    .attr("stroke-width", minorStrokeWidth)
    .attr("opacity", minorOpacity)
    .attr("d", geoGenerator(graticulesMinor));

  // Thicker lines at step intervals
  var graticulesMajor = d3
    .geoGraticule()
    .step(step)
    .precision(graticulePrecision)();

  graticuleRect
    .append("g")
    .attr("class", "graticules")
    .append("path")
    .attr("class", "major-graticules")
    .attr("clip-path", "url(#" + clipId + ")")
    .attr("stroke", stroke)
    .attr("fill", "none")
    .attr("stroke-width", majorStrokeWidth)
    .attr("opacity", majorOpacity)
    .attr("d", geoGenerator(graticulesMajor));
}

并且,通过坐标点反算出经纬度,作为轴上显示的取值:

function viewportAsGeo({
  projection,
  width,
  height,
  marginTop = 0,
  marginRight = 0,
  marginLeft = 0,
  marginBottom = 0,
  precision = 2.5,
} = {}) {
  const p1 = [marginLeft, marginTop];
  const p2 = [width - marginRight, marginTop];
  const p3 = [width - marginRight, height - marginBottom];
  const p4 = [marginLeft, height - marginBottom];

  const vertices = [p1, p2, p3, p4, p1];
  const viewportSize = Math.min(
    width - marginRight - marginLeft,
    height - marginTop - marginBottom
  );
  const parts = Math.floor(viewportSize / precision);

  let lines = pairUp(vertices);

  lines = lines
    .flatMap(([p1, p2]) => partitionLine(...p1, ...p2, parts))
    .map((l) => l[0]);

  lines = [...lines, p1];
  return {
    type: "Polygon",
    coordinates: [lines.map((p) => projection.invert(p))],
  };
}

当然,这个值是数值形式的,直接用来显示不够美观,还需要格式化成我们常见的「时分秒+NSEW」的样式:


...

d.type === "longitude" ? formatLongitude(d[0]) : formatLatitude(d[1])
...

function formatLongitude(x) {
  return x < 0 ? formatDigtalToDMS(x) + "W" : formatDigtalToDMS(x) + "E";
}

function formatLatitude(x) {
  return x < 0 ? formatDigtalToDMS(x) + "S" : formatDigtalToDMS(x) + "N";
}

function formatDigtalToDMS(x) {
  deg = 0 | (x < 0 ? (x = -x) : x);
  min = 0 | (((x += 1e-9) % 1) * 60);
  sec = (0 | (((x * 60) % 1) * 6000)) / 100;
  return deg + "°" + min + "'" + sec + '"';
}

到这里就得到了一张下面的地图:

how_to_map_6

图 8. 增加经纬度和坐标轴

和 Fjelltopp 的海报比,这张地图上缺少的东西包括:

  • 等高线;
  • 区域的地名;
  • 比例尺等图例;

我还想加的东西包括:

  • 实际的自驾轨迹;
  • 关键景点的标记;

限于篇幅,放到下篇来记录。