xref: /webtrees/app/Module/PedigreeMapModule.php (revision 9a3accd78795046143d5b995599032fcff82a467)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2019 webtrees development team
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 */
16declare(strict_types=1);
17
18namespace Fisharebest\Webtrees\Module;
19
20use Exception;
21use Fisharebest\Webtrees\Exceptions\IndividualAccessDeniedException;
22use Fisharebest\Webtrees\Exceptions\IndividualNotFoundException;
23use Fisharebest\Webtrees\Fact;
24use Fisharebest\Webtrees\Family;
25use Fisharebest\Webtrees\Functions\FunctionsCharts;
26use Fisharebest\Webtrees\GedcomTag;
27use Fisharebest\Webtrees\I18N;
28use Fisharebest\Webtrees\Individual;
29use Fisharebest\Webtrees\Location;
30use Fisharebest\Webtrees\Menu;
31use Fisharebest\Webtrees\Services\ChartService;
32use Fisharebest\Webtrees\Tree;
33use Fisharebest\Webtrees\Webtrees;
34use Symfony\Component\HttpFoundation\JsonResponse;
35use Symfony\Component\HttpFoundation\Request;
36use Symfony\Component\HttpFoundation\Response;
37
38/**
39 * Class PedigreeMapModule
40 */
41class PedigreeMapModule extends AbstractModule implements ModuleChartInterface
42{
43    use ModuleChartTrait;
44
45    // Defaults
46    public const DEFAULT_GENERATIONS = '4';
47
48    // Limits
49    public const MAXIMUM_GENERATIONS = 10;
50
51    private const LINE_COLORS = [
52        '#FF0000',
53        // Red
54        '#00FF00',
55        // Green
56        '#0000FF',
57        // Blue
58        '#FFB300',
59        // Gold
60        '#00FFFF',
61        // Cyan
62        '#FF00FF',
63        // Purple
64        '#7777FF',
65        // Light blue
66        '#80FF80'
67        // Light green
68    ];
69
70    private static $map_providers  = null;
71    private static $map_selections = null;
72
73    /**
74     * How should this module be identified in the control panel, etc.?
75     *
76     * @return string
77     */
78    public function title(): string
79    {
80        /* I18N: Name of a module */
81        return I18N::translate('Pedigree map');
82    }
83
84    /**
85     * A sentence describing what this module does.
86     *
87     * @return string
88     */
89    public function description(): string
90    {
91        /* I18N: Description of the “OSM” module */
92        return I18N::translate('Show the birthplace of ancestors on a map.');
93    }
94
95    /**
96     * CSS class for the URL.
97     *
98     * @return string
99     */
100    public function chartMenuClass(): string
101    {
102        return 'menu-chart-pedigreemap';
103    }
104
105    /**
106     * Return a menu item for this chart - for use in individual boxes.
107     *
108     * @param Individual $individual
109     *
110     * @return Menu|null
111     */
112    public function chartBoxMenu(Individual $individual): ?Menu
113    {
114        return $this->chartMenu($individual);
115    }
116
117    /**
118     * The title for a specific instance of this chart.
119     *
120     * @param Individual $individual
121     *
122     * @return string
123     */
124    public function chartTitle(Individual $individual): string
125    {
126        /* I18N: %s is an individual’s name */
127        return I18N::translate('Pedigree map of %s', $individual->fullName());
128    }
129
130    /**
131     * The URL for this chart.
132     *
133     * @param Individual $individual
134     * @param string[]   $parameters
135     *
136     * @return string
137     */
138    public function chartUrl(Individual $individual, array $parameters = []): string
139    {
140        return route('module', [
141                'module' => $this->name(),
142                'action' => 'PedigreeMap',
143                'xref'   => $individual->xref(),
144                'ged'    => $individual->tree()->name(),
145            ] + $parameters);
146    }
147
148    /**
149     * @param Request      $request
150     * @param Tree         $tree
151     * @param ChartService $chart_service
152     *
153     * @return JsonResponse
154     */
155    public function getMapDataAction(Request $request, Tree $tree, ChartService $chart_service): JsonResponse
156    {
157        $xref        = $request->get('reference');
158        $indi        = Individual::getInstance($xref, $tree);
159        $color_count = count(self::LINE_COLORS);
160
161        $facts = $this->getPedigreeMapFacts($request, $tree, $chart_service);
162
163        $geojson = [
164            'type'     => 'FeatureCollection',
165            'features' => [],
166        ];
167
168        $sosa_points = [];
169
170        foreach ($facts as $id => $fact) {
171            $location = new Location($fact->place()->gedcomName());
172
173            // Use the co-ordinates from the fact (if they exist).
174            $latitude  = $fact->latitude();
175            $longitude = $fact->longitude();
176
177            // Use the co-ordinates from the location otherwise.
178            if ($latitude === 0.0 && $longitude === 0.0) {
179                $latitude  = $location->latitude();
180                $longitude = $location->longitude();
181            }
182
183            $icon = ['color' => 'Gold', 'name' => 'bullseye '];
184            if ($latitude !== 0.0 || $longitude !== 0.0) {
185                $polyline         = null;
186                $color            = self::LINE_COLORS[log($id, 2) % $color_count];
187                $icon['color']    = $color; //make icon color the same as the line
188                $sosa_points[$id] = [$latitude, $longitude];
189                $sosa_parent      = intdiv($id, 2);
190                if (array_key_exists($sosa_parent, $sosa_points)) {
191                    // Would like to use a GeometryCollection to hold LineStrings
192                    // rather than generate polylines but the MarkerCluster library
193                    // doesn't seem to like them
194                    $polyline = [
195                        'points'  => [
196                            $sosa_points[$sosa_parent],
197                            [$latitude, $longitude],
198                        ],
199                        'options' => [
200                            'color' => $color,
201                        ],
202                    ];
203                }
204                $geojson['features'][] = [
205                    'type'       => 'Feature',
206                    'id'         => $id,
207                    'valid'      => true,
208                    'geometry'   => [
209                        'type'        => 'Point',
210                        'coordinates' => [$longitude, $latitude],
211                    ],
212                    'properties' => [
213                        'polyline' => $polyline,
214                        'icon'     => $icon,
215                        'tooltip'  => strip_tags($fact->place()->fullName()),
216                        'summary'  => view('modules/pedigree-map/events', $this->summaryData($indi, $fact, $id)),
217                        'zoom'     => $location->zoom() ?: 2,
218                    ],
219                ];
220            }
221        }
222
223        $code = empty($facts) ? Response::HTTP_NO_CONTENT : Response::HTTP_OK;
224
225        return new JsonResponse($geojson, $code);
226    }
227
228    /**
229     * @param Individual $individual
230     * @param Fact       $fact
231     * @param int        $sosa
232     *
233     * @return array
234     */
235    private function summaryData(Individual $individual, Fact $fact, int $sosa): array
236    {
237        $record      = $fact->record();
238        $name        = '';
239        $url         = '';
240        $tag         = $fact->label();
241        $addbirthtag = false;
242
243        if ($record instanceof Family) {
244            // Marriage
245            $spouse = $record->spouse($individual);
246            if ($spouse) {
247                $url  = $spouse->url();
248                $name = $spouse->fullName();
249            }
250        } elseif ($record !== $individual) {
251            // Birth of a child
252            $url  = $record->url();
253            $name = $record->fullName();
254            $tag  = GedcomTag::getLabel('_BIRT_CHIL', $record);
255        }
256
257        if ($sosa > 1) {
258            $addbirthtag = true;
259            $tag         = ucfirst(FunctionsCharts::getSosaName($sosa));
260        }
261
262        return [
263            'tag'    => $tag,
264            'url'    => $url,
265            'name'   => $name,
266            'value'  => $fact->value(),
267            'date'   => $fact->date()->display(true),
268            'place'  => $fact->place(),
269            'addtag' => $addbirthtag,
270        ];
271    }
272
273    /**
274     * @param Request      $request
275     * @param Tree         $tree
276     * @param ChartService $chart_service
277     *
278     * @return array
279     */
280    private function getPedigreeMapFacts(Request $request, Tree $tree, ChartService $chart_service): array
281    {
282        $xref        = $request->get('reference');
283        $individual  = Individual::getInstance($xref, $tree);
284        $generations = (int) $request->get('generations', '4');
285        $ancestors   = $chart_service->sosaStradonitzAncestors($individual, $generations);
286        $facts       = [];
287        foreach ($ancestors as $sosa => $person) {
288            if ($person->canShow()) {
289                $birth = $person->facts(['BIRT'])->first();
290                if ($birth instanceof Fact && $birth->place()->gedcomName() !== '') {
291                    $facts[$sosa] = $birth;
292                }
293            }
294        }
295
296        return $facts;
297    }
298
299    /**
300     * @param Request $request
301     *
302     * @return JsonResponse
303     */
304    public function getProviderStylesAction(Request $request): JsonResponse
305    {
306        $styles = $this->getMapProviderData($request);
307
308        return new JsonResponse($styles);
309    }
310
311    /**
312     * @param Request $request
313     *
314     * @return array|null
315     */
316    private function getMapProviderData(Request $request): ?array
317    {
318        if (self::$map_providers === null) {
319            $providersFile        = WT_ROOT . Webtrees::MODULES_PATH . 'openstreetmap/providers/providers.xml';
320            self::$map_selections = [
321                'provider' => $this->getPreference('provider', 'openstreetmap'),
322                'style'    => $this->getPreference('provider_style', 'mapnik'),
323            ];
324
325            try {
326                $xml = simplexml_load_file($providersFile);
327                // need to convert xml structure into arrays & strings
328                foreach ($xml as $provider) {
329                    $style_keys = array_map(
330                        function (string $item): string {
331                            return preg_replace('/[^a-z\d]/i', '', strtolower($item));
332                        },
333                        (array) $provider->styles
334                    );
335
336                    $key = preg_replace('/[^a-z\d]/i', '', strtolower((string) $provider->name));
337
338                    self::$map_providers[$key] = [
339                        'name'   => (string) $provider->name,
340                        'styles' => array_combine($style_keys, (array) $provider->styles),
341                    ];
342                }
343            } catch (Exception $ex) {
344                // Default provider is OpenStreetMap
345                self::$map_selections = [
346                    'provider' => 'openstreetmap',
347                    'style'    => 'mapnik',
348                ];
349                self::$map_providers  = [
350                    'openstreetmap' => [
351                        'name'   => 'OpenStreetMap',
352                        'styles' => ['mapnik' => 'Mapnik'],
353                    ],
354                ];
355            }
356        }
357
358        //Ugly!!!
359        switch ($request->get('action')) {
360            case 'BaseData':
361                $varName = (self::$map_selections['style'] === '') ? '' : self::$map_providers[self::$map_selections['provider']]['styles'][self::$map_selections['style']];
362                $payload = [
363                    'selectedProvIndex' => self::$map_selections['provider'],
364                    'selectedProvName'  => self::$map_providers[self::$map_selections['provider']]['name'],
365                    'selectedStyleName' => $varName,
366                ];
367                break;
368            case 'ProviderStyles':
369                $provider = $request->get('provider', 'openstreetmap');
370                $payload  = self::$map_providers[$provider]['styles'];
371                break;
372            case 'AdminConfig':
373                $providers = [];
374                foreach (self::$map_providers as $key => $provider) {
375                    $providers[$key] = $provider['name'];
376                }
377                $payload = [
378                    'providers'     => $providers,
379                    'selectedProv'  => self::$map_selections['provider'],
380                    'styles'        => self::$map_providers[self::$map_selections['provider']]['styles'],
381                    'selectedStyle' => self::$map_selections['style'],
382                ];
383                break;
384            default:
385                $payload = null;
386        }
387
388        return $payload;
389    }
390
391    /**
392     * @param Request $request
393     * @param Tree    $tree
394     *
395     * @return object
396     */
397    public function getPedigreeMapAction(Request $request, Tree $tree)
398    {
399        $xref           = $request->get('xref', '');
400        $individual     = Individual::getInstance($xref, $tree);
401        $generations    = $request->get('generations', self::DEFAULT_GENERATIONS);
402
403        if ($individual === null) {
404            throw new IndividualNotFoundException();
405        }
406
407        if (!$individual->canShow()) {
408            throw new IndividualAccessDeniedException();
409        }
410
411        return $this->viewResponse('modules/pedigree-map/page', [
412            'module_name'    => $this->name(),
413            /* I18N: %s is an individual’s name */
414            'title'          => I18N::translate('Pedigree map of %s', $individual->fullName()),
415            'tree'           => $tree,
416            'individual'     => $individual,
417            'generations'    => $generations,
418            'maxgenerations' => self::MAXIMUM_GENERATIONS,
419            'map'            => view(
420                'modules/pedigree-map/chart',
421                [
422                    'module'      => $this->name(),
423                    'ref'         => $individual->xref(),
424                    'type'        => 'pedigree',
425                    'generations' => $generations,
426                ]
427            ),
428        ]);
429    }
430}
431