xref: /webtrees/app/Module/PlacesModule.php (revision fab86cdecbfcd5eafff2913e4b03bacac2f79f3c)
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 Exception;
23use Fisharebest\Webtrees\Fact;
24use Fisharebest\Webtrees\Family;
25use Fisharebest\Webtrees\I18N;
26use Fisharebest\Webtrees\Individual;
27use Fisharebest\Webtrees\Place;
28use Fisharebest\Webtrees\PlaceLocation;
29use Fisharebest\Webtrees\Services\LeafletJsService;
30use Fisharebest\Webtrees\Services\ModuleService;
31use Illuminate\Support\Collection;
32
33/**
34 * Class PlacesMapModule
35 */
36class PlacesModule extends AbstractModule implements ModuleTabInterface
37{
38    use ModuleTabTrait;
39
40    protected const ICONS = [
41        'FAM:CENS'  => ['color' => 'darkcyan', 'name' => 'list fas'],
42        'FAM:MARR'  => ['color' => 'green', 'name' => 'infinity fas'],
43        'INDI:BAPM' => ['color' => 'hotpink', 'name' => 'water fas'],
44        'INDI:BARM' => ['color' => 'hotpink', 'name' => 'star-of-david fas'],
45        'INDI:BASM' => ['color' => 'hotpink', 'name' => 'star-of-david fas'],
46        'INDI:BIRT' => ['color' => 'hotpink', 'name' => 'baby-carriage fas'],
47        'INDI:BURI' => ['color' => 'purple', 'name' => 'times fas'],
48        'INDI:CENS' => ['color' => 'darkcyan', 'name' => 'list fas'],
49        'INDI:CHR'  => ['color' => 'hotpink', 'name' => 'water fas'],
50        'INDI:CHRA' => ['color' => 'hotpink', 'name' => 'water fas'],
51        'INDI:CREM' => ['color' => 'black', 'name' => 'times fas'],
52        'INDI:DEAT' => ['color' => 'black', 'name' => 'times fas'],
53        'INDI:EDUC' => ['color' => 'violet', 'name' => 'university fas'],
54        'INDI:GRAD' => ['color' => 'violet', 'name' => 'university fas'],
55        'INDI:OCCU' => ['color' => 'darkcyan', 'name' => 'industry fas'],
56        'INDI:RESI' => ['color' => 'darkcyan', 'name' => 'home fas'],
57    ];
58
59    protected const OWN_ICONS = [
60        'INDI:BIRT' => ['color' => 'red', 'name' => 'baby-carriage fas'],
61        'INDI:CHR'  => ['color' => 'red', 'name' => 'water fas'],
62    ] + self::ICONS;
63
64    protected const DEFAULT_ICON = ['color' => 'gold', 'name' => 'bullseye fas'];
65
66    private LeafletJsService $leaflet_js_service;
67
68    private ModuleService $module_service;
69
70    /**
71     * @param LeafletJsService $leaflet_js_service
72     * @param ModuleService    $module_service
73     */
74    public function __construct(LeafletJsService $leaflet_js_service, ModuleService $module_service)
75    {
76        $this->leaflet_js_service = $leaflet_js_service;
77        $this->module_service = $module_service;
78    }
79
80    /**
81     * How should this module be identified in the control panel, etc.?
82     *
83     * @return string
84     */
85    public function title(): string
86    {
87        /* I18N: Name of a module */
88        return I18N::translate('Places');
89    }
90
91    /**
92     * A sentence describing what this module does.
93     *
94     * @return string
95     */
96    public function description(): string
97    {
98        /* I18N: Description of the “Places” module */
99        return I18N::translate('Show the location of events on a map.');
100    }
101
102    /**
103     * The default position for this tab.  It can be changed in the control panel.
104     *
105     * @return int
106     */
107    public function defaultTabOrder(): int
108    {
109        return 8;
110    }
111
112    /**
113     * Is this tab empty? If so, we don't always need to display it.
114     *
115     * @param Individual $individual
116     *
117     * @return bool
118     */
119    public function hasTabContent(Individual $individual): bool
120    {
121        $map_providers = $this->module_service->findByInterface(ModuleMapProviderInterface::class);
122
123        return $map_providers->isNotEmpty() && $this->getMapData($individual)->features !== [];
124    }
125
126    /**
127     * @param Individual $indi
128     *
129     * @return object
130     */
131    private function getMapData(Individual $indi): object
132    {
133        $facts = $this->getPersonalFacts($indi);
134
135        $geojson = [
136            'type'     => 'FeatureCollection',
137            'features' => [],
138        ];
139
140        foreach ($facts as $id => $fact) {
141            $location = new PlaceLocation($fact->place()->gedcomName());
142
143            // Use the co-ordinates from the fact (if they exist).
144            $latitude  = $fact->latitude();
145            $longitude = $fact->longitude();
146
147            // Use the co-ordinates from the location otherwise.
148            if ($latitude === null || $longitude === null) {
149                $latitude  = $location->latitude();
150                $longitude = $location->longitude();
151            }
152
153            $icons = $fact->record() === $indi ? static::OWN_ICONS : static::ICONS;
154
155            if ($latitude !== null && $longitude !== null) {
156                $geojson['features'][] = [
157                    'type'       => 'Feature',
158                    'id'         => $id,
159                    'geometry'   => [
160                        'type'        => 'Point',
161                        'coordinates' => [$longitude, $latitude],
162                    ],
163                    'properties' => [
164                        'icon'    => $icons[$fact->tag()] ?? static::DEFAULT_ICON,
165                        'tooltip' => $fact->place()->gedcomName(),
166                        'summary' => view('modules/places/event-sidebar', $this->summaryData($indi, $fact)),
167                    ],
168                ];
169            }
170        }
171
172        return (object) $geojson;
173    }
174
175    /**
176     * @param Individual $individual
177     *
178     * @return Collection<int,Fact>
179     * @throws Exception
180     */
181    private function getPersonalFacts(Individual $individual): Collection
182    {
183        $facts = $individual->facts();
184
185        foreach ($individual->spouseFamilies() as $family) {
186            $facts = $facts->merge($family->facts());
187            // Add birth of children from this family to the facts array
188            foreach ($family->children() as $child) {
189                $childsBirth = $child->facts(['BIRT'])->first();
190                if ($childsBirth instanceof Fact && $childsBirth->place()->gedcomName() !== '') {
191                    $facts->push($childsBirth);
192                }
193            }
194        }
195
196        $facts = Fact::sortFacts($facts);
197
198        return $facts->filter(static function (Fact $item): bool {
199            return $item->place()->gedcomName() !== '';
200        });
201    }
202
203    /**
204     * @param Individual $individual
205     * @param Fact       $fact
206     *
207     * @return array<string|Place>
208     */
209    private function summaryData(Individual $individual, Fact $fact): array
210    {
211        $record = $fact->record();
212        $name   = '';
213        $url    = '';
214        $tag    = $fact->label();
215
216        if ($record instanceof Family) {
217            // Marriage
218            $spouse = $record->spouse($individual);
219            if ($spouse instanceof Individual) {
220                $url  = $spouse->url();
221                $name = $spouse->fullName();
222            }
223        } elseif ($record !== $individual) {
224            // Birth of a child
225            $url  = $record->url();
226            $name = $record->fullName();
227            $tag  = I18N::translate('Birth of a child');
228        }
229
230        return [
231            'tag'   => $tag,
232            'url'   => $url,
233            'name'  => $name,
234            'value' => $fact->value(),
235            'date'  => $fact->date()->display($individual->tree(), null, true),
236            'place' => $fact->place(),
237        ];
238    }
239
240    /**
241     * A greyed out tab has no actual content, but may perhaps have
242     * options to create content.
243     *
244     * @param Individual $individual
245     *
246     * @return bool
247     */
248    public function isGrayedOut(Individual $individual): bool
249    {
250        return false;
251    }
252
253    /**
254     * Can this tab load asynchronously?
255     *
256     * @return bool
257     */
258    public function canLoadAjax(): bool
259    {
260        return true;
261    }
262
263    /**
264     * Generate the HTML content of this tab.
265     *
266     * @param Individual $individual
267     *
268     * @return string
269     */
270    public function getTabContent(Individual $individual): string
271    {
272        return view('modules/places/tab', [
273            'data'           => $this->getMapData($individual),
274            'leaflet_config' => $this->leaflet_js_service->config(),
275        ]);
276    }
277}
278