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