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