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