xref: /webtrees/app/Module/PedigreeMapModule.php (revision 720f8728cd7f538685cb0825c0992ff971ef186e)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2018 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\Auth;
22use Fisharebest\Webtrees\Database;
23use Fisharebest\Webtrees\DebugBar;
24use Fisharebest\Webtrees\Exceptions\IndividualAccessDeniedException;
25use Fisharebest\Webtrees\Exceptions\IndividualNotFoundException;
26use Fisharebest\Webtrees\Fact;
27use Fisharebest\Webtrees\FactLocation;
28use Fisharebest\Webtrees\I18N;
29use Fisharebest\Webtrees\Individual;
30use Fisharebest\Webtrees\Log;
31use Fisharebest\Webtrees\Menu;
32use Fisharebest\Webtrees\Place;
33use Fisharebest\Webtrees\Tree;
34use Symfony\Component\HttpFoundation\JsonResponse;
35use Symfony\Component\HttpFoundation\Request;
36
37/**
38 * Class PedigreeMapModule
39 */
40class PedigreeMapModule extends AbstractModule implements ModuleChartInterface
41{
42    const OSM_MIN_ZOOM = 2;
43    const LINE_COLORS  = [
44        '#FF0000',
45        // Red
46        '#00FF00',
47        // Green
48        '#0000FF',
49        // Blue
50        '#FFB300',
51        // Gold
52        '#00FFFF',
53        // Cyan
54        '#FF00FF',
55        // Purple
56        '#7777FF',
57        // Light blue
58        '#80FF80'
59        // Light green
60    ];
61
62    private static $map_providers  = null;
63    private static $map_selections = null;
64
65    /** {@inheritdoc} */
66    public function getTitle()
67    {
68        return /* I18N: Name of a module */
69            I18N::translate('Pedigree map');
70    }
71
72    /** {@inheritdoc} */
73    public function getDescription()
74    {
75        return /* I18N: Description of the “OSM” module */
76            I18N::translate('Show the birthplace of ancestors on a map.');
77    }
78
79    /** {@inheritdoc} */
80    public function defaultAccessLevel()
81    {
82        return Auth::PRIV_PRIVATE;
83    }
84
85    /**
86     * Return a menu item for this chart.
87     *
88     * @param Individual $individual
89     *
90     * @return Menu
91     */
92    public function getChartMenu(Individual $individual)
93    {
94        return new Menu(
95            I18N::translate('Pedigree map'),
96            route('module', [
97                'module' => $this->getName(),
98                'action' => 'PedigreeMap',
99                'xref'   => $individual->getXref(),
100            ]),
101            'menu-chart-pedigreemap',
102            ['rel' => 'nofollow']
103        );
104    }
105
106    /**
107     * Return a menu item for this chart - for use in individual boxes.
108     *
109     * @param Individual $individual
110     *
111     * @return Menu
112     */
113    public function getBoxChartMenu(Individual $individual)
114    {
115        return $this->getChartMenu($individual);
116    }
117
118    /**
119     * @param string $type
120     *
121     * @return array
122     */
123    public function assets($type = 'user')
124    {
125        $dir = WT_MODULES_DIR . $this->getName();
126        if ($type === 'admin') {
127            return [
128                'css' => [
129                    $dir . '/assets/css/osm-module.css',
130                ],
131                'js'  => [
132                    $dir . '/assets/js/osm-admin.js',
133                ],
134            ];
135        } else {
136            return [
137                'css' => [
138                    $dir . '/assets/css/osm-module.css',
139                ],
140                'js'  => [
141                    $dir . '/assets/js/osm-module.js',
142                ],
143            ];
144        }
145    }
146
147    /**
148     * @param Request $request
149     *
150     * @return JsonResponse
151     */
152    public function getBaseDataAction(Request $request): JsonResponse
153    {
154        $provider = $this->getMapProviderData($request);
155        $style    = $provider['selectedStyleName'] = '' ? '' : '.' . $provider['selectedStyleName'];
156
157        switch ($provider['selectedProvIndex']) {
158            case 'mapbox':
159                $providerOptions = [
160                    'id'          => $this->getPreference('mapbox_id'),
161                    'accessToken' => $this->getPreference('mapbox_token'),
162                ];
163                break;
164            case 'here':
165                $providerOptions = [
166                    'app_id'   => $this->getPreference('here_appid'),
167                    'app_code' => $this->getPreference('here_appcode'),
168                ];
169                break;
170            default:
171                $providerOptions = [];
172        };
173
174        $options = [
175            'minZoom'         => self::OSM_MIN_ZOOM,
176            'providerName'    => $provider['selectedProvName'] . $style,
177            'providerOptions' => $providerOptions,
178            'animate'         => $this->getPreference('map_animate', 0),
179            'I18N'            => [
180                'zoomInTitle'  => I18N::translate('Zoom in'),
181                'zoomOutTitle' => I18N::translate('Zoom out'),
182                'reset'        => I18N::translate('Reset to initial map state'),
183                'noData'       => I18N::translate('No mappable items'),
184                'error'        => I18N::translate('An unknown error occurred'),
185            ],
186        ];
187
188        return new JsonResponse($options);
189    }
190
191    /**
192     * @param Request $request
193     *
194     * @return JsonResponse
195     * @throws Exception
196     */
197    public function getMapDataAction(Request $request): JsonResponse
198    {
199        $xref        = $request->get('reference');
200        $tree        = $request->attributes->get('tree');
201        $indi        = Individual::getInstance($xref, $tree);
202        $color_count = count(self::LINE_COLORS);
203
204        $facts = $this->getPedigreeMapFacts($request);
205
206        $geojson = [
207            'type'     => 'FeatureCollection',
208            'features' => [],
209        ];
210        if (empty($facts)) {
211            $code = 204;
212        } else {
213            $code = 200;
214            foreach ($facts as $id => $fact) {
215                $event = new FactLocation($fact, $indi);
216                $icon  = $event->getIconDetails();
217                if ($event->knownLatLon()) {
218                    $polyline         = null;
219                    $color            = self::LINE_COLORS[log($id, 2) % $color_count];
220                    $icon['color']    = $color; //make icon color the same as the line
221                    $sosa_points[$id] = $event->getLatLonJSArray();
222                    $sosa_parent      = (int)floor($id / 2);
223                    if (array_key_exists($sosa_parent, $sosa_points)) {
224                        // Would like to use a GeometryCollection to hold LineStrings
225                        // rather than generate polylines but the MarkerCluster library
226                        // doesn't seem to like them
227                        $polyline = [
228                            'points'  => [
229                                $sosa_points[$sosa_parent],
230                                $event->getLatLonJSArray(),
231                            ],
232                            'options' => [
233                                'color' => $color,
234                            ],
235                        ];
236                    }
237                    $geojson['features'][] = [
238                        'type'       => 'Feature',
239                        'id'         => $id,
240                        'valid'      => true,
241                        'geometry'   => [
242                            'type'        => 'Point',
243                            'coordinates' => $event->getGeoJsonCoords(),
244                        ],
245                        'properties' => [
246                            'polyline' => $polyline,
247                            'icon'     => $icon,
248                            'tooltip'  => $event->toolTip(),
249                            'summary'  => view(
250                                'modules/openstreetmap/event-sidebar',
251                                $event->shortSummary('pedigree', $id)
252                            ),
253                            'zoom'     => (int)$event->getZoom(),
254                        ],
255                    ];
256                }
257            }
258        }
259
260        return new JsonResponse($geojson, $code);
261    }
262
263    /**
264     * @param Request $request
265     *
266     * @return array
267     * @throws Exception
268     */
269    private function getPedigreeMapFacts(Request $request)
270    {
271        $xref        = $request->get('reference');
272        $tree        = $request->attributes->get('tree');
273        $individual  = Individual::getInstance($xref, $tree);
274        $generations = (int)$request->get(
275            'generations',
276            $tree->getPreference('DEFAULT_PEDIGREE_GENERATIONS')
277        );
278        $ancestors   = $this->sosaStradonitzAncestors($individual, $generations);
279        $facts       = [];
280        foreach ($ancestors as $sosa => $person) {
281            if ($person !== null && $person->canShow()) {
282                /** @var Fact $birth */
283                $birth = $person->getFirstFact('BIRT');
284                if ($birth && !$birth->getPlace()->isEmpty()) {
285                    $facts[$sosa] = $birth;
286                }
287            }
288        }
289
290        return $facts;
291    }
292
293    /**
294     * @param Request $request
295     *
296     * @return JsonResponse
297     */
298    public function getProviderStylesAction(Request $request): JsonResponse
299    {
300        $styles = $this->getMapProviderData($request);
301
302        return new JsonResponse($styles);
303    }
304
305    /**
306     * @param Request $request
307     *
308     * @return array|null
309     */
310    private function getMapProviderData(Request $request)
311    {
312        if (self::$map_providers === null) {
313            $providersFile        = WT_ROOT . WT_MODULES_DIR . $this->getName() . '/providers/providers.xml';
314            self::$map_selections = [
315                'provider' => $this->getPreference('provider', 'openstreetmap'),
316                'style'    => $this->getPreference('provider_style', 'mapnik'),
317            ];
318
319            try {
320                $xml = simplexml_load_file($providersFile);
321                // need to convert xml structure into arrays & strings
322                foreach ($xml as $provider) {
323                    $style_keys = array_map(
324                        function ($item) {
325                            return preg_replace('/[^a-z\d]/i', '', strtolower($item));
326                        },
327                        (array)$provider->styles
328                    );
329
330                    $key = preg_replace('/[^a-z\d]/i', '', strtolower((string)$provider->name));
331
332                    self::$map_providers[$key] = [
333                        'name'   => (string)$provider->name,
334                        'styles' => array_combine($style_keys, (array)$provider->styles),
335                    ];
336                }
337            } catch (Exception $ex) {
338                // Default provider is OpenStreetMap
339                self::$map_selections = [
340                    'provider' => 'openstreetmap',
341                    'style'    => 'mapnik',
342                ];
343                self::$map_providers  = [
344                    'openstreetmap' => [
345                        'name'   => 'OpenStreetMap',
346                        'styles' => ['mapnik' => 'Mapnik'],
347                    ],
348                ];
349            };
350        }
351
352        //Ugly!!!
353        switch ($request->get('action')) {
354            case 'BaseData':
355                $varName = (self::$map_selections['style'] === '') ? '' : self::$map_providers[self::$map_selections['provider']]['styles'][self::$map_selections['style']];
356                $payload = [
357                    'selectedProvIndex' => self::$map_selections['provider'],
358                    'selectedProvName'  => self::$map_providers[self::$map_selections['provider']]['name'],
359                    'selectedStyleName' => $varName,
360                ];
361                break;
362            case 'ProviderStyles':
363                $provider = $request->get('provider', 'openstreetmap');
364                $payload  = self::$map_providers[$provider]['styles'];
365                break;
366            case 'AdminConfig':
367                $providers = [];
368                foreach (self::$map_providers as $key => $provider) {
369                    $providers[$key] = $provider['name'];
370                }
371                $payload = [
372                    'providers'     => $providers,
373                    'selectedProv'  => self::$map_selections['provider'],
374                    'styles'        => self::$map_providers[self::$map_selections['provider']]['styles'],
375                    'selectedStyle' => self::$map_selections['style'],
376                ];
377                break;
378            default:
379                $payload = null;
380        }
381
382        return $payload;
383    }
384
385    /**
386     * @param Request $request
387     *
388     * @return object
389     * @throws Exception
390     */
391    public function getPedigreeMapAction(Request $request)
392    {
393        /** @var Tree $tree */
394        $tree           = $request->attributes->get('tree');
395        $xref           = $request->get('xref');
396        $individual     = Individual::getInstance($xref, $tree);
397        $maxgenerations = $tree->getPreference('MAX_PEDIGREE_GENERATIONS');
398        $generations    = $request->get('generations', $tree->getPreference('DEFAULT_PEDIGREE_GENERATIONS'));
399
400        if ($individual === null) {
401            throw new IndividualNotFoundException;
402        } elseif (!$individual->canShow()) {
403            throw new IndividualAccessDeniedException;
404        }
405
406        return (object)[
407            'name' => 'modules/openstreetmap/pedigreemap',
408            'data' => [
409                'assets'         => $this->assets(),
410                'module'         => $this->getName(),
411                'title'          => /* I18N: %s is an individual’s name */
412                    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/openstreetmap/map',
419                    [
420                        'assets'      => $this->assets(),
421                        'module'      => $this->getName(),
422                        'ref'         => $individual->getXref(),
423                        'type'        => 'pedigree',
424                        'generations' => $generations,
425                    ]
426                ),
427            ],
428        ];
429    }
430
431    // @TODO shift the following function to somewhere more appropriate during restructure
432
433    /**
434     * Copied from AbstractChartController.php
435     *
436     * Find the ancestors of an individual, and generate an array indexed by
437     * Sosa-Stradonitz number.
438     *
439     * @param Individual $individual  Start with this individual
440     * @param int        $generations Fetch this number of generations
441     *
442     * @return Individual[]
443     */
444    private function sosaStradonitzAncestors(Individual $individual, int $generations): array
445    {
446        /** @var Individual[] $ancestors */
447        $ancestors = [
448            1 => $individual,
449        ];
450
451        for ($i = 1, $max = 2 ** ($generations - 1); $i < $max; $i++) {
452            $ancestors[$i * 2]     = null;
453            $ancestors[$i * 2 + 1] = null;
454
455            $individual = $ancestors[$i];
456
457            if ($individual !== null) {
458                $family = $individual->getPrimaryChildFamily();
459                if ($family !== null) {
460                    if ($family->getHusband() !== null) {
461                        $ancestors[$i * 2] = $family->getHusband();
462                    }
463                    if ($family->getWife() !== null) {
464                        $ancestors[$i * 2 + 1] = $family->getWife();
465                    }
466                }
467            }
468        }
469
470        return $ancestors;
471    }
472}
473