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