xref: /webtrees/app/Module/PedigreeChartModule.php (revision 202c018b592d5a516e4a465dc6dc515f3be37399)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2023 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 <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Module;
21
22use Fig\Http\Message\RequestMethodInterface;
23use Fisharebest\Webtrees\Auth;
24use Fisharebest\Webtrees\I18N;
25use Fisharebest\Webtrees\Individual;
26use Fisharebest\Webtrees\Menu;
27use Fisharebest\Webtrees\Registry;
28use Fisharebest\Webtrees\Services\ChartService;
29use Fisharebest\Webtrees\Validator;
30use Psr\Http\Message\ResponseInterface;
31use Psr\Http\Message\ServerRequestInterface;
32use Psr\Http\Server\RequestHandlerInterface;
33
34use function route;
35use function view;
36
37/**
38 * Class PedigreeChartModule
39 */
40class PedigreeChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface
41{
42    use ModuleChartTrait;
43
44    protected const ROUTE_URL = '/tree/{tree}/pedigree-{style}-{generations}/{xref}';
45
46    // Chart styles
47    public const STYLE_LEFT  = 'left';
48    public const STYLE_RIGHT = 'right';
49    public const STYLE_UP    = 'up';
50    public const STYLE_DOWN  = 'down';
51
52    // Defaults
53    public const    DEFAULT_GENERATIONS = '4';
54    public const    DEFAULT_STYLE       = self::STYLE_RIGHT;
55    protected const DEFAULT_PARAMETERS  = [
56        'generations' => self::DEFAULT_GENERATIONS,
57        'style'       => self::DEFAULT_STYLE,
58    ];
59
60    // Limits
61    protected const MINIMUM_GENERATIONS = 2;
62    protected const MAXIMUM_GENERATIONS = 12;
63
64    // For RTL languages
65    protected const MIRROR_STYLE = [
66        self::STYLE_UP    => self::STYLE_DOWN,
67        self::STYLE_DOWN  => self::STYLE_UP,
68        self::STYLE_LEFT  => self::STYLE_RIGHT,
69        self::STYLE_RIGHT => self::STYLE_LEFT,
70    ];
71
72    private ChartService $chart_service;
73
74    /**
75     * @param ChartService $chart_service
76     */
77    public function __construct(ChartService $chart_service)
78    {
79        $this->chart_service = $chart_service;
80    }
81
82    /**
83     * Initialization.
84     *
85     * @return void
86     */
87    public function boot(): void
88    {
89        Registry::routeFactory()->routeMap()
90            ->get(static::class, static::ROUTE_URL, $this)
91            ->allows(RequestMethodInterface::METHOD_POST);
92    }
93
94    /**
95     * How should this module be identified in the control panel, etc.?
96     *
97     * @return string
98     */
99    public function title(): string
100    {
101        /* I18N: Name of a module/chart */
102        return I18N::translate('Pedigree');
103    }
104
105    /**
106     * A sentence describing what this module does.
107     *
108     * @return string
109     */
110    public function description(): string
111    {
112        /* I18N: Description of the “PedigreeChart” module */
113        return I18N::translate('A chart of an individual’s ancestors, formatted as a tree.');
114    }
115
116    /**
117     * CSS class for the URL.
118     *
119     * @return string
120     */
121    public function chartMenuClass(): string
122    {
123        return 'menu-chart-pedigree';
124    }
125
126    /**
127     * Return a menu item for this chart - for use in individual boxes.
128     *
129     * @param Individual $individual
130     *
131     * @return Menu|null
132     */
133    public function chartBoxMenu(Individual $individual): Menu|null
134    {
135        return $this->chartMenu($individual);
136    }
137
138    /**
139     * The title for a specific instance of this chart.
140     *
141     * @param Individual $individual
142     *
143     * @return string
144     */
145    public function chartTitle(Individual $individual): string
146    {
147        /* I18N: %s is an individual’s name */
148        return I18N::translate('Pedigree tree of %s', $individual->fullName());
149    }
150
151    /**
152     * The URL for a page showing chart options.
153     *
154     * @param Individual                                $individual
155     * @param array<bool|int|string|array<string>|null> $parameters
156     *
157     * @return string
158     */
159    public function chartUrl(Individual $individual, array $parameters = []): string
160    {
161        return route(static::class, [
162                'xref' => $individual->xref(),
163                'tree' => $individual->tree()->name(),
164            ] + $parameters + static::DEFAULT_PARAMETERS);
165    }
166
167    /**
168     * @param ServerRequestInterface $request
169     *
170     * @return ResponseInterface
171     */
172    public function handle(ServerRequestInterface $request): ResponseInterface
173    {
174        $tree        = Validator::attributes($request)->tree();
175        $user        = Validator::attributes($request)->user();
176        $xref        = Validator::attributes($request)->isXref()->string('xref');
177        $style       = Validator::attributes($request)->isInArrayKeys($this->styles('ltr'))->string('style');
178        $generations = Validator::attributes($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('generations');
179        $ajax        = Validator::queryParams($request)->boolean('ajax', false);
180
181        // Convert POST requests into GET requests for pretty URLs.
182        if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
183            return redirect(route(self::class, [
184                'tree'        => $tree->name(),
185                'xref'        => Validator::parsedBody($request)->isXref()->string('xref'),
186                'style'       => Validator::parsedBody($request)->isInArrayKeys($this->styles('ltr'))->string('style'),
187                'generations' => Validator::parsedBody($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('generations'),
188            ]));
189        }
190
191        Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user);
192
193        $individual  = Registry::individualFactory()->make($xref, $tree);
194        $individual  = Auth::checkIndividualAccess($individual, false, true);
195
196        if ($ajax) {
197            $this->layout = 'layouts/ajax';
198
199            $ancestors = $this->chart_service->sosaStradonitzAncestors($individual, $generations);
200
201            // Father’s ancestors link to the father’s pedigree
202            // Mother’s ancestors link to the mother’s pedigree..
203            $links = $ancestors->map(function (Individual|null $individual, $sosa) use ($ancestors, $style, $generations): string {
204                if ($individual instanceof Individual && $sosa >= 2 ** $generations / 2 && $individual->childFamilies()->isNotEmpty()) {
205                    // The last row/column, and there are more generations.
206                    if ($sosa >= 2 ** $generations * 3 / 4) {
207                        return $this->nextLink($ancestors->get(3), $style, $generations);
208                    }
209
210                    return $this->nextLink($ancestors->get(2), $style, $generations);
211                }
212
213                // A spacer to fix the "Left" layout.
214                return '<span class="invisible px-2">' . view('icons/arrow-' . $style) . '</span>';
215            });
216
217            // Root individual links to their children.
218            $links->put(1, $this->previousLink($individual, $style, $generations));
219
220            return $this->viewResponse('modules/pedigree-chart/chart', [
221                'ancestors'   => $ancestors,
222                'generations' => $generations,
223                'style'       => $style,
224                'layout'      => 'right',
225                'links'       => $links,
226                'spacer'      => $this->spacer(),
227            ]);
228        }
229
230        $ajax_url = $this->chartUrl($individual, [
231            'ajax'        => true,
232            'generations' => $generations,
233            'style'       => $style,
234            'xref'        => $xref,
235        ]);
236
237        return $this->viewResponse('modules/pedigree-chart/page', [
238            'ajax_url'           => $ajax_url,
239            'generations'        => $generations,
240            'individual'         => $individual,
241            'module'             => $this->name(),
242            'max_generations'    => self::MAXIMUM_GENERATIONS,
243            'min_generations'    => self::MINIMUM_GENERATIONS,
244            'style'              => $style,
245            'styles'             => $this->styles(I18N::direction()),
246            'title'              => $this->chartTitle($individual),
247            'tree'               => $tree,
248        ]);
249    }
250
251    /**
252     * A link-sized spacer, to maintain the chart layout
253     *
254     * @return string
255     */
256    public function spacer(): string
257    {
258        return '<span class="px-2">' . view('icons/spacer') . '</span>';
259    }
260
261    /**
262     * Build a menu for the chart root individual
263     *
264     * @param Individual $individual
265     * @param string     $style
266     * @param int        $generations
267     *
268     * @return string
269     */
270    public function nextLink(Individual $individual, string $style, int $generations): string
271    {
272        $icon  = view('icons/arrow-' . $style);
273        $title = $this->chartTitle($individual);
274        $url   = $this->chartUrl($individual, [
275            'style'       => $style,
276            'generations' => $generations,
277        ]);
278
279        return '<a class="px-2" href="' . e($url) . '" title="' . strip_tags($title) . '">' . $icon . '<span class="visually-hidden">' . $title . '</span></a>';
280    }
281
282    /**
283     * Build a menu for the chart root individual
284     *
285     * @param Individual $individual
286     * @param string     $style
287     * @param int        $generations
288     *
289     * @return string
290     */
291    public function previousLink(Individual $individual, string $style, int $generations): string
292    {
293        $icon = view('icons/arrow-' . self::MIRROR_STYLE[$style]);
294
295        $siblings = [];
296        $spouses  = [];
297        $children = [];
298
299        foreach ($individual->childFamilies() as $family) {
300            foreach ($family->children() as $child) {
301                if ($child !== $individual) {
302                    $siblings[] = $this->individualLink($child, $style, $generations);
303                }
304            }
305        }
306
307        foreach ($individual->spouseFamilies() as $family) {
308            foreach ($family->spouses() as $spouse) {
309                if ($spouse !== $individual) {
310                    $spouses[] = $this->individualLink($spouse, $style, $generations);
311                }
312            }
313
314            foreach ($family->children() as $child) {
315                $children[] = $this->individualLink($child, $style, $generations);
316            }
317        }
318
319        return view('modules/pedigree-chart/previous', [
320            'icon'        => $icon,
321            'individual'  => $individual,
322            'generations' => $generations,
323            'style'       => $style,
324            'chart'       => $this,
325            'siblings'    => $siblings,
326            'spouses'     => $spouses,
327            'children'    => $children,
328        ]);
329    }
330
331    /**
332     * @param Individual $individual
333     * @param string     $style
334     * @param int        $generations
335     *
336     * @return string
337     */
338    protected function individualLink(Individual $individual, string $style, int $generations): string
339    {
340        $text  = $individual->fullName();
341        $title = $this->chartTitle($individual);
342        $url   = $this->chartUrl($individual, [
343            'style'       => $style,
344            'generations' => $generations,
345        ]);
346
347        return '<a class="dropdown-item" href="' . e($url) . '" title="' . strip_tags($title) . '">' . $text . '</a>';
348    }
349
350    /**
351     * This chart can display its output in a number of styles
352     *
353     * @param string $direction
354     *
355     * @return array<string>
356     */
357    protected function styles(string $direction): array
358    {
359        // On right-to-left pages, the CSS will mirror the chart, so we need to mirror the label.
360        if ($direction === 'rtl') {
361            return [
362                self::STYLE_RIGHT => view('icons/pedigree-left') . I18N::translate('left'),
363                self::STYLE_LEFT  => view('icons/pedigree-right') . I18N::translate('right'),
364                self::STYLE_UP    => view('icons/pedigree-up') . I18N::translate('up'),
365                self::STYLE_DOWN  => view('icons/pedigree-down') . I18N::translate('down'),
366            ];
367        }
368
369        return [
370            self::STYLE_LEFT  => view('icons/pedigree-left') . I18N::translate('left'),
371            self::STYLE_RIGHT => view('icons/pedigree-right') . I18N::translate('right'),
372            self::STYLE_UP    => view('icons/pedigree-up') . I18N::translate('up'),
373            self::STYLE_DOWN  => view('icons/pedigree-down') . I18N::translate('down'),
374        ];
375    }
376}
377