summaryrefslogtreecommitdiffstats
path: root/lib/cepis/Geo.php
blob: c67762238577fe09391b94e26d4ad1fd16db0624 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<?php

declare(strict_types=1);

namespace Cepis;

/**
 * The CEP's coordinate, when the database has one.
 *
 * The point is the **median** of the coordinates of that CEP's addresses, not
 * the mean: one badly geocoded address 40 km away shifts a mean and does not
 * move a median.
 *
 * Always check {@see Geo::$precisao} before using the coordinate for distance:
 *
 * - `"logradouro"`: the point is a stretch of street. `raio_m` lands around
 *   100–150 m. Good for maps and for distance.
 * - `"localidade"`: the CEP *is* the whole municipality (a city-wide general
 *   CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km.
 *   Computing shipping from this is wrong by tens of kilometres.
 *
 * `raio_m` is the radius containing 90% of that CEP's addresses. It exists
 * precisely so that error cannot pass unnoticed.
 */
final class Geo
{
    /**
     * Property names are the API's own JSON keys, `raio_m` included. See
     * {@see Address} for why they are not translated or camel-cased.
     *
     * @param string $precisao Kept a plain string rather than an enum so a
     *     precision level the API adds later cannot break this client.
     */
    public function __construct(
        public readonly float $lat,
        public readonly float $lon,
        public readonly int $raio_m,
        public readonly string $precisao,
    ) {
    }

    /**
     * Build from the decoded `geo` block, or `null` when it is missing or
     * malformed.
     *
     * A malformed `geo` is not worth losing the whole address over, since the
     * rest of the response is still useful.
     */
    public static function fromArray(mixed $data): ?self
    {
        if (!is_array($data)) {
            return null;
        }
        foreach (['lat', 'lon', 'raio_m', 'precisao'] as $key) {
            if (!isset($data[$key]) || !is_scalar($data[$key])) {
                return null;
            }
        }
        if (!is_numeric($data['lat']) || !is_numeric($data['lon']) || !is_numeric($data['raio_m'])) {
            return null;
        }

        return new self(
            lat: (float) $data['lat'],
            lon: (float) $data['lon'],
            raio_m: (int) (float) $data['raio_m'],
            precisao: (string) $data['precisao'],
        );
    }

    /**
     * `true` when the coordinate points at a street, not a municipality.
     *
     * Shorthand for `precisao === "logradouro"`, which is the check to make
     * before using the point to measure distance.
     */
    public function isPrecise(): bool
    {
        return $this->precisao === 'logradouro';
    }

    /**
     * @return array{lat: float, lon: float, raio_m: int, precisao: string}
     */
    public function toArray(): array
    {
        return [
            'lat' => $this->lat,
            'lon' => $this->lon,
            'raio_m' => $this->raio_m,
            'precisao' => $this->precisao,
        ];
    }
}