编程

Laravel 中的地理数据检索

277 2024-08-07 00:42:00

在这个系列中,我将试图涵盖我在那个项目中遇到的与地理数据相关的所有方面。首先是如何检索地理数据。

Overpass API

有些人可能知道 OpenStreetMap 项目 —— Overpass API 是它的一部分,可以用来检索数据。它的行为类似于 GraphQL,因为它只有一个端点,你可以使用 Overpass QL 向其发送查询,通常 OpenStreetMap 及 Overpass API 只有 3 不同的实体:

  • Node: 地球表面上的某个特定的点
  • Way: 线性原始或边界或区域
  • Relation: node 和 way 的结合

OSM 数据对象

我们可以通过 PHP 代码创建持有这些实体的简易对象。

Node

class Node
{
    /**
     * @param  array<string, string>  $tags
     */
    public function __construct(
        public readonly int $id,
        public readonly float $lat,
        public readonly float $lon,
        public readonly array $tags = [],
    ) {
    }
}

Way

class Way
{
    /**
     * @param  array{lat: float, lon: float}  $center
     * @param  array<array-key, int>  $nodes
     * @param  array<string, string>  $tags
     */
    public function __construct(
        public readonly int $id,
        public readonly array $center,
        public readonly array $nodes,
        public readonly array $tags = [],
    ) {
    }
}

Relation

class Relation
{
    /**
     * @param  array{lat: float, lon: float}  $center
     * @param  array<array-key, array{type: string, ref: int, role: string}>  $members
     * @param  array<string, string>  $tags
     */
    public function __construct(
        public readonly int $id,
        public readonly array $center,
        public readonly array $members,
        public readonly array $tags = [],
    ) {
    }
}

如果你希望使用 DTO 包来自动进行属性赋值、强制转换,请参考 spatie/laravel-data。它们也是普通对象 - 如你所见,在 way 对象中只持有子元素(比如 node)的 ID。当然,你也可以先解析节点(node)对象,然后直接赋值节点。

Overpass 查询

首先,你可以在 overpass-turbo.eu 上使用一个很酷的沙盒/playground 来测试 Overpass 查询。你可以看到简单的响应或将其可视化在地图上,这对首先构建查询或在没有得到所需内容时进行调试非常有帮助。

我暂时使用以下查询,因为我只对一些 POI 和建筑感兴趣。

[out:json]
[timeout:30]
[bbox:{$box->south()},{$box->west()},{$box->north()},{$box->east()}];
(
  node[amenity];
  way[building];
  >;
);
out center;

对每一行做一下简短的解释:

  • [out:json] - 响应应该是 JSON
  • [timeout:30] - 查询超时时间为 30 秒
  • [bbox:{$box->south()},{$box->west()},{$box->north()},{$box->east()}]; - 为整个查询定义一个边界
  • node[amenity]; - 选择带有 amenity 标签的 node
  • way[building]; - 选择带有 building 标签的 way
  • >; - 选择 building way 中的所有 node
  • out center; - 为每个项目添加中心

这不会找到所有的建筑,因为有些建筑是 relation 实体,而它们是更复杂的结构。具有整体(自上而下视角)的主要建筑。而处理 Relation 要复杂得多,所以我暂时忽略了它们。

发送查询并解析结果

由于我们已经有了 DTO 和查询,使用 Laravel HTTP 客户端,这是一个非常简单的过程。

<?php

namespace App\Actions;

use App\Concerns\Makeable;
use App\Data\Overpass\Node;
use App\Data\Overpass\Relation;
use App\Data\Overpass\Way;
use App\Geotools\Data\Box;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;

class FetchMapDataInBoundingBox
{
    /**
     * @return array{ node: Collection<array-key, Node>, way: Collection<array-key, Way>, relation: Collection<array-key, Relation> }
     *
     * @throws \Illuminate\Http\Client\ConnectionException
     */
    public function execute(Box $box): array
    {
        $response = Http::acceptJson()
            ->timeout(60)
            ->connectTimeout(10)
            ->throw()
            ->get('https://overpass-api.de/api/interpreter', [
                'data' => <<<TXT
                [out:json]
                [timeout:30]
                [bbox:{$box->south()},{$box->west()},{$box->north()},{$box->east()}];
                (
                  node[amenity];
                  way[building];
                  >;
                );
                out center;
                TXT
            ])
            ->collect('elements')
            ->groupBy('type');

        $nodes = collect($response->get('node'))
            ->map(fn (array $data) => Node::from($data))
            ->values();

        $ways = collect($response->get('way'))
            ->map(fn (array $data) => Way::from($data))
            ->values();

        $relations = collect($response->get('relation'))
            ->map(fn (array $data) => Relation::from($data))
            ->values();

        return [
            'node' => $nodes,
            'way' => $ways,
            'relation' => $relations,
        ];
    }
}

对此操作的一点解释:

  • 生成基于 Box 对象的查询 - 你可以使用任何你想要的 box 对象,也可以自己创建一个
  • 发送查询到 https://overpass-api.de/api/interpreter 并创建一个元素(element)集合
  • 按照类型(type)对元素进行分组
  • 将所有元素匹配到 PHP 对象
  • 返回一个包含三个集合的数组,其包含返回的元素

总结

通过这种方法你可以查询您想要/需要的任何地理数据。通过调整边界框和查询本身,可以更改返回的数据——根据 box 的大小,你可能需要增加超时时间。如果你严重依赖这些数据,并且经常需要它们,你可能应该拥有自己的 Overpass API 实例。

后面的文章,我将展示如何使用 PostGIS 将这些信息存储在本地数据库中。