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