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