xref: /webtrees/app/PlaceLocation.php (revision 6fcafd028e511367c3fcdbfaa31aa05a85bf85c0)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2021 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees;
21
22use Illuminate\Database\Capsule\Manager as DB;
23use Illuminate\Database\Query\Builder;
24use Illuminate\Support\Collection;
25use stdClass;
26
27use function max;
28use function min;
29use function preg_split;
30use function trim;
31
32use const PREG_SPLIT_NO_EMPTY;
33
34/**
35 * Class PlaceLocation
36 */
37class PlaceLocation
38{
39    /** @var string e.g. "Westminster, London, England" */
40    private $location_name;
41
42    /** @var Collection<string> The parts of a location name, e.g. ["Westminster", "London", "England"] */
43    private $parts;
44
45    /**
46     * Create a place-location.
47     *
48     * @param string $location_name
49     */
50    public function __construct(string $location_name)
51    {
52        // Ignore any empty parts in location names such as "Village, , , Country".
53        $location_name = trim($location_name);
54        $this->parts   = new Collection(preg_split(Gedcom::PLACE_SEPARATOR_REGEX, $location_name, -1, PREG_SPLIT_NO_EMPTY));
55
56        // Rebuild the location name in the correct format.
57        $this->location_name = $this->parts->implode(Gedcom::PLACE_SEPARATOR);
58    }
59
60    /**
61     * Get the higher level location.
62     *
63     * @return PlaceLocation
64     */
65    public function parent(): PlaceLocation
66    {
67        return new self($this->parts->slice(1)->implode(Gedcom::PLACE_SEPARATOR));
68    }
69
70    /**
71     * The database row id that contains this location.
72     * Note that due to database collation, both "Quebec" and "Québec" will share the same row.
73     *
74     * @return int|null
75     */
76    public function id(): ?int
77    {
78        // The "top-level" location won't exist in the database.
79        if ($this->parts->isEmpty()) {
80            return null;
81        }
82
83        return Registry::cache()->array()->remember('location-' . $this->location_name, function () {
84            $parent_id = $this->parent()->id();
85
86            $place = $this->parts->first();
87            $place = mb_substr($place, 0, 120);
88
89            if ($parent_id === null) {
90                $location_id = DB::table('place_location')
91                    ->where('place', '=', $place)
92                    ->whereNull('parent_id')
93                    ->value('id');
94            } else {
95                $location_id = DB::table('place_location')
96                    ->where('place', '=', $place)
97                    ->where('parent_id', '=', $parent_id)
98                    ->value('id');
99            }
100
101            $location_id = $location_id ?? DB::table('place_location')->insertGetId([
102                    'parent_id' => $parent_id,
103                    'place'     => $place,
104                ]);
105
106            return (int) $location_id;
107        });
108    }
109
110    /**
111     * Does this location exist in the database?  Note that calls to PlaceLocation::id() will
112     * create the row, so this function is only meaningful when called before a call to PlaceLocation::id().
113     *
114     * @return bool
115     */
116    public function exists(): bool
117    {
118        $parent_id = null;
119
120        foreach ($this->parts->reverse() as $place) {
121            if ($parent_id === null) {
122                $parent_id = DB::table('place_location')
123                    ->whereNull('parent_id')
124                    ->where('place', '=', mb_substr($place, 0, 120))
125                    ->value('id');
126            } else {
127                $parent_id = DB::table('place_location')
128                    ->where('parent_id', '=', $parent_id)
129                    ->where('place', '=', mb_substr($place, 0, 120))
130                    ->value('id');
131            }
132
133            if ($parent_id === null) {
134                return false;
135            }
136        }
137
138        return true;
139    }
140
141    /**
142     * @return stdClass
143     */
144    private function details(): stdClass
145    {
146        return Registry::cache()->array()->remember('location-details-' . $this->id(), function () {
147            // The "top-level" location won't exist in the database.
148            if ($this->parts->isEmpty()) {
149                return (object) [
150                    'latitude'  => null,
151                    'longitude' => null,
152                ];
153            }
154
155            $row = DB::table('place_location')
156                ->where('id', '=', $this->id())
157                ->select(['latitude', 'longitude'])
158                ->first();
159
160            if ($row->latitude !== null) {
161                $row->latitude = (float) $row->latitude;
162            }
163
164            if ($row->longitude !== null) {
165                $row->longitude = (float) $row->longitude;
166            }
167
168            return $row;
169        });
170    }
171
172    /**
173     * Latitude of the location.
174     *
175     * @return float|null
176     */
177    public function latitude(): ?float
178    {
179        return $this->details()->latitude;
180    }
181
182    /**
183     * Longitude of the location.
184     *
185     * @return float|null
186     */
187    public function longitude(): ?float
188    {
189        return $this->details()->longitude;
190    }
191
192    /**
193     * @return string
194     */
195    public function locationName(): string
196    {
197        return (string) $this->parts->first();
198    }
199
200    /**
201     * Find a rectangle that (approximately) encloses this place.
202     *
203     * @return array<array<float>>
204     */
205    public function boundingRectangle(): array
206    {
207        if ($this->id() === null) {
208            return [[-180.0, -90.0], [180.0, 90.0]];
209        }
210
211        // Find our own co-ordinates and those of any child places
212        $latitudes = DB::table('place_location')
213            ->whereNotNull('latitude')
214            ->where(function (Builder $query): void {
215                $query
216                    ->where('parent_id', '=', $this->id())
217                    ->orWhere('id', '=', $this->id());
218            })
219            ->groupBy(['latitude'])
220            ->pluck('latitude')
221            ->map(static function (string $x): float {
222                return (float) $x;
223            });
224
225        $longitudes = DB::table('place_location')
226            ->whereNotNull('longitude')
227            ->where(function (Builder $query): void {
228                $query
229                    ->where('parent_id', '=', $this->id())
230                    ->orWhere('id', '=', $this->id());
231            })
232            ->groupBy(['longitude'])
233            ->pluck('longitude')
234            ->map(static function (string $x): float {
235                return (float) $x;
236            });
237
238        // No co-ordinates?  Use the parent place instead.
239        if ($latitudes->isEmpty() || $longitudes->isEmpty()) {
240            return $this->parent()->boundingRectangle();
241        }
242
243        // Many co-ordinates?  Generate a bounding rectangle that includes them.
244        if ($latitudes->count() > 1 || $longitudes->count() > 1) {
245            return [[$latitudes->min(), $longitudes->min()], [$latitudes->max(), $longitudes->max()]];
246        }
247
248        // Just one co-ordinate?  Draw a box around it.
249        switch ($this->parts->count()) {
250            case 1:
251                // Countries
252                $delta = 5.0;
253                break;
254            case 2:
255                // Regions
256                $delta = 1.0;
257                break;
258            default:
259                // Cities and districts
260                $delta = 0.2;
261                break;
262        }
263
264        return [[
265            max($latitudes->min() - $delta, -90.0),
266            max($longitudes->min() - $delta, -180.0),
267        ], [
268            min($latitudes->max() + $delta, 90.0),
269            min($longitudes->max() + $delta, 180.0),
270        ]];
271    }
272}
273