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