xref: /webtrees/app/Module/PedigreeChartModule.php (revision 33c343964203a24deeb34413eac7f48bee172865)
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 Fisharebest\Webtrees\Auth;
21use Fisharebest\Webtrees\Functions\FunctionsEdit;
22use Fisharebest\Webtrees\I18N;
23use Fisharebest\Webtrees\Individual;
24use Fisharebest\Webtrees\Menu;
25use Fisharebest\Webtrees\Services\ChartService;
26use Fisharebest\Webtrees\Theme;
27use Fisharebest\Webtrees\Tree;
28use Fisharebest\Webtrees\User;
29use stdClass;
30use Symfony\Component\HttpFoundation\Request;
31use Symfony\Component\HttpFoundation\Response;
32
33/**
34 * Class PedigreeChartModule
35 */
36class PedigreeChartModule extends AbstractModule implements ModuleChartInterface
37{
38    use ModuleChartTrait;
39
40    // With more than 8 generations, we run out of pixels on the <canvas>
41    protected const MAX_GENERATIONS = 8;
42    protected const MIN_GENERATIONS = 2;
43
44    protected const DEFAULT_GENERATIONS = '4';
45
46    /**
47     * Chart orientation codes
48     * Dont change them! the offset calculations rely on this order
49     */
50    public const PORTRAIT         = 0;
51    public const LANDSCAPE        = 1;
52    public const OLDEST_AT_TOP    = 2;
53    public const OLDEST_AT_BOTTOM = 3;
54
55    protected const DEFAULT_ORIENTATION = self::LANDSCAPE;
56
57    /** @var int Number of generation to display */
58    protected $generations;
59
60    /** @var array data pertaining to each chart node */
61    protected $nodes = [];
62
63    /** @var int Number of nodes in the chart */
64    protected $treesize;
65
66    /** @var stdClass Determine which arrows to use for each of the chart orientations */
67    protected $arrows;
68
69    /** @var Individual */
70    protected $root;
71
72    /**
73     * Next and previous generation arrow size in pixels.
74     */
75    protected const ARROW_SIZE = 22;
76
77    /**
78     * How should this module be labelled on tabs, menus, etc.?
79     *
80     * @return string
81     */
82    public function title(): string
83    {
84        /* I18N: Name of a module/chart */
85        return I18N::translate('Pedigree');
86    }
87
88    /**
89     * A sentence describing what this module does.
90     *
91     * @return string
92     */
93    public function description(): string
94    {
95        /* I18N: Description of the “PedigreeChart” module */
96        return I18N::translate('A chart of an individual’s ancestors, formatted as a tree.');
97    }
98
99    /**
100     * CSS class for the URL.
101     *
102     * @return string
103     */
104    public function chartMenuClass(): string
105    {
106        return 'menu-chart-pedigree';
107    }
108
109    /**
110     * Return a menu item for this chart - for use in individual boxes.
111     *
112     * @param Individual $individual
113     *
114     * @return Menu|null
115     */
116    public function chartBoxMenu(Individual $individual): ?Menu
117    {
118        return $this->chartMenu($individual);
119    }
120
121    /**
122     * The title for a specific instance of this chart.
123     *
124     * @param Individual $individual
125     *
126     * @return string
127     */
128    public function chartTitle(Individual $individual): string
129    {
130        /* I18N: %s is an individual’s name */
131        return I18N::translate('Pedigree tree of %s', $individual->getFullName());
132    }
133
134    /**
135     * A form to request the chart parameters.
136     *
137     * @param Request      $request
138     * @param Tree         $tree
139     * @param User         $user
140     * @param ChartService $chart_service
141     *
142     * @return Response
143     */
144    public function getChartAction(Request $request, Tree $tree, User $user, ChartService $chart_service): Response
145    {
146        $ajax       = (bool) $request->get('ajax');
147        $xref       = $request->get('xref', '');
148        $individual = Individual::getInstance($xref, $tree);
149
150        Auth::checkIndividualAccess($individual);
151        Auth::checkComponentAccess($this, 'chart', $tree, $user);
152
153        $orientation = (int) $request->get('orientation', static::DEFAULT_ORIENTATION);
154        $generations = (int) $request->get('generations', static::DEFAULT_GENERATIONS);
155
156        $generations = min(static::MAX_GENERATIONS, $generations);
157        $generations = max(static::MIN_GENERATIONS, $generations);
158
159        $generation_options = $this->generationOptions();
160
161        if ($ajax) {
162            return $this->chart($individual, $generations, $orientation, $chart_service);
163        }
164
165        $ajax_url = $this->chartUrl($individual, [
166            'ajax'        => true,
167            'generations' => $generations,
168            'orientation' => $orientation,
169        ]);
170
171        return $this->viewResponse('modules/pedigree-chart/page', [
172            'ajax_url'           => $ajax_url,
173            'generations'        => $generations,
174            'generation_options' => $generation_options,
175            'individual'         => $individual,
176            'module_name'        => $this->name(),
177            'orientation'        => $orientation,
178            'orientations'       => $this->orientations(),
179            'title'              => $this->chartTitle($individual),
180        ]);
181    }
182
183    /**
184     * @param Individual   $individual
185     * @param int          $generations
186     * @param int          $orientation
187     * @param ChartService $chart_service
188     *
189     * @return Response
190     */
191    public function chart(Individual $individual, int $generations, int $orientation, ChartService $chart_service): Response
192    {
193        $bxspacing = Theme::theme()->parameter('chart-spacing-x');
194        $byspacing = Theme::theme()->parameter('chart-spacing-y');
195        $curgen    = 1; // Track which generation the algorithm is currently working on
196        $addoffset = [];
197
198        $this->root = $individual;
199
200        $this->treesize = (2 ** $generations) - 1;
201
202        $this->nodes = [];
203
204        $ancestors = $chart_service->sosaStradonitzAncestors($individual, $generations);
205
206        // $ancestors starts array at index 1 we need to start at 0
207        for ($i = 0; $i < $this->treesize; ++$i) {
208            $this->nodes[$i] = [
209                'indi' => $ancestors->get($i + 1),
210                'x'    => 0,
211                'y'    => 0,
212            ];
213        }
214
215        // Are there ancestors beyond the bounds of this chart
216        $chart_has_ancestors = false;
217
218        // Check earliest generation for any ancestors
219        for ($i = (int) ($this->treesize / 2); $i < $this->treesize; $i++) {
220            $chart_has_ancestors = $chart_has_ancestors || ($this->nodes[$i]['indi'] && $this->nodes[$i]['indi']->getChildFamilies());
221        }
222
223        $this->arrows = new stdClass();
224        switch ($orientation) {
225            default:
226            case static::PORTRAIT:
227            case static::LANDSCAPE:
228                $this->arrows->prevGen = 'fas fa-arrow-end wt-icon-arrow-end';
229                $this->arrows->menu    = 'fas fa-arrow-start wt-icon-arrow-start';
230                $addoffset['x']        = $chart_has_ancestors ? static::ARROW_SIZE : 0;
231                $addoffset['y']        = 0;
232                break;
233
234            case static::OLDEST_AT_TOP:
235                $this->arrows->prevGen = 'fas fa-arrow-up wt-icon-arrow-up';
236                $this->arrows->menu    = 'fas fa-arrow-down wt-icon-arrow-down';
237                $addoffset['x']        = 0;
238                $addoffset['y']        = $this->root->getSpouseFamilies() ? static::ARROW_SIZE : 0;
239                break;
240
241            case static::OLDEST_AT_BOTTOM:
242                $this->arrows->prevGen = 'fas fa-arrow-down wt-icon-arrow-down';
243                $this->arrows->menu    = 'fas fa-arrow-up wt-icon-arrow-up';
244                $addoffset['x']        = 0;
245                $addoffset['y']        = $chart_has_ancestors ? static::ARROW_SIZE : 0;
246                break;
247        }
248
249        // Create and position the DIV layers for the pedigree tree
250        for ($i = ($this->treesize - 1); $i >= 0; $i--) {
251            // Check to see if we have moved to the next generation
252            if ($i < (int) ($this->treesize / (2 ** $curgen))) {
253                $curgen++;
254            }
255
256            // Box position in current generation
257            $boxpos = $i - (2 ** ($this->generations - $curgen));
258            // Offset multiple for current generation
259            if ($orientation < static::OLDEST_AT_TOP) {
260                $genoffset  = 2 ** ($curgen - $orientation);
261                $boxspacing = Theme::theme()->parameter('chart-box-y') + $byspacing;
262            } else {
263                $genoffset  = 2 ** ($curgen - 1);
264                $boxspacing = Theme::theme()->parameter('chart-box-x') + $byspacing;
265            }
266            // Calculate the yoffset position in the generation put child between parents
267            $yoffset = ($boxpos * ($boxspacing * $genoffset)) + (($boxspacing / 2) * $genoffset) + ($boxspacing * $genoffset);
268
269            // Calculate the xoffset
270            switch ($orientation) {
271                default:
272                case static::PORTRAIT:
273                    $xoffset = ($this->generations - $curgen) * ((Theme::theme()->parameter('chart-box-x') + $bxspacing) / 1.8);
274                    if (!$i && $this->root->getSpouseFamilies()) {
275                        $xoffset -= static::ARROW_SIZE;
276                    }
277                    // Compact the tree
278                    if ($curgen < $this->generations) {
279                        if ($i % 2 == 0) {
280                            $yoffset = $yoffset - (($boxspacing / 2) * ($curgen - 1));
281                        } else {
282                            $yoffset = $yoffset + (($boxspacing / 2) * ($curgen - 1));
283                        }
284                        $parent = (int) (($i - 1) / 2);
285                        $pgen   = $curgen;
286                        while ($parent > 0) {
287                            if ($parent % 2 == 0) {
288                                $yoffset = $yoffset - (($boxspacing / 2) * $pgen);
289                            } else {
290                                $yoffset = $yoffset + (($boxspacing / 2) * $pgen);
291                            }
292                            $pgen++;
293                            if ($pgen > 3) {
294                                $temp = 0;
295                                for ($j = 1; $j < ($pgen - 2); $j++) {
296                                    $temp += ((2 ** $j) - 1);
297                                }
298                                if ($parent % 2 == 0) {
299                                    $yoffset = $yoffset - (($boxspacing / 2) * $temp);
300                                } else {
301                                    $yoffset = $yoffset + (($boxspacing / 2) * $temp);
302                                }
303                            }
304                            $parent = (int) (($parent - 1) / 2);
305                        }
306                        if ($curgen > 3) {
307                            $temp = 0;
308                            for ($j = 1; $j < ($curgen - 2); $j++) {
309                                $temp += ((2 ** $j) - 1);
310                            }
311                            if ($i % 2 == 0) {
312                                $yoffset = $yoffset - (($boxspacing / 2) * $temp);
313                            } else {
314                                $yoffset = $yoffset + (($boxspacing / 2) * $temp);
315                            }
316                        }
317                    }
318                    $yoffset -= (($boxspacing / 2) * (2 ** ($this->generations - 2)) - ($boxspacing / 2));
319                    break;
320
321                case static::LANDSCAPE:
322                    $xoffset = ($this->generations - $curgen) * (Theme::theme()->parameter('chart-box-x') + $bxspacing);
323                    if ($curgen == 1) {
324                        $xoffset += 10;
325                    }
326                    break;
327
328                case static::OLDEST_AT_TOP:
329                    // Swap x & y offsets as chart is rotated
330                    $xoffset = $yoffset;
331                    $yoffset = $curgen * (Theme::theme()->parameter('chart-box-y') + ($byspacing * 4));
332                    break;
333
334                case static::OLDEST_AT_BOTTOM:
335                    // Swap x & y offsets as chart is rotated
336                    $xoffset = $yoffset;
337                    $yoffset = ($this->generations - $curgen) * (Theme::theme()->parameter('chart-box-y') + ($byspacing * 2));
338                    if ($i && $this->root->getSpouseFamilies()) {
339                        $yoffset += static::ARROW_SIZE;
340                    }
341                    break;
342            }
343            $this->nodes[$i]['x'] = (int) $xoffset;
344            $this->nodes[$i]['y'] = (int) $yoffset;
345        }
346
347        // Find the minimum x & y offsets and deduct that number from
348        // each value in the array so that offsets start from zero
349        $min_xoffset = min(array_map(function (array $item): int {
350            return $item['x'];
351        }, $this->nodes));
352        $min_yoffset = min(array_map(function (array $item): int {
353            return $item['y'];
354        }, $this->nodes));
355
356        array_walk($this->nodes, function (&$item) use ($min_xoffset, $min_yoffset) {
357            $item['x'] -= $min_xoffset;
358            $item['y'] -= $min_yoffset;
359        });
360
361        // Calculate chart & canvas dimensions
362        $max_xoffset = max(array_map(function ($item) {
363            return $item['x'];
364        }, $this->nodes));
365        $max_yoffset = max(array_map(function ($item) {
366            return $item['y'];
367        }, $this->nodes));
368
369        $canvas_width   = $max_xoffset + $bxspacing + Theme::theme()->parameter('chart-box-x') + $addoffset['x'];
370        $canvas_height  = $max_yoffset + $byspacing + Theme::theme()->parameter('chart-box-y') + $addoffset['y'];
371        $posn           = I18N::direction() === 'rtl' ? 'right' : 'left';
372        $last_gen_start = (int) floor($this->treesize / 2);
373        if ($orientation === static::OLDEST_AT_TOP || $orientation === static::OLDEST_AT_BOTTOM) {
374            $flex_direction = ' flex-column';
375        } else {
376            $flex_direction = '';
377        }
378
379        foreach ($this->nodes as $n => $node) {
380            if ($n >= $last_gen_start) {
381                $this->nodes[$n]['previous_gen'] = $this->gotoPreviousGen($n, $generations, $orientation, $chart_has_ancestors);
382            } else {
383                $this->nodes[$n]['previous_gen'] = '';
384            }
385        }
386
387        $html = view('modules/pedigree-chart/chart', [
388            'canvas_height'    => $canvas_height,
389            'canvas_width'     => $canvas_width,
390            'child_menu'       => $this->getMenu($individual, $generations, $orientation),
391            'flex_direction'   => $flex_direction,
392            'last_gen_start'   => $last_gen_start,
393            'orientation'      => $orientation,
394            'nodes'            => $this->nodes,
395            'landscape'        => static::LANDSCAPE,
396            'oldest_at_top'    => static::OLDEST_AT_TOP,
397            'oldest_at_bottom' => static::OLDEST_AT_BOTTOM,
398            'portrait'         => static::PORTRAIT,
399            'posn'             => $posn,
400        ]);
401
402        return new Response($html);
403    }
404
405    /**
406     * Build a menu for the chart root individual
407     *
408     * @param Individual $root
409     * @param int        $generations
410     * @param int        $orientation
411     *
412     * @return string
413     */
414    public function getMenu(Individual $root, int $generations, int $orientation): string
415    {
416        $families = $root->getSpouseFamilies();
417        $html     = '';
418        if (!empty($families)) {
419            $html = sprintf('<div id="childarrow"><a href="#" class="menuselect %s"></a><div id="childbox-pedigree">', $this->arrows->menu);
420
421            foreach ($families as $family) {
422                $html   .= '<span class="name1">' . I18N::translate('Family') . '</span>';
423                $spouse = $family->getSpouse($root);
424                if ($spouse) {
425                    $html .= '<a class="name1" href="' . e($this->chartUrl($spouse, ['generations' => $generations, 'orientation' => $orientation])) . '">' . $spouse->getFullName() . '</a>';
426                }
427                $children = $family->getChildren();
428                foreach ($children as $sibling) {
429                    $html .= '<a class="name1" href="' . e($this->chartUrl($sibling, ['generations' => $generations, 'orientation' => $orientation])) . '">' . $sibling->getFullName() . '</a>';
430                }
431            }
432
433            foreach ($root->getChildFamilies() as $family) {
434                $siblings = array_filter($family->getChildren(), function (Individual $item) use ($root): bool {
435                    return $root->xref() !== $item->xref();
436                });
437                if (!empty($siblings)) {
438                    $html .= '<span class="name1">';
439                    $html .= count($siblings) > 1 ? I18N::translate('Siblings') : I18N::translate('Sibling');
440                    $html .= '</span>';
441                    foreach ($siblings as $sibling) {
442                        $html .= '<a class="name1" href="' . e($this->chartUrl($sibling, ['generations' => $generations, 'orientation' => $orientation])) . '">' . $sibling->getFullName() . '</a>';
443                    }
444                }
445            }
446            $html .= '</div></div>';
447        }
448
449        return $html;
450    }
451
452    /**
453     * Function gotoPreviousGen
454     * Create a link to generate a new chart based on the correct parent of the individual with this index
455     *
456     * @param int  $index
457     * @param int  $generations
458     * @param int  $orientation
459     * @param bool $chart_has_ancestors
460     *
461     * @return string
462     */
463    public function gotoPreviousGen(int $index, int $generations, int $orientation, bool $chart_has_ancestors): string
464    {
465        $html = '';
466        if ($chart_has_ancestors) {
467            if ($this->nodes[$index]['indi'] && $this->nodes[$index]['indi']->getChildFamilies()) {
468                $html         .= '<div class="ancestorarrow">';
469                $rootParentId = 1;
470                if ($index > (int) ($this->treesize / 2) + (int) ($this->treesize / 4)) {
471                    $rootParentId++;
472                }
473                $html .= '<a class="' . $this->arrows->prevGen . '" href="' . e($this->chartUrl($this->nodes[$rootParentId]['indi'], ['generations' => $generations, 'orientation' => $orientation])) . '"></a>';
474                $html .= '</div>';
475            } else {
476                $html .= '<div class="spacer"></div>';
477            }
478        }
479
480        return $html;
481    }
482
483    /**
484     * @return string[]
485     */
486    protected function generationOptions(): array
487    {
488        return FunctionsEdit::numericOptions(range(static::MIN_GENERATIONS, static::MAX_GENERATIONS));
489    }
490
491    /**
492     * @return string[]
493     */
494    protected function orientations(): array
495    {
496        return [
497            0 => I18N::translate('Portrait'),
498            1 => I18N::translate('Landscape'),
499            2 => I18N::translate('Oldest at top'),
500            3 => I18N::translate('Oldest at bottom'),
501        ];
502    }
503}
504