xref: /webtrees/app/Module/PlaceHierarchyListModule.php (revision 24931b29a0237a5f5f1b8620af661ea530451af0)
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\Module;
21
22use Fisharebest\Webtrees\Auth;
23use Fisharebest\Webtrees\DB;
24use Fisharebest\Webtrees\Family;
25use Fisharebest\Webtrees\Http\RequestHandlers\MapDataEdit;
26use Fisharebest\Webtrees\I18N;
27use Fisharebest\Webtrees\Individual;
28use Fisharebest\Webtrees\Location;
29use Fisharebest\Webtrees\Place;
30use Fisharebest\Webtrees\PlaceLocation;
31use Fisharebest\Webtrees\Registry;
32use Fisharebest\Webtrees\Services\LeafletJsService;
33use Fisharebest\Webtrees\Services\ModuleService;
34use Fisharebest\Webtrees\Services\SearchService;
35use Fisharebest\Webtrees\Tree;
36use Fisharebest\Webtrees\Validator;
37use Illuminate\Database\Query\Builder;
38use Illuminate\Database\Query\JoinClause;
39use Psr\Http\Message\ResponseInterface;
40use Psr\Http\Message\ServerRequestInterface;
41use Psr\Http\Server\RequestHandlerInterface;
42
43use function array_chunk;
44use function array_pop;
45use function array_reverse;
46use function ceil;
47use function count;
48use function redirect;
49use function route;
50use function view;
51
52/**
53 * Class IndividualListModule
54 */
55class PlaceHierarchyListModule extends AbstractModule implements ModuleListInterface, RequestHandlerInterface
56{
57    use ModuleListTrait;
58
59    protected const ROUTE_URL = '/tree/{tree}/place-list{/place_id}';
60
61    /** @var int The default access level for this module.  It can be changed in the control panel. */
62    protected int $access_level = Auth::PRIV_USER;
63
64    private LeafletJsService $leaflet_js_service;
65
66    private ModuleService $module_service;
67
68    private SearchService $search_service;
69
70    /**
71     * @param LeafletJsService $leaflet_js_service
72     * @param ModuleService    $module_service
73     * @param SearchService    $search_service
74     */
75    public function __construct(LeafletJsService $leaflet_js_service, ModuleService $module_service, SearchService $search_service)
76    {
77        $this->leaflet_js_service = $leaflet_js_service;
78        $this->module_service     = $module_service;
79        $this->search_service     = $search_service;
80    }
81
82    /**
83     * Initialization.
84     *
85     * @return void
86     */
87    public function boot(): void
88    {
89        Registry::routeFactory()->routeMap()
90            ->get(static::class, static::ROUTE_URL, $this);
91    }
92
93    /**
94     * How should this module be identified in the control panel, etc.?
95     *
96     * @return string
97     */
98    public function title(): string
99    {
100        /* I18N: Name of a module/list */
101        return I18N::translate('Place hierarchy');
102    }
103
104    /**
105     * A sentence describing what this module does.
106     *
107     * @return string
108     */
109    public function description(): string
110    {
111        /* I18N: Description of the “Place hierarchy” module */
112        return I18N::translate('The place hierarchy.');
113    }
114
115    /**
116     * CSS class for the URL.
117     *
118     * @return string
119     */
120    public function listMenuClass(): string
121    {
122        return 'menu-list-plac';
123    }
124
125    /**
126     * @return array<string>
127     */
128    public function listUrlAttributes(): array
129    {
130        return [];
131    }
132
133    /**
134     * @param Tree $tree
135     *
136     * @return bool
137     */
138    public function listIsEmpty(Tree $tree): bool
139    {
140        return !DB::table('places')
141            ->where('p_file', '=', $tree->id())
142            ->exists();
143    }
144
145    /**
146     * @param Tree                                      $tree
147     * @param array<bool|int|string|array<string>|null> $parameters
148     *
149     * @return string
150     */
151    public function listUrl(Tree $tree, array $parameters = []): string
152    {
153        $parameters['tree'] = $tree->name();
154
155        return route(static::class, $parameters);
156    }
157
158    /**
159     * @param ServerRequestInterface $request
160     *
161     * @return ResponseInterface
162     */
163    public function handle(ServerRequestInterface $request): ResponseInterface
164    {
165        $tree     = Validator::attributes($request)->tree();
166        $user     = Validator::attributes($request)->user();
167        $place_id = Validator::attributes($request)->integer('place_id', 0);
168
169        Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user);
170
171        $action2  = Validator::queryParams($request)->string('action2', 'hierarchy');
172        $place    = Place::find($place_id, $tree);
173
174        // Request for a non-existent place?
175        if ($place_id !== $place->id()) {
176            return redirect($place->url());
177        }
178
179        $map_providers = $this->module_service->findByInterface(ModuleMapProviderInterface::class);
180
181        $content = '';
182        $showmap = $map_providers->isNotEmpty();
183        $data    = null;
184
185        if ($showmap) {
186            $content .= view('modules/place-hierarchy/map', [
187                'data'           => $this->mapData($place),
188                'leaflet_config' => $this->leaflet_js_service->config(),
189            ]);
190        }
191
192        switch ($action2) {
193            case 'list':
194            default:
195                $alt_link = I18N::translate('Show place hierarchy');
196                $alt_url  = $this->listUrl($tree, ['action2' => 'hierarchy', 'place_id' => $place_id]);
197                $content .= view('modules/place-hierarchy/list', ['columns' => $this->getList($tree)]);
198                break;
199            case 'hierarchy':
200            case 'hierarchy-e':
201                $alt_link = I18N::translate('Show all places in a list');
202                $alt_url  = $this->listUrl($tree, ['action2' => 'list', 'place_id' => 0]);
203                $data     = $this->getHierarchy($place);
204                $content .= ($data === null || $showmap) ? '' : view('place-hierarchy', $data);
205                if ($data === null || $action2 === 'hierarchy-e') {
206                    $content .= view('modules/place-hierarchy/events', [
207                        'indilist' => $this->search_service->searchIndividualsInPlace($place),
208                        'famlist'  => $this->search_service->searchFamiliesInPlace($place),
209                        'tree'     => $place->tree(),
210                    ]);
211                }
212        }
213
214        if ($data !== null && $action2 !== 'hierarchy-e' && $place->gedcomName() !== '') {
215            $events_link = $this->listUrl($tree, ['action2' => 'hierarchy-e', 'place_id' => $place_id]);
216        } else {
217            $events_link = '';
218        }
219
220        $breadcrumbs = $this->breadcrumbs($place);
221
222        return $this->viewResponse('modules/place-hierarchy/page', [
223            'alt_link'    => $alt_link,
224            'alt_url'     => $alt_url,
225            'breadcrumbs' => $breadcrumbs['breadcrumbs'],
226            'content'     => $content,
227            'current'     => $breadcrumbs['current'],
228            'events_link' => $events_link,
229            'place'       => $place,
230            'title'       => I18N::translate('Place hierarchy'),
231            'tree'        => $tree,
232            'world_url'   => $this->listUrl($tree),
233        ]);
234    }
235
236    /**
237     * @param Place $place
238     *
239     * @return array<mixed>
240     */
241    protected function mapData(Place $place): array
242    {
243        $children  = $place->getChildPlaces();
244        $features  = [];
245        $sidebar   = '';
246        $show_link = true;
247
248        // No children?  Show ourself on the map instead.
249        if ($children === []) {
250            $children[] = $place;
251            $show_link  = false;
252        }
253
254        foreach ($children as $id => $child) {
255            $location = new PlaceLocation($child->gedcomName());
256
257            if (Auth::isAdmin()) {
258                $this_url = route(self::class, ['tree' => $child->tree()->name(), 'place_id' => $place->id()]);
259                $edit_url = route(MapDataEdit::class, ['location_id' => $location->id(), 'url' => $this_url]);
260            } else {
261                $edit_url = '';
262            }
263
264            if ($location->latitude() === null || $location->longitude() === null) {
265                $sidebar_class = 'unmapped';
266            } else {
267                $sidebar_class = 'mapped';
268                $features[]    = [
269                    'type'       => 'Feature',
270                    'id'         => $id,
271                    'geometry'   => [
272                        'type'        => 'Point',
273                        'coordinates' => [$location->longitude(), $location->latitude()],
274                    ],
275                    'properties' => [
276                        'tooltip' => $child->gedcomName(),
277                        'popup'   => view('modules/place-hierarchy/popup', [
278                            'edit_url'  => $edit_url,
279                            'place'     => $child,
280                            'latitude'  => $location->latitude(),
281                            'longitude' => $location->longitude(),
282                            'showlink'  => $show_link,
283                        ]),
284                    ],
285                ];
286            }
287
288            $stats = [
289                Family::RECORD_TYPE     => $this->familyPlaceLinks($child)->count(),
290                Individual::RECORD_TYPE => $this->individualPlaceLinks($child)->count(),
291                Location::RECORD_TYPE   => $this->locationPlaceLinks($child)->count(),
292            ];
293
294            $sidebar .= view('modules/place-hierarchy/sidebar', [
295                'edit_url'      => $edit_url,
296                'id'            => $id,
297                'place'         => $child,
298                'showlink'      => $show_link,
299                'sidebar_class' => $sidebar_class,
300                'stats'         => $stats,
301            ]);
302        }
303
304        return [
305            'bounds'  => (new PlaceLocation($place->gedcomName()))->boundingRectangle(),
306            'sidebar' => $sidebar,
307            'markers' => [
308                'type'     => 'FeatureCollection',
309                'features' => $features,
310            ],
311        ];
312    }
313
314    /**
315     * @param Tree $tree
316     *
317     * @return array<array<Place>>
318     */
319    private function getList(Tree $tree): array
320    {
321        $places = $this->search_service->searchPlaces($tree, '')
322            ->sort(static function (Place $x, Place $y): int {
323                return I18N::comparator()($x->gedcomName(), $y->gedcomName());
324            })
325            ->all();
326
327        $count = count($places);
328
329        if ($places === []) {
330            return [];
331        }
332
333        $columns = $count > 20 ? 3 : 2;
334
335        return array_chunk($places, (int) ceil($count / $columns));
336    }
337
338    /**
339     * @param Place $place
340     *
341     * @return array{columns:array<array<Place>>,place:Place,tree:Tree,col_class:string}|null
342     */
343    private function getHierarchy(Place $place): ?array
344    {
345        $child_places = $place->getChildPlaces();
346        $numfound     = count($child_places);
347
348        if ($numfound > 0) {
349            $divisor = $numfound > 20 ? 3 : 2;
350
351            return [
352                'tree'      => $place->tree(),
353                'col_class' => 'w-' . ($divisor === 2 ? '25' : '50'),
354                'columns'   => array_chunk($child_places, (int) ceil($numfound / $divisor)),
355                'place'     => $place,
356            ];
357        }
358
359        return null;
360    }
361
362    /**
363     * @param Place $place
364     *
365     * @return array{breadcrumbs:array<Place>,current:Place|null}
366     */
367    private function breadcrumbs(Place $place): array
368    {
369        $breadcrumbs = [];
370        if ($place->gedcomName() !== '') {
371            $breadcrumbs[] = $place;
372            $parent_place  = $place->parent();
373            while ($parent_place->gedcomName() !== '') {
374                $breadcrumbs[] = $parent_place;
375                $parent_place  = $parent_place->parent();
376            }
377            $breadcrumbs = array_reverse($breadcrumbs);
378            $current     = array_pop($breadcrumbs);
379        } else {
380            $current = null;
381        }
382
383        return [
384            'breadcrumbs' => $breadcrumbs,
385            'current'     => $current,
386        ];
387    }
388
389    /**
390     * @param Place $place
391     *
392     * @return Builder
393     */
394    private function placeLinks(Place $place): Builder
395    {
396        return DB::table('places')
397            ->join('placelinks', static function (JoinClause $join): void {
398                $join
399                    ->on('pl_file', '=', 'p_file')
400                    ->on('pl_p_id', '=', 'p_id');
401            })
402            ->where('p_file', '=', $place->tree()->id())
403            ->where('p_id', '=', $place->id());
404    }
405
406    /**
407     * @param Place $place
408     *
409     * @return Builder
410     */
411    private function familyPlaceLinks(Place $place): Builder
412    {
413        return $this->placeLinks($place)
414            ->join('families', static function (JoinClause $join): void {
415                $join
416                    ->on('pl_file', '=', 'f_file')
417                    ->on('pl_gid', '=', 'f_id');
418            });
419    }
420
421    /**
422     * @param Place $place
423     *
424     * @return Builder
425     */
426    private function individualPlaceLinks(Place $place): Builder
427    {
428        return $this->placeLinks($place)
429            ->join('individuals', static function (JoinClause $join): void {
430                $join
431                    ->on('pl_file', '=', 'i_file')
432                    ->on('pl_gid', '=', 'i_id');
433            });
434    }
435
436    /**
437     * @param Place $place
438     *
439     * @return Builder
440     */
441    private function locationPlaceLinks(Place $place): Builder
442    {
443        return $this->placeLinks($place)
444            ->join('other', static function (JoinClause $join): void {
445                $join
446                    ->on('pl_file', '=', 'o_file')
447                    ->on('pl_gid', '=', 'o_id');
448            })
449            ->where('o_type', '=', Location::RECORD_TYPE);
450    }
451}
452