@Lenciel

打造一张自驾地图(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 的海报比,这张地图上缺少的东西包括:

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

我还想加的东西包括:

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

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

江湖儿女(15) - 如何快速进入新角色并成功

I)TL;DR

i4cp 前年对100多个公司几千人做了一个调研,发现 49% 进入新角色的人失败了

在 i4cp 的另一项调查中,只有 44% 的受访者表示他们的组织有努力做各个岗位的 onboarding,其中又有 88% 的受访者表示,这些努力设计的 onboarding 流程其实没有那么有用

很显然,不管你是跳槽到新公司干着熟悉的岗位,还是在组织内转岗干了别的职能,指望公司的 onboarding 流程,很难成功。但这两年,偏偏大环境不好。无论你在什么层级,哪怕公司还在,岗位没变,你的工作内容可能也发生了很大变化:人人都在进入新的角色。。

今天就聊聊进入新角色难在哪里,如何通过关注几个要点构建自己的网络,来提高成功率。

II)Why

国外的管理学院几年前就提出,现在是一个超协作(Hyper-Collaborative)的工作环境:几乎所有的脑力工作者,都会花 85% 甚至更多的时间用于协作活动上:电话、邮件、会议、聊天工具。

这是 onboarding 流程失效的核心原因:那些规定、制度、福利、章程和企业文化方面的讲解,目的是让你这个外来的个体,快速融入组织。

但要想取得成功,不能被动地「融入」组织,因为它有时候会吞噬你。在协作没有那么重要的行业或者时代,你可以按照自己的性格和偏好,慢慢构建自己的网络。现在,你必须迅速建立自己的网络,下面我们就来说说怎么做。

III)How

1)搞清楚非正式组织架构的核心节点

每个公司有正式的公布的组织架构,还有一个「非正式的」组织架构它的节点是各个部门的实权人物和意见领袖。

我接手过很多完全没有管理过的职能部门:市场、销售、供应链、服务…接手的第一件事,都是密集的 1-1。首先是部门里面的每个人,然后是协同部门包括我自己的上级。除开了解组织的运作方式和每个人最紧迫的目标和困扰,我会问两个非常固定的问题:

  • 你觉得我应该去跟谁多聊聊?
  • 你觉得这个部门里面大家对能力和品行都比较认可,值得依赖,或者说对业务有真正影响力的人是谁?

你很快就会搞清楚一个涵盖自己的团队、平行部门、中后台相关部门的关键人物的网络。这张网络里每个人对你只会有两种力:积极的和消极的我们的工作中很重要也许是最重要的部分,就是确保自己拿到尽可能多积极的力

2)产生拉力

成功并不仅仅取决于直属上下级和关键相关方给力。一开始觉得没有那么重要的人,比如上级的某个副手,可能比你更清楚上级的目标、兴趣、动机和日程安排。比如中后台职能的同事,可能会成倍的减少你完成某个任务的工作量或者时间。

那么怎么让大家之间产生这种积极向上的拉力?有一些书是专门讲这个的,比如《the Power of Pull》,但我看过之后总觉得有的虚。我自己就是两个原则:

充分展示,积极倾听:展示你的目标,展示你可以做什么,展示你的弱点,展示你需要的帮助。然后多听,了解他人的目标、需求、想法和兴趣。

我们进入新环境时容易犯的毛病是过度推销自己。讲述自己的技能或者经验之前不妨都问问自己:这对跟我聊天的人有用吗?还是只是为了让对方更欣赏我?如果是后者,就不用说。

积极共创,追求利他:公司里面的协作大部分时候不是零和游戏。往往你解决了其他人的问题,你的问题也就解决了。前面那个步骤如果你做得足够好,就会找到很多可以和大家一起创造共同成功的目标。

3)创造价值和规模

构建网络和产生拉力,是不是搞权术或者小团体?有一个关键区别,就是在这个网络里面你创造的是规模化的价值,还是废话和八卦。

进入新角色往往意味着你有技能上的缺失。很多人要不是选择视而不见,要不是试图虚张声势,千万不要这样

一方面你要清楚自己的特长。你被接纳进入新角色一定是因为你有过人之处,迅速发挥它们,创造价值。

另一方面,搞清楚自己薄弱的地方。哪些是自己要补的,哪些通过招聘下属来解决。做到在原有基础上不断增值自己,本来就是进入新角色最大的收益。

最后,规模很重要。我们需要网络,是因为我们需要价值创造有规模效应。这个可能值得专门花一篇文章来写,但你先得有一个概念,就是你花了这么多时间构建这个网络,绝不是为了完成那点儿 KPI 或者 OKR。你应该调动大家一起去为一个更有价值、更有规模、更有意义的目标努力。

以上。