xref: /webtrees/app/Module/PedigreeChartModule.php (revision a34b54dc84837d4fd1e3b55dfaf41e1761acb658)
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\Contracts\UserInterface;
22use Fisharebest\Webtrees\Functions\FunctionsEdit;
23use Fisharebest\Webtrees\I18N;
24use Fisharebest\Webtrees\Individual;
25use Fisharebest\Webtrees\Menu;
26use Fisharebest\Webtrees\Services\ChartService;
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 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    public const PORTRAIT         = 0;
50    public const LANDSCAPE        = 1;
51    public const OLDEST_AT_TOP    = 2;
52    public 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 UserInterface $user
139     * @param ChartService  $chart_service
140     *
141     * @return Response
142     */
143    public function getChartAction(Request $request, Tree $tree, UserInterface $user, ChartService $chart_service): Response
144    {
145        $ajax       = (bool) $request->get('ajax');
146        $xref       = $request->get('xref', '');
147        $individual = Individual::getInstance($xref, $tree);
148
149        Auth::checkIndividualAccess($individual);
150        Auth::checkComponentAccess($this, 'chart', $tree, $user);
151
152        $orientation = (int) $request->get('orientation', static::DEFAULT_ORIENTATION);
153        $generations = (int) $request->get('generations', static::DEFAULT_GENERATIONS);
154
155        $generations = min(static::MAX_GENERATIONS, $generations);
156        $generations = max(static::MIN_GENERATIONS, $generations);
157
158        $generation_options = $this->generationOptions();
159
160        if ($ajax) {
161            return $this->chart($individual, $generations, $orientation, $chart_service);
162        }
163
164        $ajax_url = $this->chartUrl($individual, [
165            'ajax'        => true,
166            'generations' => $generations,
167            'orientation' => $orientation,
168        ]);
169
170        return $this->viewResponse('modules/pedigree-chart/page', [
171            'ajax_url'           => $ajax_url,
172            'generations'        => $generations,
173            'generation_options' => $generation_options,
174            'individual'         => $individual,
175            'module_name'        => $this->name(),
176            'orientation'        => $orientation,
177            'orientations'       => $this->orientations(),
178            'title'              => $this->chartTitle($individual),
179        ]);
180    }
181
182    /**
183     * @param Individual   $individual
184     * @param int          $generations
185     * @param int          $orientation
186     * @param ChartService $chart_service
187     *
188     * @return Response
189     */
190    public function chart(Individual $individual, int $generations, int $orientation, ChartService $chart_service): Response
191    {
192        $bxspacing = app()->make(ModuleThemeInterface::class)->parameter('chart-spacing-x');
193        $byspacing = app()->make(ModuleThemeInterface::class)->parameter('chart-spacing-y');
194        $curgen    = 1; // Track which generation the algorithm is currently working on
195        $addoffset = [];
196
197        $this->root = $individual;
198
199        $this->treesize = (2 ** $generations) - 1;
200
201        $this->nodes = [];
202
203        $ancestors = $chart_service->sosaStradonitzAncestors($individual, $generations);
204
205        // $ancestors starts array at index 1 we need to start at 0
206        for ($i = 0; $i < $this->treesize; ++$i) {
207            $this->nodes[$i] = [
208                'indi' => $ancestors->get($i + 1),
209                'x'    => 0,
210                'y'    => 0,
211            ];
212        }
213
214        // Are there ancestors beyond the bounds of this chart
215        $chart_has_ancestors = false;
216
217        // Check earliest generation for any ancestors
218        for ($i = (int) ($this->treesize / 2); $i < $this->treesize; $i++) {
219            $chart_has_ancestors = $chart_has_ancestors || ($this->nodes[$i]['indi'] && $this->nodes[$i]['indi']->getChildFamilies());
220        }
221
222        $this->arrows = new stdClass();
223        switch ($orientation) {
224            default:
225            case static::PORTRAIT:
226            case static::LANDSCAPE:
227                $this->arrows->prevGen = 'fas fa-arrow-end wt-icon-arrow-end';
228                $this->arrows->menu    = 'fas fa-arrow-start wt-icon-arrow-start';
229                $addoffset['x']        = $chart_has_ancestors ? static::ARROW_SIZE : 0;
230                $addoffset['y']        = 0;
231                break;
232
233            case static::OLDEST_AT_TOP:
234                $this->arrows->prevGen = 'fas fa-arrow-up wt-icon-arrow-up';
235                $this->arrows->menu    = 'fas fa-arrow-down wt-icon-arrow-down';
236                $addoffset['x']        = 0;
237                $addoffset['y']        = $this->root->getSpouseFamilies() ? static::ARROW_SIZE : 0;
238                break;
239
240            case static::OLDEST_AT_BOTTOM:
241                $this->arrows->prevGen = 'fas fa-arrow-down wt-icon-arrow-down';
242                $this->arrows->menu    = 'fas fa-arrow-up wt-icon-arrow-up';
243                $addoffset['x']        = 0;
244                $addoffset['y']        = $chart_has_ancestors ? static::ARROW_SIZE : 0;
245                break;
246        }
247
248        // Create and position the DIV layers for the pedigree tree
249        for ($i = ($this->treesize - 1); $i >= 0; $i--) {
250            // Check to see if we have moved to the next generation
251            if ($i < (int) ($this->treesize / (2 ** $curgen))) {
252                $curgen++;
253            }
254
255            // Box position in current generation
256            $boxpos = $i - (2 ** ($this->generations - $curgen));
257            // Offset multiple for current generation
258            if ($orientation < static::OLDEST_AT_TOP) {
259                $genoffset  = 2 ** ($curgen - $orientation);
260                $boxspacing = app()->make(ModuleThemeInterface::class)->parameter('chart-box-y') + $byspacing;
261            } else {
262                $genoffset  = 2 ** ($curgen - 1);
263                $boxspacing = app()->make(ModuleThemeInterface::class)->parameter('chart-box-x') + $byspacing;
264            }
265            // Calculate the yoffset position in the generation put child between parents
266            $yoffset = ($boxpos * ($boxspacing * $genoffset)) + (($boxspacing / 2) * $genoffset) + ($boxspacing * $genoffset);
267
268            // Calculate the xoffset
269            switch ($orientation) {
270                default:
271                case static::PORTRAIT:
272                    $xoffset = ($this->generations - $curgen) * ((app()->make(ModuleThemeInterface::class)->parameter('chart-box-x') + $bxspacing) / 1.8);
273                    if (!$i && $this->root->getSpouseFamilies()) {
274                        $xoffset -= static::ARROW_SIZE;
275                    }
276                    // Compact the tree
277                    if ($curgen < $this->generations) {
278                        if ($i % 2 == 0) {
279                            $yoffset = $yoffset - (($boxspacing / 2) * ($curgen - 1));
280                        } else {
281                            $yoffset = $yoffset + (($boxspacing / 2) * ($curgen - 1));
282                        }
283                        $parent = (int) (($i - 1) / 2);
284                        $pgen   = $curgen;
285                        while ($parent > 0) {
286                            if ($parent % 2 == 0) {
287                                $yoffset = $yoffset - (($boxspacing / 2) * $pgen);
288                            } else {
289                                $yoffset = $yoffset + (($boxspacing / 2) * $pgen);
290                            }
291                            $pgen++;
292                            if ($pgen > 3) {
293                                $temp = 0;
294                                for ($j = 1; $j < ($pgen - 2); $j++) {
295                                    $temp += ((2 ** $j) - 1);
296                                }
297                                if ($parent % 2 == 0) {
298                                    $yoffset = $yoffset - (($boxspacing / 2) * $temp);
299                                } else {
300                                    $yoffset = $yoffset + (($boxspacing / 2) * $temp);
301                                }
302                            }
303                            $parent = (int) (($parent - 1) / 2);
304                        }
305                        if ($curgen > 3) {
306                            $temp = 0;
307                            for ($j = 1; $j < ($curgen - 2); $j++) {
308                                $temp += ((2 ** $j) - 1);
309                            }
310                            if ($i % 2 == 0) {
311                                $yoffset = $yoffset - (($boxspacing / 2) * $temp);
312                            } else {
313                                $yoffset = $yoffset + (($boxspacing / 2) * $temp);
314                            }
315                        }
316                    }
317                    $yoffset -= (($boxspacing / 2) * (2 ** ($this->generations - 2)) - ($boxspacing / 2));
318                    break;
319
320                case static::LANDSCAPE:
321                    $xoffset = ($this->generations - $curgen) * (app()->make(ModuleThemeInterface::class)->parameter('chart-box-x') + $bxspacing);
322                    if ($curgen == 1) {
323                        $xoffset += 10;
324                    }
325                    break;
326
327                case static::OLDEST_AT_TOP:
328                    // Swap x & y offsets as chart is rotated
329                    $xoffset = $yoffset;
330                    $yoffset = $curgen * (app()->make(ModuleThemeInterface::class)->parameter('chart-box-y') + ($byspacing * 4));
331                    break;
332
333                case static::OLDEST_AT_BOTTOM:
334                    // Swap x & y offsets as chart is rotated
335                    $xoffset = $yoffset;
336                    $yoffset = ($this->generations - $curgen) * (app()->make(ModuleThemeInterface::class)->parameter('chart-box-y') + ($byspacing * 2));
337                    if ($i && $this->root->getSpouseFamilies()) {
338                        $yoffset += static::ARROW_SIZE;
339                    }
340                    break;
341            }
342            $this->nodes[$i]['x'] = (int) $xoffset;
343            $this->nodes[$i]['y'] = (int) $yoffset;
344        }
345
346        // Find the minimum x & y offsets and deduct that number from
347        // each value in the array so that offsets start from zero
348        $min_xoffset = min(array_map(function (array $item): int {
349            return $item['x'];
350        }, $this->nodes));
351        $min_yoffset = min(array_map(function (array $item): int {
352            return $item['y'];
353        }, $this->nodes));
354
355        array_walk($this->nodes, function (&$item) use ($min_xoffset, $min_yoffset) {
356            $item['x'] -= $min_xoffset;
357            $item['y'] -= $min_yoffset;
358        });
359
360        // Calculate chart & canvas dimensions
361        $max_xoffset = max(array_map(function ($item) {
362            return $item['x'];
363        }, $this->nodes));
364        $max_yoffset = max(array_map(function ($item) {
365            return $item['y'];
366        }, $this->nodes));
367
368        $canvas_width   = $max_xoffset + $bxspacing + app()->make(ModuleThemeInterface::class)->parameter('chart-box-x') + $addoffset['x'];
369        $canvas_height  = $max_yoffset + $byspacing + app()->make(ModuleThemeInterface::class)->parameter('chart-box-y') + $addoffset['y'];
370        $posn           = I18N::direction() === 'rtl' ? 'right' : 'left';
371        $last_gen_start = (int) floor($this->treesize / 2);
372        if ($orientation === static::OLDEST_AT_TOP || $orientation === static::OLDEST_AT_BOTTOM) {
373            $flex_direction = ' flex-column';
374        } else {
375            $flex_direction = '';
376        }
377
378        foreach ($this->nodes as $n => $node) {
379            if ($n >= $last_gen_start) {
380                $this->nodes[$n]['previous_gen'] = $this->gotoPreviousGen($n, $generations, $orientation, $chart_has_ancestors);
381            } else {
382                $this->nodes[$n]['previous_gen'] = '';
383            }
384        }
385
386        $html = view('modules/pedigree-chart/chart', [
387            'canvas_height'    => $canvas_height,
388            'canvas_width'     => $canvas_width,
389            'child_menu'       => $this->getMenu($individual, $generations, $orientation),
390            'flex_direction'   => $flex_direction,
391            'last_gen_start'   => $last_gen_start,
392            'orientation'      => $orientation,
393            'nodes'            => $this->nodes,
394            'landscape'        => static::LANDSCAPE,
395            'oldest_at_top'    => static::OLDEST_AT_TOP,
396            'oldest_at_bottom' => static::OLDEST_AT_BOTTOM,
397            'portrait'         => static::PORTRAIT,
398            'posn'             => $posn,
399        ]);
400
401        return new Response($html);
402    }
403
404    /**
405     * Build a menu for the chart root individual
406     *
407     * @param Individual $root
408     * @param int        $generations
409     * @param int        $orientation
410     *
411     * @return string
412     */
413    public function getMenu(Individual $root, int $generations, int $orientation): string
414    {
415        $families = $root->getSpouseFamilies();
416        $html     = '';
417        if (!empty($families)) {
418            $html = sprintf('<div id="childarrow"><a href="#" class="menuselect %s"></a><div id="childbox-pedigree">', $this->arrows->menu);
419
420            foreach ($families as $family) {
421                $html   .= '<span class="name1">' . I18N::translate('Family') . '</span>';
422                $spouse = $family->getSpouse($root);
423                if ($spouse) {
424                    $html .= '<a class="name1" href="' . e($this->chartUrl($spouse, ['generations' => $generations, 'orientation' => $orientation])) . '">' . $spouse->getFullName() . '</a>';
425                }
426                $children = $family->getChildren();
427                foreach ($children as $sibling) {
428                    $html .= '<a class="name1" href="' . e($this->chartUrl($sibling, ['generations' => $generations, 'orientation' => $orientation])) . '">' . $sibling->getFullName() . '</a>';
429                }
430            }
431
432            foreach ($root->getChildFamilies() as $family) {
433                $siblings = array_filter($family->getChildren(), function (Individual $item) use ($root): bool {
434                    return $root->xref() !== $item->xref();
435                });
436                if (!empty($siblings)) {
437                    $html .= '<span class="name1">';
438                    $html .= count($siblings) > 1 ? I18N::translate('Siblings') : I18N::translate('Sibling');
439                    $html .= '</span>';
440                    foreach ($siblings as $sibling) {
441                        $html .= '<a class="name1" href="' . e($this->chartUrl($sibling, ['generations' => $generations, 'orientation' => $orientation])) . '">' . $sibling->getFullName() . '</a>';
442                    }
443                }
444            }
445            $html .= '</div></div>';
446        }
447
448        return $html;
449    }
450
451    /**
452     * Function gotoPreviousGen
453     * Create a link to generate a new chart based on the correct parent of the individual with this index
454     *
455     * @param int  $index
456     * @param int  $generations
457     * @param int  $orientation
458     * @param bool $chart_has_ancestors
459     *
460     * @return string
461     */
462    public function gotoPreviousGen(int $index, int $generations, int $orientation, bool $chart_has_ancestors): string
463    {
464        $html = '';
465        if ($chart_has_ancestors) {
466            if ($this->nodes[$index]['indi'] && $this->nodes[$index]['indi']->getChildFamilies()) {
467                $html         .= '<div class="ancestorarrow">';
468                $rootParentId = 1;
469                if ($index > (int) ($this->treesize / 2) + (int) ($this->treesize / 4)) {
470                    $rootParentId++;
471                }
472                $html .= '<a class="' . $this->arrows->prevGen . '" href="' . e($this->chartUrl($this->nodes[$rootParentId]['indi'], ['generations' => $generations, 'orientation' => $orientation])) . '"></a>';
473                $html .= '</div>';
474            } else {
475                $html .= '<div class="spacer"></div>';
476            }
477        }
478
479        return $html;
480    }
481
482    /**
483     * @return string[]
484     */
485    protected function generationOptions(): array
486    {
487        return FunctionsEdit::numericOptions(range(static::MIN_GENERATIONS, static::MAX_GENERATIONS));
488    }
489
490    /**
491     * @return string[]
492     */
493    protected function orientations(): array
494    {
495        return [
496            0 => I18N::translate('Portrait'),
497            1 => I18N::translate('Landscape'),
498            2 => I18N::translate('Oldest at top'),
499            3 => I18N::translate('Oldest at bottom'),
500        ];
501    }
502}
503