xref: /webtrees/app/Services/MapDataService.php (revision c88147bf68caeb2b737b5803b640737427e748ca)
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\Services;
21
22use Fisharebest\Webtrees\DB;
23use Fisharebest\Webtrees\FlashMessages;
24use Fisharebest\Webtrees\Gedcom;
25use Fisharebest\Webtrees\I18N;
26use Fisharebest\Webtrees\PlaceLocation;
27use Illuminate\Database\Query\Expression;
28use Illuminate\Support\Collection;
29
30use function abs;
31use function array_filter;
32use function array_unshift;
33use function implode;
34use function mb_strtolower;
35use function round;
36
37/**
38 * Process geographic data.
39 */
40class MapDataService
41{
42    // Location of files to import
43    public const PLACES_FOLDER = 'places/';
44
45    // Format of CSV files
46    public const CSV_SEPARATOR = ';';
47
48    /**
49     * @param int $id
50     *
51     * @return PlaceLocation
52     */
53    public function findById(int $id): PlaceLocation
54    {
55        $hierarchy = [];
56
57        while (true) {
58            $row = DB::table('place_location')
59                ->where('id', '=', $id)
60                ->select(['place', 'parent_id'])
61                ->first();
62
63            if ($row === null) {
64                return new PlaceLocation(implode(Gedcom::PLACE_SEPARATOR, $hierarchy));
65            }
66
67            $hierarchy[] = $row->place;
68            $id          = $row->parent_id;
69        }
70    }
71
72    /**
73     * Which trees use a particular location?
74     *
75     * @param PlaceLocation $location
76     *
77     * @return array<string,array<object>>
78     */
79    public function activePlaces(PlaceLocation $location): array
80    {
81        $parents  = $this->placeIdsForLocation($location);
82        $children = [];
83
84        $rows = DB::table('places')
85            ->join('gedcom', 'gedcom.gedcom_id', '=', 'p_file')
86            ->join('gedcom_setting', 'gedcom_setting.gedcom_id', '=', 'gedcom.gedcom_id')
87            ->where('setting_name', '=', 'title')
88            ->whereIn('p_parent_id', $parents)
89            ->select(['p_place', 'gedcom_name AS tree_name', 'setting_value AS tree_title', 'p_id'])
90            ->get();
91
92        foreach ($rows as $row) {
93            $children[mb_strtolower($row->p_place)][] = $row;
94        }
95
96        return $children;
97    }
98
99    /**
100     * Make sure that all places in the genealogy data also exist in the location data.
101     *
102     * @return void
103     */
104    public function importMissingLocations(): void
105    {
106        $all_places = DB::table('places AS p0')
107            ->leftJoin('places AS p1', 'p1.p_id', '=', 'p0.p_parent_id')
108            ->leftJoin('places AS p2', 'p2.p_id', '=', 'p1.p_parent_id')
109            ->leftJoin('places AS p3', 'p3.p_id', '=', 'p2.p_parent_id')
110            ->leftJoin('places AS p4', 'p4.p_id', '=', 'p3.p_parent_id')
111            ->leftJoin('places AS p5', 'p5.p_id', '=', 'p4.p_parent_id')
112            ->leftJoin('places AS p6', 'p6.p_id', '=', 'p5.p_parent_id')
113            ->leftJoin('places AS p7', 'p7.p_id', '=', 'p6.p_parent_id')
114            ->leftJoin('places AS p8', 'p8.p_id', '=', 'p7.p_parent_id')
115            ->select([
116                'p0.p_place AS part_0',
117                'p1.p_place AS part_1',
118                'p2.p_place AS part_2',
119                'p3.p_place AS part_3',
120                'p4.p_place AS part_4',
121                'p5.p_place AS part_5',
122                'p6.p_place AS part_6',
123                'p7.p_place AS part_7',
124                'p8.p_place AS part_8',
125            ])
126            ->get()
127            ->map(static function (object $row): string {
128                return implode(Gedcom::PLACE_SEPARATOR, array_filter((array) $row));
129            });
130
131        $all_locations = DB::table('place_location AS p0')
132            ->leftJoin('place_location AS p1', 'p1.id', '=', 'p0.parent_id')
133            ->leftJoin('place_location AS p2', 'p2.id', '=', 'p1.parent_id')
134            ->leftJoin('place_location AS p3', 'p3.id', '=', 'p2.parent_id')
135            ->leftJoin('place_location AS p4', 'p4.id', '=', 'p3.parent_id')
136            ->leftJoin('place_location AS p5', 'p5.id', '=', 'p4.parent_id')
137            ->leftJoin('place_location AS p6', 'p6.id', '=', 'p5.parent_id')
138            ->leftJoin('place_location AS p7', 'p7.id', '=', 'p6.parent_id')
139            ->leftJoin('place_location AS p8', 'p8.id', '=', 'p7.parent_id')
140            ->select([
141                'p0.place AS part_0',
142                'p1.place AS part_1',
143                'p2.place AS part_2',
144                'p3.place AS part_3',
145                'p4.place AS part_4',
146                'p5.place AS part_5',
147                'p6.place AS part_6',
148                'p7.place AS part_7',
149                'p8.place AS part_8',
150            ])
151            ->get()
152            ->map(static function (object $row): string {
153                return implode(Gedcom::PLACE_SEPARATOR, array_filter((array) $row));
154            });
155
156        $missing = $all_places->diff($all_locations);
157
158        foreach ($missing as $location) {
159            (new PlaceLocation($location))->id();
160        }
161    }
162
163    /**
164     * @param int $id
165     *
166     * @return void
167     */
168    public function deleteRecursively(int $id): void
169    {
170        // Uses on-delete-cascade
171        DB::table('place_location')
172            ->where('id', '=', $id)
173            ->delete();
174    }
175
176    /**
177     * @param int|null   $parent_location_id
178     * @param array<int> $parent_place_ids
179     *
180     * @return void
181     */
182    public function deleteUnusedLocations(?int $parent_location_id, array $parent_place_ids): void
183    {
184        if ($parent_location_id === null) {
185            $location_query = DB::table('place_location')
186                ->whereNull('parent_id');
187        } else {
188            $location_query = DB::table('place_location')
189                ->where('parent_id', '=', $parent_location_id);
190        }
191
192        foreach ($location_query->get() as $location) {
193            $places = DB::table('places')
194                ->whereIn('p_parent_id', $parent_place_ids)
195                ->where('p_place', '=', $location->place)
196                ->get();
197
198            if ($places->isEmpty()) {
199                FlashMessages::addMessage(I18N::translate('“%s” has been deleted.', e($location->place)));
200
201                DB::table('place_location')
202                    ->where('id', '=', $location->id)
203                    ->delete();
204            } else {
205                $place_ids = $places->map(static fn (object $place): int => (int) $place->p_id)->all();
206                $this->deleteUnusedLocations((int) $location->id, $place_ids);
207            }
208        }
209    }
210
211    /**
212     * Find a list of child places.
213     * How many children does each child place have?  How many have co-ordinates?
214     *
215     * @param int|null $parent_id
216     *
217     * @return Collection<int,object>
218     */
219    public function getPlaceListLocation(?int $parent_id): Collection
220    {
221        $prefix = DB::connection()->getTablePrefix();
222
223        $expression =
224            $prefix . 'p1.place IS NOT NULL AND ' . $prefix . 'p1.latitude IS NULL OR ' .
225            $prefix . 'p2.place IS NOT NULL AND ' . $prefix . 'p2.latitude IS NULL OR ' .
226            $prefix . 'p3.place IS NOT NULL AND ' . $prefix . 'p3.latitude IS NULL OR ' .
227            $prefix . 'p4.place IS NOT NULL AND ' . $prefix . 'p4.latitude IS NULL OR ' .
228            $prefix . 'p5.place IS NOT NULL AND ' . $prefix . 'p5.latitude IS NULL OR ' .
229            $prefix . 'p6.place IS NOT NULL AND ' . $prefix . 'p6.latitude IS NULL OR ' .
230            $prefix . 'p7.place IS NOT NULL AND ' . $prefix . 'p7.latitude IS NULL OR ' .
231            $prefix . 'p8.place IS NOT NULL AND ' . $prefix . 'p8.latitude IS NULL OR ' .
232            $prefix . 'p9.place IS NOT NULL AND ' . $prefix . 'p9.latitude IS NULL';
233
234        $expression = 'CASE ' . $expression . ' WHEN TRUE THEN 1 ELSE 0 END';
235
236        $query = DB::table('place_location AS p0')
237            ->leftJoin('place_location AS p1', 'p1.parent_id', '=', 'p0.id')
238            ->leftJoin('place_location AS p2', 'p2.parent_id', '=', 'p1.id')
239            ->leftJoin('place_location AS p3', 'p3.parent_id', '=', 'p2.id')
240            ->leftJoin('place_location AS p4', 'p4.parent_id', '=', 'p3.id')
241            ->leftJoin('place_location AS p5', 'p5.parent_id', '=', 'p4.id')
242            ->leftJoin('place_location AS p6', 'p6.parent_id', '=', 'p5.id')
243            ->leftJoin('place_location AS p7', 'p7.parent_id', '=', 'p6.id')
244            ->leftJoin('place_location AS p8', 'p8.parent_id', '=', 'p7.id')
245            ->leftJoin('place_location AS p9', 'p9.parent_id', '=', 'p8.id');
246
247        if ($parent_id === null) {
248            $query->whereNull('p0.parent_id');
249        } else {
250            $query->where('p0.parent_id', '=', $parent_id);
251        }
252
253        return $query
254            ->groupBy(['p0.id'])
255            ->select([
256                'p0.*',
257                new Expression('COUNT(' . $prefix . 'p1.id) AS child_count'),
258                new Expression('SUM(' . $expression . ') AS no_coord'),
259            ])
260            ->get()
261            ->map(static function (object $row): object {
262                $row->child_count = (int) $row->child_count;
263                $row->no_coord    = (int) $row->no_coord;
264                $row->key         = mb_strtolower($row->place);
265
266                return $row;
267            })
268            ->sort(static fn (object $x, object $y): int => I18N::comparator()($x->place, $y->place));
269    }
270
271    /**
272     * @param float $latitude
273     *
274     * @return string
275     */
276    public function writeLatitude(float $latitude): string
277    {
278        return $this->writeDegrees($latitude, Gedcom::LATITUDE_NORTH, Gedcom::LATITUDE_SOUTH);
279    }
280
281    /**
282     * @param float $longitude
283     *
284     * @return string
285     */
286    public function writeLongitude(float $longitude): string
287    {
288        return $this->writeDegrees($longitude, Gedcom::LONGITUDE_EAST, Gedcom::LONGITUDE_WEST);
289    }
290
291    /**
292     * Find all active places that match a location
293     *
294     * @param PlaceLocation $location
295     *
296     * @return array<string>
297     */
298    private function placeIdsForLocation(PlaceLocation $location): array
299    {
300        $hierarchy = [];
301
302        while ($location->id() !== null) {
303            array_unshift($hierarchy, $location->locationName());
304            $location = $location->parent();
305        }
306
307        $place_ids = ['0'];
308
309        foreach ($hierarchy as $place_name) {
310            $place_ids = DB::table('places')
311                ->whereIn('p_parent_id', $place_ids)
312                ->where('p_place', '=', $place_name)
313                ->groupBy(['p_id'])
314                ->pluck('p_id')
315                ->all();
316        }
317
318        return $place_ids;
319    }
320
321    /**
322     * @param float  $degrees
323     * @param string $positive
324     * @param string $negative
325     *
326     * @return string
327     */
328    private function writeDegrees(float $degrees, string $positive, string $negative): string
329    {
330        $degrees = round($degrees, 5);
331
332        if ($degrees < 0.0) {
333            return $negative . abs($degrees);
334        }
335
336        return $positive . $degrees;
337    }
338}
339