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