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