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