40. 高级几何构造

``nyc_subway_stations``图层目前已经提供了许多有趣的案例,但其中存在一个显著特点:

_images/adv_geom0.jpg

虽然该数据库包含所有站点信息,但无法直观展示地铁线路!本章将利用PostgreSQL和PostGIS的高级功能,基于地铁站点的点图层构建全新的线性线路图层。

我们的任务尤其受到两个问题的困扰:

  • nyc_subway_stations 表中的``routes`` 列每行包含多个线路标识符,因此一个可能出现在多条线路中的站点在表中仅出现一次。

  • 与前一个问题相关,站点表中缺少线路顺序信息。虽然可以查询到某条线路上的所有站点,但无法通过现有属性确定列车经过各站点的运行顺序。

第二个问题更为棘手:给定一条线路上无序的站点集合,如何对它们进行排序以匹配实际线路走向。

以下是 'Q' 号线列车的站点列表:

SELECT s.gid, s.geom
FROM nyc_subway_stations s
WHERE (strpos(s.routes, 'Q') <> 0);

如图所示,各站点均标注有其唯一主键``gid`` 值。

_images/adv_geom1.jpg

若从任一终点站出发,线路上的下一站似乎始终是距离最近的站点。只要在搜索中排除已确定的站点,即可重复此过程完成全线排序。

在数据库中执行此类迭代计算有两种实现方式:

Common table expressions (CTE)的优势在于无需预定义函数即可执行。以下是通过CTE计算'Q'号线路径的示例,从最北端站点(``gid``为304的位置)开始构建线路。

WITH RECURSIVE next_stop(geom, idlist) AS (
    (SELECT
      geom,
      ARRAY[gid] AS idlist
    FROM nyc_subway_stations
    WHERE gid = 304)
    UNION ALL
    (SELECT
      s.geom,
      array_append(n.idlist, s.gid) AS idlist
    FROM nyc_subway_stations s, next_stop n
    WHERE strpos(s.routes, 'Q') != 0
    AND NOT n.idlist @> ARRAY[s.gid]
    ORDER BY ST_Distance(n.geom, s.geom) ASC
    LIMIT 1)
)
SELECT geom, idlist FROM next_stop;

该表达式(CTE)由通过UNION连接的两部分组成:

  • 前半部分为递归表达式设定起始点:通过获取 "gid" 为 304(线路终点站)的初始几何图形,并初始化已访问标识符数组来完成。

  • 后半部分通过递归迭代运行,直至无法找到新的记录为止。每次迭代时,通过自引用"next_stop"获取前一次迭代的值。我们会搜索Q线路上所有站点(strpos(s.routes,'Q')),且未加入已访问列表的站点(NOT n.idlist @> ARRAY[s.gid]),然后按照与前一站点的距离排序,仅选择最近的一个站点(LIMIT 1)。

除递归CTE本身外,此实现还运用了多项PostgreSQL高级数组特性:

  • 我们正在使用数组功能!PostgreSQL支持任意类型的数组——当前示例使用整数数组存储站点ID,但同样支持构建几何图形数组或任何PostgreSQL数据类型的数组。

  • 我们正在使用 array_append 函数动态扩展已访问标识符数组。

  • 我们使用 @> 数组运算符("数组包含")来筛选已访问过的 Q 线路站点。 @> 要求两侧均为 ARRAY 类型,因此需通过 ARRAY[] 语法将单独的 "gid" 数值转换为单元素数组。

执行查询时,系统会按检索顺序(即线路顺序)返回每个几何图形及已访问的标识符列表。通过PostGIS的 ST_MakeLine 聚合函数,可将这些几何图形按给定顺序构建为单一的线性输出。

WITH RECURSIVE next_stop(geom, idlist) AS (
    (SELECT
      geom,
      ARRAY[gid] AS idlist
    FROM nyc_subway_stations
    WHERE gid = 304)
    UNION ALL
    (SELECT
      s.geom,
      array_append(n.idlist, s.gid) AS idlist
    FROM nyc_subway_stations s, next_stop n
    WHERE strpos(s.routes, 'Q') != 0
    AND NOT n.idlist @> ARRAY[s.gid]
    ORDER BY ST_Distance(n.geom, s.geom) ASC
    LIMIT 1)
)
SELECT ST_MakeLine(geom) AS geom FROM next_stop;

其语法结构如下:

_images/adv_geom3.jpg

成功!

然而存在两个技术问题:

  • 当前仅计算单条地铁线路,而实际需求是计算所有线路的路径。

  • 我们的查询语句包含一项*先验知识*——作为路径搜索算法初始种子节点的起始站点标识符。

让我们优先攻克核心难题:如何在不人工识别的情况下,自动确定某条线路的起始站点。

'Q'线地铁站点可作为分析起点,那么该线路的终点站具有哪些特征?

_images/adv_geom2.jpg

一种观点认为"终点站就是线路最北端和最南端的站点"。但假设'Q'线改为东西走向运行,这一判定条件是否仍然成立?

对终点站的一种方向无关的界定方式是:"它们为距离线路中点最远的站点"。该定义不受线路走向(南北/东西)影响,仅需满足。

既然不存在百分之百可靠的启发式规则来确定终点站,我们暂时搁置第二条判定准则("距离线路中点最远"规则),转而探索其他解决方案。

注解

"距线路中点最远"这一判定规则存在明显缺陷,典型反例即英国伦敦的环线(如Circle Line)。所幸纽约地铁目前不存在环形线路,该规则在此仍可适用!

确定各线路终点站的首要步骤是提取唯一线路标识。

WITH routes AS (
  SELECT DISTINCT unnest(string_to_array(routes,',')) AS route
  FROM nyc_subway_stations ORDER BY route
)
SELECT * FROM routes;

请注意此处使用的两项PostgreSQL高级数组函数特性:

该查询结果将返回地铁系统中所有唯一的线路标识符列表。

 route
-------
 1
 2
 3
 4
 5
 6
 7
 A
 B
 C
 D
 E
 F
 G
 J
 L
 M
 N
 Q
 R
 S
 V
 W
 Z
(24 rows)

我们可以基于该结果,通过与 nyc_subway_stations 表进行关联,创建一个新表,其中每条线路的每个站点都对应一行记录。

WITH routes AS (
  SELECT DISTINCT unnest(string_to_array(routes,',')) AS route
  FROM nyc_subway_stations ORDER BY route
),
stops AS (
  SELECT s.gid, s.geom, r.route
  FROM routes r
  JOIN nyc_subway_stations s
  ON (strpos(s.routes, r.route) <> 0)
)
SELECT * FROM stops;
 gid |                      geom                      | route
-----+----------------------------------------------------+-------
   2 | 010100002026690000CBE327F938CD21415EDBE1572D315141 | 1
   3 | 010100002026690000C676635D10CD2141A0ECDB6975305141 | 1
  20 | 010100002026690000AE59A3F82C132241D835BA14D1435141 | 1
  22 | 0101000020266900003495A303D615224116DA56527D445141 | 1
                            ...etc...

现在,我们可以通过将每条线路的所有站点收集为一个多点(multi-point),然后计算该多点的中心点(centroid)来找到中心点。

WITH routes AS (
  SELECT DISTINCT unnest(string_to_array(routes,',')) AS route
  FROM nyc_subway_stations ORDER BY route
),
stops AS (
  SELECT s.gid, s.geom, r.route
  FROM routes r
  JOIN nyc_subway_stations s
  ON (strpos(s.routes, r.route) <> 0)
),
centers AS (
  SELECT ST_Centroid(ST_Collect(geom)) AS geom, route
  FROM stops
  GROUP BY route
)
SELECT * FROM centers;

'Q'线列车站点集合的中心点如下:

_images/adv_geom4.jpg

所以最北端的车站(终点站)似乎也是距离中心最远的车站。让我们计算每条线路距离中心最远的站点。

WITH routes AS (
  SELECT DISTINCT unnest(string_to_array(routes,',')) AS route
  FROM nyc_subway_stations ORDER BY route
),
stops AS (
  SELECT s.gid, s.geom, r.route
  FROM routes r
  JOIN nyc_subway_stations s
  ON (strpos(s.routes, r.route) <> 0)
),
centers AS (
  SELECT ST_Centroid(ST_Collect(geom)) AS geom, route
  FROM stops
  GROUP BY route
),
stops_distance AS (
  SELECT s.*, ST_Distance(s.geom, c.geom) AS distance
  FROM stops s JOIN centers c
  ON (s.route = c.route)
  ORDER BY route, distance DESC
),
first_stops AS (
  SELECT DISTINCT ON (route) stops_distance.*
  FROM stops_distance
)
SELECT * FROM first_stops;

这次我们添加了两个子查询:

  • stops_distance 将中心点与车站表连接,并计算每条线路中车站与中心点之间的距离。结果按线路分批排序,每批的第一条记录是距离中心最远的车站。

  • first_stops 通过仅获取每个不同分组的第一条记录来过滤**stops_distance** 的输出。由于 **stops_distance**已按特定方式排序,因第一条记录就是距离中心最远的记录,也就是我们希望用作起始点来构建每条地铁线路的站点。

现在我们已经确定了每条线路,并且大致知道了每条线路的起始站点:我们可以开始生成路线线条了!

首先,我们需要将递归 CTE 表达式转换为一个可以通过参数调用的函数。

CREATE OR REPLACE function walk_subway(integer, text) returns geometry AS
$$
WITH RECURSIVE next_stop(geom, idlist) AS (
    (SELECT
      geom AS geom,
      ARRAY[gid] AS idlist
    FROM nyc_subway_stations
    WHERE gid = $1)
    UNION ALL
    (SELECT
      s.geom AS geom,
      array_append(n.idlist, s.gid) AS idlist
    FROM nyc_subway_stations s, next_stop n
    WHERE strpos(s.routes, $2) != 0
    AND NOT n.idlist @> ARRAY[s.gid]
    ORDER BY ST_Distance(n.geom, s.geom) ASC
    LIMIT 1)
)
SELECT ST_MakeLine(geom) AS geom
FROM next_stop;
$$
language 'sql';

现在,我们已经准备就绪,可以开始了!

CREATE TABLE nyc_subway_lines AS
-- Distinct route identifiers!
WITH routes AS (
  SELECT DISTINCT unnest(string_to_array(routes,',')) AS route
  FROM nyc_subway_stations ORDER BY route
),
-- Joined back to stops! Every route has all its stops!
stops AS (
  SELECT s.gid, s.geom, r.route
  FROM routes r
  JOIN nyc_subway_stations s
  ON (strpos(s.routes, r.route) <> 0)
),
-- Collects stops by routes and calculate centroid!
centers AS (
  SELECT ST_Centroid(ST_Collect(geom)) AS geom, route
  FROM stops
  GROUP BY route
),
-- Calculate stop/center distance for each stop in each route.
stops_distance AS (
  SELECT s.*, ST_Distance(s.geom, c.geom) AS distance
  FROM stops s JOIN centers c
  ON (s.route = c.route)
  ORDER BY route, distance DESC
),
-- Filter out just the furthest stop/center pairs.
first_stops AS (
  SELECT DISTINCT ON (route) stops_distance.*
  FROM stops_distance
)
-- Pass the route/stop information into the linear route generation function!
SELECT
  ascii(route) AS gid, -- QGIS likes numeric primary keys
  route,
  walk_subway(gid, route) AS geom
FROM first_stops;

-- Do some housekeeping too
ALTER TABLE nyc_subway_lines ADD PRIMARY KEY (gid);

这是我们的最终表在 QGIS 中的可视化效果:

_images/adv_geom5.jpg

和往常一样,我们对数据的简单理解存在一些问题:

  • 系统中实际存在两条简称均为'S'的短途接驳线路:一条位于曼哈顿区,另一条位于洛克威区。尽管二者地理上不相连,但因共享线路标识符'S',我们在查询中将其合并处理;

  • '4' 号线(以及其他几条线路)在某一端分成了两个终点站,因此“沿着一条线路前进”的假设被打破,导致结果在末端出现了一个奇怪的弯钩。

希望这个示例能让大家体验到,结合 PostgreSQL 和 PostGIS 的高级特性,可以实现多种复杂的数据处理操作。