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