xref: /webtrees/app/Module/PedigreeMapModule.php (revision f735852025b7cf25907aa75da63403e46a63b49b)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2019 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Module;
21
22use Aura\Router\RouterContainer;
23use Fig\Http\Message\RequestMethodInterface;
24use Fig\Http\Message\StatusCodeInterface;
25use Fisharebest\Webtrees\Auth;
26use Fisharebest\Webtrees\Fact;
27use Fisharebest\Webtrees\Family;
28use Fisharebest\Webtrees\Functions\Functions;
29use Fisharebest\Webtrees\GedcomTag;
30use Fisharebest\Webtrees\I18N;
31use Fisharebest\Webtrees\Individual;
32use Fisharebest\Webtrees\Location;
33use Fisharebest\Webtrees\Menu;
34use Fisharebest\Webtrees\Services\ChartService;
35use Psr\Http\Message\ResponseInterface;
36use Psr\Http\Message\ServerRequestInterface;
37use Psr\Http\Server\RequestHandlerInterface;
38
39use function app;
40use function assert;
41use function intdiv;
42use function redirect;
43use function route;
44use function view;
45
46/**
47 * Class PedigreeMapModule
48 */
49class PedigreeMapModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface
50{
51    use ModuleChartTrait;
52
53    private const ROUTE_NAME = 'pedigree-map';
54    private const ROUTE_URL  = '/tree/{tree}/pedigree-map-{generations}/{xref}';
55
56    // Defaults
57    public const DEFAULT_GENERATIONS = '4';
58    public const DEFAULT_PARAMETERS  = [
59        'generations' => self::DEFAULT_GENERATIONS,
60    ];
61
62    // Limits
63    public const MAXIMUM_GENERATIONS = 10;
64
65    private const LINE_COLORS = [
66        '#FF0000',
67        // Red
68        '#00FF00',
69        // Green
70        '#0000FF',
71        // Blue
72        '#FFB300',
73        // Gold
74        '#00FFFF',
75        // Cyan
76        '#FF00FF',
77        // Purple
78        '#7777FF',
79        // Light blue
80        '#80FF80'
81        // Light green
82    ];
83
84    /** @var ChartService */
85    private $chart_service;
86
87    /**
88     * PedigreeMapModule constructor.
89     *
90     * @param ChartService $chart_service
91     */
92    public function __construct(ChartService $chart_service)
93    {
94        $this->chart_service = $chart_service;
95    }
96
97    /**
98     * Initialization.
99     *
100     * @return void
101     */
102    public function boot(): void
103    {
104        $router_container = app(RouterContainer::class);
105        assert($router_container instanceof RouterContainer);
106
107        $router_container->getMap()
108            ->get(self::ROUTE_NAME, self::ROUTE_URL, $this)
109            ->allows(RequestMethodInterface::METHOD_POST)
110            ->tokens([
111                'generations' => '\d+',
112            ]);
113    }
114
115    /**
116     * How should this module be identified in the control panel, etc.?
117     *
118     * @return string
119     */
120    public function title(): string
121    {
122        /* I18N: Name of a module */
123        return I18N::translate('Pedigree map');
124    }
125
126    /**
127     * A sentence describing what this module does.
128     *
129     * @return string
130     */
131    public function description(): string
132    {
133        /* I18N: Description of the “Pedigree map” module */
134        return I18N::translate('Show the birthplace of ancestors on a map.');
135    }
136
137    /**
138     * CSS class for the URL.
139     *
140     * @return string
141     */
142    public function chartMenuClass(): string
143    {
144        return 'menu-chart-pedigreemap';
145    }
146
147    /**
148     * Return a menu item for this chart - for use in individual boxes.
149     *
150     * @param Individual $individual
151     *
152     * @return Menu|null
153     */
154    public function chartBoxMenu(Individual $individual): ?Menu
155    {
156        return $this->chartMenu($individual);
157    }
158
159    /**
160     * The title for a specific instance of this chart.
161     *
162     * @param Individual $individual
163     *
164     * @return string
165     */
166    public function chartTitle(Individual $individual): string
167    {
168        /* I18N: %s is an individual’s name */
169        return I18N::translate('Pedigree map of %s', $individual->fullName());
170    }
171
172    /**
173     * The URL for a page showing chart options.
174     *
175     * @param Individual $individual
176     * @param mixed[]    $parameters
177     *
178     * @return string
179     */
180    public function chartUrl(Individual $individual, array $parameters = []): string
181    {
182        return route(self::ROUTE_NAME, [
183                'tree' => $individual->tree()->name(),
184                'xref' => $individual->xref(),
185            ] + $parameters + self::DEFAULT_PARAMETERS);
186    }
187
188    /**
189     * @param ServerRequestInterface $request
190     *
191     * @return ResponseInterface
192     */
193    public function getMapDataAction(ServerRequestInterface $request): ResponseInterface
194    {
195        $tree        = $request->getAttribute('tree');
196        $xref        = $request->getQueryParams()['xref'];
197        $individual  = Individual::getInstance($xref, $tree);
198        $color_count = count(self::LINE_COLORS);
199
200        $facts = $this->getPedigreeMapFacts($request, $this->chart_service);
201
202        $geojson = [
203            'type'     => 'FeatureCollection',
204            'features' => [],
205        ];
206
207        $sosa_points = [];
208
209        foreach ($facts as $id => $fact) {
210            $location = new Location($fact->place()->gedcomName());
211
212            // Use the co-ordinates from the fact (if they exist).
213            $latitude  = $fact->latitude();
214            $longitude = $fact->longitude();
215
216            // Use the co-ordinates from the location otherwise.
217            if ($latitude === 0.0 && $longitude === 0.0) {
218                $latitude  = $location->latitude();
219                $longitude = $location->longitude();
220            }
221
222            $icon = ['color' => 'Gold', 'name' => 'bullseye '];
223            if ($latitude !== 0.0 || $longitude !== 0.0) {
224                $polyline         = null;
225                $color            = self::LINE_COLORS[log($id, 2) % $color_count];
226                $icon['color']    = $color; //make icon color the same as the line
227                $sosa_points[$id] = [$latitude, $longitude];
228                $sosa_parent      = intdiv($id, 2);
229                if (array_key_exists($sosa_parent, $sosa_points)) {
230                    // Would like to use a GeometryCollection to hold LineStrings
231                    // rather than generate polylines but the MarkerCluster library
232                    // doesn't seem to like them
233                    $polyline = [
234                        'points'  => [
235                            $sosa_points[$sosa_parent],
236                            [$latitude, $longitude],
237                        ],
238                        'options' => [
239                            'color' => $color,
240                        ],
241                    ];
242                }
243                $geojson['features'][] = [
244                    'type'       => 'Feature',
245                    'id'         => $id,
246                    'valid'      => true,
247                    'geometry'   => [
248                        'type'        => 'Point',
249                        'coordinates' => [$longitude, $latitude],
250                    ],
251                    'properties' => [
252                        'polyline' => $polyline,
253                        'icon'     => $icon,
254                        'tooltip'  => strip_tags($fact->place()->fullName()),
255                        'summary'  => view('modules/pedigree-map/events', $this->summaryData($individual, $fact, $id)),
256                        'zoom'     => $location->zoom() ?: 2,
257                    ],
258                ];
259            }
260        }
261
262        $code = empty($facts) ? StatusCodeInterface::STATUS_NO_CONTENT : StatusCodeInterface::STATUS_OK;
263
264        return response($geojson, $code);
265    }
266
267    /**
268     * @param ServerRequestInterface $request
269     *
270     * @return ResponseInterface
271     */
272    public function handle(ServerRequestInterface $request): ResponseInterface
273    {
274        $generations = (int) $request->getAttribute('generations');
275        $tree        = $request->getAttribute('tree');
276        $user        = $request->getAttribute('user');
277        $xref        = $request->getAttribute('xref');
278        $individual  = Individual::getInstance($xref, $tree);
279
280        // Convert POST requests into GET requests for pretty URLs.
281        if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
282            return redirect(route(self::ROUTE_NAME, [
283                'tree'        => $tree->name(),
284                'xref'        => $request->getParsedBody()['xref'],
285                'generations' => $request->getParsedBody()['generations'],
286            ]));
287        }
288
289        Auth::checkIndividualAccess($individual);
290        Auth::checkComponentAccess($this, 'chart', $tree, $user);
291
292        $map = view('modules/pedigree-map/chart', [
293            'module'      => $this->name(),
294            'individual'  => $individual,
295            'type'        => 'pedigree',
296            'generations' => $generations,
297        ]);
298
299        return $this->viewResponse('modules/pedigree-map/page', [
300            'module'         => $this->name(),
301            /* I18N: %s is an individual’s name */
302            'title'          => I18N::translate('Pedigree map of %s', $individual->fullName()),
303            'tree'           => $tree,
304            'individual'     => $individual,
305            'generations'    => $generations,
306            'maxgenerations' => self::MAXIMUM_GENERATIONS,
307            'map'            => $map,
308        ]);
309    }
310
311    /**
312     * @param ServerRequestInterface $request
313     * @param ChartService           $chart_service
314     *
315     * @return array
316     */
317    private function getPedigreeMapFacts(ServerRequestInterface $request, ChartService $chart_service): array
318    {
319        $generations = (int) $request->getQueryParams()['generations'];
320        $tree        = $request->getAttribute('tree');
321        $xref        = $request->getQueryParams()['xref'];
322        $individual  = Individual::getInstance($xref, $tree);
323        $ancestors   = $chart_service->sosaStradonitzAncestors($individual, $generations);
324        $facts       = [];
325        foreach ($ancestors as $sosa => $person) {
326            if ($person->canShow()) {
327                $birth = $person->facts(['BIRT'])->first();
328                if ($birth instanceof Fact && $birth->place()->gedcomName() !== '') {
329                    $facts[$sosa] = $birth;
330                }
331            }
332        }
333
334        return $facts;
335    }
336
337    /**
338     * @param Individual $individual
339     * @param Fact       $fact
340     * @param int        $sosa
341     *
342     * @return array
343     */
344    private function summaryData(Individual $individual, Fact $fact, int $sosa): array
345    {
346        $record      = $fact->record();
347        $name        = '';
348        $url         = '';
349        $tag         = $fact->label();
350        $addbirthtag = false;
351
352        if ($record instanceof Family) {
353            // Marriage
354            $spouse = $record->spouse($individual);
355            if ($spouse) {
356                $url  = $spouse->url();
357                $name = $spouse->fullName();
358            }
359        } elseif ($record !== $individual) {
360            // Birth of a child
361            $url  = $record->url();
362            $name = $record->fullName();
363            $tag  = GedcomTag::getLabel('_BIRT_CHIL', $record);
364        }
365
366        if ($sosa > 1) {
367            $addbirthtag = true;
368            $tag         = ucfirst($this->getSosaName($sosa));
369        }
370
371        return [
372            'tag'    => $tag,
373            'url'    => $url,
374            'name'   => $name,
375            'value'  => $fact->value(),
376            'date'   => $fact->date()->display(true),
377            'place'  => $fact->place(),
378            'addtag' => $addbirthtag,
379        ];
380    }
381
382    /**
383     * builds and returns sosa relationship name in the active language
384     *
385     * @param int $sosa Sosa number
386     *
387     * @return string
388     */
389    private function getSosaName(int $sosa): string
390    {
391        $path = '';
392
393        while ($sosa > 1) {
394            if ($sosa % 2 === 1) {
395                $path = 'mot' . $path;
396            } else {
397                $path = 'fat' . $path;
398            }
399            $sosa = intdiv($sosa, 2);
400        }
401
402        return Functions::getRelationshipNameFromPath($path);
403    }
404}
405