xref: /webtrees/app/Module/PedigreeMapModule.php (revision b6db7c1f82484c632c75d5225ce767d398ddc27f)
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 LINE_COLORS  = [
39        '#FF0000',
40        // Red
41        '#00FF00',
42        // Green
43        '#0000FF',
44        // Blue
45        '#FFB300',
46        // Gold
47        '#00FFFF',
48        // Cyan
49        '#FF00FF',
50        // Purple
51        '#7777FF',
52        // Light blue
53        '#80FF80'
54        // Light green
55    ];
56
57    private static $map_providers  = null;
58    private static $map_selections = null;
59
60    /** {@inheritdoc} */
61    public function getTitle()
62    {
63        return /* I18N: Name of a module */
64            I18N::translate('Pedigree map');
65    }
66
67    /** {@inheritdoc} */
68    public function getDescription()
69    {
70        return /* I18N: Description of the “OSM” module */
71            I18N::translate('Show the birthplace of ancestors on a map.');
72    }
73
74    /** {@inheritdoc} */
75    public function defaultAccessLevel()
76    {
77        return Auth::PRIV_PRIVATE;
78    }
79
80    /**
81     * Return a menu item for this chart.
82     *
83     * @param Individual $individual
84     *
85     * @return Menu
86     */
87    public function getChartMenu(Individual $individual)
88    {
89        return new Menu(
90            I18N::translate('Pedigree map'),
91            route('module', [
92                'module' => $this->getName(),
93                'action' => 'PedigreeMap',
94                'xref'   => $individual->getXref(),
95            ]),
96            'menu-chart-pedigreemap',
97            ['rel' => 'nofollow']
98        );
99    }
100
101    /**
102     * Return a menu item for this chart - for use in individual boxes.
103     *
104     * @param Individual $individual
105     *
106     * @return Menu
107     */
108    public function getBoxChartMenu(Individual $individual)
109    {
110        return $this->getChartMenu($individual);
111    }
112
113    /**
114     * @param Request $request
115     * @param Tree    $tree
116     *
117     * @return JsonResponse
118     */
119    public function getMapDataAction(Request $request, Tree $tree): JsonResponse
120    {
121        $xref        = $request->get('reference');
122        $indi        = Individual::getInstance($xref, $tree);
123        $color_count = count(self::LINE_COLORS);
124
125        $facts = $this->getPedigreeMapFacts($request);
126
127        $geojson = [
128            'type'     => 'FeatureCollection',
129            'features' => [],
130        ];
131        if (empty($facts)) {
132            $code = 204;
133        } else {
134            $code = 200;
135            foreach ($facts as $id => $fact) {
136                $event = new FactLocation($fact, $indi);
137                $icon  = $event->getIconDetails();
138                if ($event->knownLatLon()) {
139                    $polyline         = null;
140                    $color            = self::LINE_COLORS[log($id, 2) % $color_count];
141                    $icon['color']    = $color; //make icon color the same as the line
142                    $sosa_points[$id] = $event->getLatLonJSArray();
143                    $sosa_parent      = (int)floor($id / 2);
144                    if (array_key_exists($sosa_parent, $sosa_points)) {
145                        // Would like to use a GeometryCollection to hold LineStrings
146                        // rather than generate polylines but the MarkerCluster library
147                        // doesn't seem to like them
148                        $polyline = [
149                            'points'  => [
150                                $sosa_points[$sosa_parent],
151                                $event->getLatLonJSArray(),
152                            ],
153                            'options' => [
154                                'color' => $color,
155                            ],
156                        ];
157                    }
158                    $geojson['features'][] = [
159                        'type'       => 'Feature',
160                        'id'         => $id,
161                        'valid'      => true,
162                        'geometry'   => [
163                            'type'        => 'Point',
164                            'coordinates' => $event->getGeoJsonCoords(),
165                        ],
166                        'properties' => [
167                            'polyline' => $polyline,
168                            'icon'     => $icon,
169                            'tooltip'  => $event->toolTip(),
170                            'summary'  => view('modules/pedigree-map/event-sidebar', $event->shortSummary('pedigree', $id)),
171                            'zoom'     => (int)$event->getZoom(),
172                        ],
173                    ];
174                }
175            }
176        }
177
178        return new JsonResponse($geojson, $code);
179    }
180
181    /**
182     * @param Request $request
183     * @param Tree    $tree
184     *
185     * @return array
186     */
187    private function getPedigreeMapFacts(Request $request, Tree $tree)
188    {
189        $xref        = $request->get('reference');
190        $individual  = Individual::getInstance($xref, $tree);
191        $generations = (int)$request->get(
192            'generations',
193            $tree->getPreference('DEFAULT_PEDIGREE_GENERATIONS')
194        );
195        $ancestors   = $this->sosaStradonitzAncestors($individual, $generations);
196        $facts       = [];
197        foreach ($ancestors as $sosa => $person) {
198            if ($person !== null && $person->canShow()) {
199                /** @var Fact $birth */
200                $birth = $person->getFirstFact('BIRT');
201                if ($birth && !$birth->getPlace()->isEmpty()) {
202                    $facts[$sosa] = $birth;
203                }
204            }
205        }
206
207        return $facts;
208    }
209
210    /**
211     * @param Request $request
212     *
213     * @return JsonResponse
214     */
215    public function getProviderStylesAction(Request $request): JsonResponse
216    {
217        $styles = $this->getMapProviderData($request);
218
219        return new JsonResponse($styles);
220    }
221
222    /**
223     * @param Request $request
224     *
225     * @return array|null
226     */
227    private function getMapProviderData(Request $request)
228    {
229        if (self::$map_providers === null) {
230            $providersFile        = WT_ROOT . WT_MODULES_DIR . 'openstreetmap/providers/providers.xml';
231            self::$map_selections = [
232                'provider' => $this->getPreference('provider', 'openstreetmap'),
233                'style'    => $this->getPreference('provider_style', 'mapnik'),
234            ];
235
236            try {
237                $xml = simplexml_load_file($providersFile);
238                // need to convert xml structure into arrays & strings
239                foreach ($xml as $provider) {
240                    $style_keys = array_map(
241                        function ($item) {
242                            return preg_replace('/[^a-z\d]/i', '', strtolower($item));
243                        },
244                        (array)$provider->styles
245                    );
246
247                    $key = preg_replace('/[^a-z\d]/i', '', strtolower((string)$provider->name));
248
249                    self::$map_providers[$key] = [
250                        'name'   => (string)$provider->name,
251                        'styles' => array_combine($style_keys, (array)$provider->styles),
252                    ];
253                }
254            } catch (Exception $ex) {
255                // Default provider is OpenStreetMap
256                self::$map_selections = [
257                    'provider' => 'openstreetmap',
258                    'style'    => 'mapnik',
259                ];
260                self::$map_providers  = [
261                    'openstreetmap' => [
262                        'name'   => 'OpenStreetMap',
263                        'styles' => ['mapnik' => 'Mapnik'],
264                    ],
265                ];
266            };
267        }
268
269        //Ugly!!!
270        switch ($request->get('action')) {
271            case 'BaseData':
272                $varName = (self::$map_selections['style'] === '') ? '' : self::$map_providers[self::$map_selections['provider']]['styles'][self::$map_selections['style']];
273                $payload = [
274                    'selectedProvIndex' => self::$map_selections['provider'],
275                    'selectedProvName'  => self::$map_providers[self::$map_selections['provider']]['name'],
276                    'selectedStyleName' => $varName,
277                ];
278                break;
279            case 'ProviderStyles':
280                $provider = $request->get('provider', 'openstreetmap');
281                $payload  = self::$map_providers[$provider]['styles'];
282                break;
283            case 'AdminConfig':
284                $providers = [];
285                foreach (self::$map_providers as $key => $provider) {
286                    $providers[$key] = $provider['name'];
287                }
288                $payload = [
289                    'providers'     => $providers,
290                    'selectedProv'  => self::$map_selections['provider'],
291                    'styles'        => self::$map_providers[self::$map_selections['provider']]['styles'],
292                    'selectedStyle' => self::$map_selections['style'],
293                ];
294                break;
295            default:
296                $payload = null;
297        }
298
299        return $payload;
300    }
301
302    /**
303     * @param Request $request
304     * @param Tree    $tree
305     *
306     * @return object
307     */
308    public function getPedigreeMapAction(Request $request, Tree $tree)
309    {
310        $xref           = $request->get('xref');
311        $individual     = Individual::getInstance($xref, $tree);
312        $maxgenerations = $tree->getPreference('MAX_PEDIGREE_GENERATIONS');
313        $generations    = $request->get('generations', $tree->getPreference('DEFAULT_PEDIGREE_GENERATIONS'));
314
315        if ($individual === null) {
316            throw new IndividualNotFoundException;
317        } elseif (!$individual->canShow()) {
318            throw new IndividualAccessDeniedException;
319        }
320
321        return (object)[
322            'name' => 'modules/pedigree-map/pedigree-map-page',
323            'data' => [
324                'module'         => $this->getName(),
325                'title'          => /* I18N: %s is an individual’s name */
326                    I18N::translate('Pedigree map of %s', $individual->getFullName()),
327                'tree'           => $tree,
328                'individual'     => $individual,
329                'generations'    => $generations,
330                'maxgenerations' => $maxgenerations,
331                'map'            => view(
332                    'modules/pedigree-map/pedigree-map',
333                    [
334                        'module'      => $this->getName(),
335                        'ref'         => $individual->getXref(),
336                        'type'        => 'pedigree',
337                        'generations' => $generations,
338                    ]
339                ),
340            ],
341        ];
342    }
343
344    // @TODO shift the following function to somewhere more appropriate during restructure
345
346    /**
347     * Copied from AbstractChartController.php
348     *
349     * Find the ancestors of an individual, and generate an array indexed by
350     * Sosa-Stradonitz number.
351     *
352     * @param Individual $individual  Start with this individual
353     * @param int        $generations Fetch this number of generations
354     *
355     * @return Individual[]
356     */
357    private function sosaStradonitzAncestors(Individual $individual, int $generations): array
358    {
359        /** @var Individual[] $ancestors */
360        $ancestors = [
361            1 => $individual,
362        ];
363
364        for ($i = 1, $max = 2 ** ($generations - 1); $i < $max; $i++) {
365            $ancestors[$i * 2]     = null;
366            $ancestors[$i * 2 + 1] = null;
367
368            $individual = $ancestors[$i];
369
370            if ($individual !== null) {
371                $family = $individual->getPrimaryChildFamily();
372                if ($family !== null) {
373                    if ($family->getHusband() !== null) {
374                        $ancestors[$i * 2] = $family->getHusband();
375                    }
376                    if ($family->getWife() !== null) {
377                        $ancestors[$i * 2 + 1] = $family->getWife();
378                    }
379                }
380            }
381        }
382
383        return $ancestors;
384    }
385}
386