xref: /webtrees/app/Module/RelationshipsChartModule.php (revision f866a2aebf4ab9f4d9f30cfd2daa2ef2c18f8bea)
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\Algorithm\Dijkstra;
21use Fisharebest\Webtrees\Auth;
22use Fisharebest\Webtrees\Family;
23use Fisharebest\Webtrees\FlashMessages;
24use Fisharebest\Webtrees\FontAwesome;
25use Fisharebest\Webtrees\Functions\Functions;
26use Fisharebest\Webtrees\Functions\FunctionsPrint;
27use Fisharebest\Webtrees\I18N;
28use Fisharebest\Webtrees\Individual;
29use Fisharebest\Webtrees\Menu;
30use Fisharebest\Webtrees\Theme;
31use Fisharebest\Webtrees\Tree;
32use Illuminate\Database\Capsule\Manager as DB;
33use Illuminate\Database\Query\JoinClause;
34use Symfony\Component\HttpFoundation\RedirectResponse;
35use Symfony\Component\HttpFoundation\Request;
36use Symfony\Component\HttpFoundation\Response;
37
38/**
39 * Class RelationshipsChartModule
40 */
41class RelationshipsChartModule extends AbstractModule implements ModuleInterface, ModuleChartInterface, ModuleConfigInterface
42{
43    use ModuleChartTrait;
44    use ModuleConfigTrait;
45
46    /** It would be more correct to use PHP_INT_MAX, but this isn't friendly in URLs */
47    public const UNLIMITED_RECURSION = 99;
48
49    /** By default new trees allow unlimited recursion */
50    public const DEFAULT_RECURSION = '99';
51
52    /** By default new trees search for all relationships (not via ancestors) */
53    public const DEFAULT_ANCESTORS = '0';
54
55    /**
56     * How should this module be labelled on tabs, menus, etc.?
57     *
58     * @return string
59     */
60    public function title(): string
61    {
62        /* I18N: Name of a module/chart */
63        return I18N::translate('Relationships');
64    }
65
66    /**
67     * A sentence describing what this module does.
68     *
69     * @return string
70     */
71    public function description(): string
72    {
73        /* I18N: Description of the “RelationshipsChart” module */
74        return I18N::translate('A chart displaying relationships between two individuals.');
75    }
76
77    /**
78     * A main menu item for this chart.
79     *
80     * @param Individual $individual
81     *
82     * @return Menu
83     */
84    public function chartMenu(Individual $individual): Menu
85    {
86        $gedcomid = $individual->tree()->getUserPreference(Auth::user(), 'gedcomid');
87
88        if ($gedcomid !== '' && $gedcomid !== $individual->xref()) {
89            return new Menu(
90                I18N::translate('Relationship to me'),
91                $this->chartUrl($individual, ['xref2' => $gedcomid]),
92                $this->chartMenuClass(),
93                $this->chartUrlAttributes()
94            );
95        }
96
97        return new Menu(
98            $this->title(),
99            $this->chartUrl($individual),
100            $this->chartMenuClass(),
101            $this->chartUrlAttributes()
102        );
103    }
104
105    /**
106     * CSS class for the URL.
107     *
108     * @return string
109     */
110    public function chartMenuClass(): string
111    {
112        return 'menu-chart-relationship';
113    }
114
115    /**
116     * Return a menu item for this chart - for use in individual boxes.
117     *
118     * @param Individual $individual
119     *
120     * @return Menu|null
121     */
122    public function chartBoxMenu(Individual $individual): ?Menu
123    {
124        return $this->chartMenu($individual);
125    }
126
127    /**
128     * @return Response
129     */
130    public function getAdminAction(): Response
131    {
132        $this->layout = 'layouts/administration';
133
134        return $this->viewResponse('modules/relationships-chart/config', [
135            'all_trees'         => Tree::getAll(),
136            'ancestors_options' => $this->ancestorsOptions(),
137            'default_ancestors' => self::DEFAULT_ANCESTORS,
138            'default_recursion' => self::DEFAULT_RECURSION,
139            'recursion_options' => $this->recursionConfigOptions(),
140            'title'             => I18N::translate('Chart preferences') . ' — ' . $this->title(),
141        ]);
142    }
143
144    /**
145     * @param Request $request
146     *
147     * @return RedirectResponse
148     */
149    public function postAdminAction(Request $request): RedirectResponse
150    {
151        foreach (Tree::getAll() as $tree) {
152            $recursion = $request->get('relationship-recursion-' . $tree->id(), '');
153            $ancestors = $request->get('relationship-ancestors-' . $tree->id(), '');
154
155            $tree->setPreference('RELATIONSHIP_RECURSION', $recursion);
156            $tree->setPreference('RELATIONSHIP_ANCESTORS', $ancestors);
157        }
158
159        FlashMessages::addMessage(I18N::translate('The preferences for the module “%s” have been updated.', $this->title()), 'success');
160
161        return new RedirectResponse($this->getConfigLink());
162    }
163
164    /**
165     * Possible options for the ancestors option
166     *
167     * @return string[]
168     */
169    private function ancestorsOptions(): array
170    {
171        return [
172            0 => I18N::translate('Find any relationship'),
173            1 => I18N::translate('Find relationships via ancestors'),
174        ];
175    }
176
177    /**
178     * Possible options for the recursion option
179     *
180     * @return string[]
181     */
182    private function recursionConfigOptions(): array
183    {
184        return [
185            0                         => I18N::translate('none'),
186            1                         => I18N::number(1),
187            2                         => I18N::number(2),
188            3                         => I18N::number(3),
189            self::UNLIMITED_RECURSION => I18N::translate('unlimited'),
190        ];
191    }
192
193    /**
194     * A form to request the chart parameters.
195     *
196     * @param Request $request
197     * @param Tree    $tree
198     *
199     * @return Response
200     */
201    public function getChartAction(Request $request, Tree $tree): Response
202    {
203        $ajax = (bool) $request->get('ajax');
204
205        $xref  = $request->get('xref', '');
206        $xref2 = $request->get('xref2', '');
207
208        $individual1 = Individual::getInstance($xref, $tree);
209        $individual2 = Individual::getInstance($xref2, $tree);
210
211        $recursion = (int) $request->get('recursion', '0');
212        $ancestors = (int) $request->get('ancestors', '0');
213
214        $ancestors_only = (int) $tree->getPreference('RELATIONSHIP_ANCESTORS', static::DEFAULT_ANCESTORS);
215        $max_recursion  = (int) $tree->getPreference('RELATIONSHIP_RECURSION', static::DEFAULT_RECURSION);
216
217        $recursion = min($recursion, $max_recursion);
218
219        if ($individual1 instanceof Individual) {
220            Auth::checkIndividualAccess($individual1);
221        }
222
223        if ($individual2 instanceof Individual) {
224            Auth::checkIndividualAccess($individual2);
225        }
226
227        if ($individual1 instanceof Individual && $individual2 instanceof Individual) {
228            if ($ajax) {
229                return $this->chart($individual1, $individual2, $recursion, $ancestors);
230            }
231
232            /* I18N: %s are individual’s names */
233            $title = I18N::translate('Relationships between %1$s and %2$s', $individual1->getFullName(), $individual2->getFullName());
234
235            $ajax_url = $this->chartUrl($individual1, [
236                'ajax'      => true,
237                'xref2'     => $individual2->xref(),
238                'recursion' => $recursion,
239                'ancestors' => $ancestors,
240            ]);
241        } else {
242            $title = I18N::translate('Relationships');
243
244            $ajax_url = '';
245        }
246
247        return $this->viewResponse('modules/relationships-chart/page', [
248            'ajax_url'          => $ajax_url,
249            'ancestors'         => $ancestors,
250            'ancestors_only'    => $ancestors_only,
251            'ancestors_options' => $this->ancestorsOptions(),
252            'individual1'       => $individual1,
253            'individual2'       => $individual2,
254            'max_recursion'     => $max_recursion,
255            'module_name'       => $this->name(),
256            'recursion'         => $recursion,
257            'recursion_options' => $this->recursionOptions($max_recursion),
258            'title'             => $title,
259        ]);
260    }
261
262    /**
263     * @param Individual $individual1
264     * @param Individual $individual2
265     * @param int        $recursion
266     * @param int        $ancestors
267     *
268     * @return Response
269     */
270    public function chart(Individual $individual1, Individual $individual2, int $recursion, int $ancestors): Response
271    {
272        $tree = $individual1->tree();
273
274        $max_recursion = (int) $tree->getPreference('RELATIONSHIP_RECURSION', static::DEFAULT_RECURSION);
275
276        $recursion = min($recursion, $max_recursion);
277
278        $paths = $this->calculateRelationships($individual1, $individual2, $recursion, (bool) $ancestors);
279
280        // @TODO - convert to views
281        ob_start();
282        if (I18N::direction() === 'ltr') {
283            $diagonal1 = Theme::theme()->parameter('image-dline');
284            $diagonal2 = Theme::theme()->parameter('image-dline2');
285        } else {
286            $diagonal1 = Theme::theme()->parameter('image-dline2');
287            $diagonal2 = Theme::theme()->parameter('image-dline');
288        }
289
290        $num_paths = 0;
291        foreach ($paths as $path) {
292            // Extract the relationship names between pairs of individuals
293            $relationships = $this->oldStyleRelationshipPath($tree, $path);
294            if (empty($relationships)) {
295                // Cannot see one of the families/individuals, due to privacy;
296                continue;
297            }
298            echo '<h3>', I18N::translate('Relationship: %s', Functions::getRelationshipNameFromPath(implode('', $relationships), $individual1, $individual2)), '</h3>';
299            $num_paths++;
300
301            // Use a table/grid for layout.
302            $table = [];
303            // Current position in the grid.
304            $x = 0;
305            $y = 0;
306            // Extent of the grid.
307            $min_y = 0;
308            $max_y = 0;
309            $max_x = 0;
310            // For each node in the path.
311            foreach ($path as $n => $xref) {
312                if ($n % 2 === 1) {
313                    switch ($relationships[$n]) {
314                        case 'hus':
315                        case 'wif':
316                        case 'spo':
317                        case 'bro':
318                        case 'sis':
319                        case 'sib':
320                            $table[$x + 1][$y] = '<div style="background:url(' . Theme::theme()->parameter('image-hline') . ') repeat-x center;  width: 94px; text-align: center"><div class="hline-text" style="height: 32px;">' . Functions::getRelationshipNameFromPath($relationships[$n], Individual::getInstance($path[$n - 1], $tree), Individual::getInstance($path[$n + 1], $tree)) . '</div><div style="height: 32px;">' . FontAwesome::decorativeIcon('arrow-end') . '</div></div>';
321                            $x                 += 2;
322                            break;
323                        case 'son':
324                        case 'dau':
325                        case 'chi':
326                            if ($n > 2 && preg_match('/fat|mot|par/', $relationships[$n - 2])) {
327                                $table[$x + 1][$y - 1] = '<div style="background:url(' . $diagonal2 . '); width: 64px; height: 64px; text-align: center;"><div style="height: 32px; text-align: end;">' . Functions::getRelationshipNameFromPath($relationships[$n], Individual::getInstance($path[$n - 1], $tree), Individual::getInstance($path[$n + 1], $tree)) . '</div><div style="height: 32px; text-align: start;">' . FontAwesome::decorativeIcon('arrow-down') . '</div></div>';
328                                $x                     += 2;
329                            } else {
330                                $table[$x][$y - 1] = '<div style="background:url(' . Theme::theme()
331                                        ->parameter('image-vline') . ') repeat-y center; height: 64px; text-align: center;"><div class="vline-text" style="display: inline-block; width:50%; line-height: 64px;">' . Functions::getRelationshipNameFromPath($relationships[$n], Individual::getInstance($path[$n - 1], $tree), Individual::getInstance($path[$n + 1], $tree)) . '</div><div style="display: inline-block; width:50%; line-height: 64px;">' . FontAwesome::decorativeIcon('arrow-down') . '</div></div>';
332                            }
333                            $y -= 2;
334                            break;
335                        case 'fat':
336                        case 'mot':
337                        case 'par':
338                            if ($n > 2 && preg_match('/son|dau|chi/', $relationships[$n - 2])) {
339                                $table[$x + 1][$y + 1] = '<div style="background:url(' . $diagonal1 . '); background-position: top right; width: 64px; height: 64px; text-align: center;"><div style="height: 32px; text-align: start;">' . Functions::getRelationshipNameFromPath($relationships[$n], Individual::getInstance($path[$n - 1], $tree), Individual::getInstance($path[$n + 1], $tree)) . '</div><div style="height: 32px; text-align: end;">' . FontAwesome::decorativeIcon('arrow-down') . '</div></div>';
340                                $x                     += 2;
341                            } else {
342                                $table[$x][$y + 1] = '<div style="background:url(' . Theme::theme()
343                                        ->parameter('image-vline') . ') repeat-y center; height: 64px; text-align:center; "><div class="vline-text" style="display: inline-block; width: 50%; line-height: 32px;">' . Functions::getRelationshipNameFromPath($relationships[$n], Individual::getInstance($path[$n - 1], $tree), Individual::getInstance($path[$n + 1], $tree)) . '</div><div style="display: inline-block; width: 50%; line-height: 32px">' . FontAwesome::decorativeIcon('arrow-up') . '</div></div>';
344                            }
345                            $y += 2;
346                            break;
347                    }
348                    $max_x = max($max_x, $x);
349                    $min_y = min($min_y, $y);
350                    $max_y = max($max_y, $y);
351                } else {
352                    $individual    = Individual::getInstance($xref, $tree);
353                    $table[$x][$y] = FunctionsPrint::printPedigreePerson($individual);
354                }
355            }
356            echo '<div class="wt-chart wt-relationship-chart">';
357            echo '<table style="border-collapse: collapse; margin: 20px 50px;">';
358            for ($y = $max_y; $y >= $min_y; --$y) {
359                echo '<tr>';
360                for ($x = 0; $x <= $max_x; ++$x) {
361                    echo '<td style="padding: 0;">';
362                    if (isset($table[$x][$y])) {
363                        echo $table[$x][$y];
364                    }
365                    echo '</td>';
366                }
367                echo '</tr>';
368            }
369            echo '</table>';
370            echo '</div>';
371        }
372
373        if (!$num_paths) {
374            echo '<p>', I18N::translate('No link between the two individuals could be found.'), '</p>';
375        }
376
377        $html = ob_get_clean();
378
379        return new Response($html);
380    }
381
382    /**
383     * Calculate the shortest paths - or all paths - between two individuals.
384     *
385     * @param Individual $individual1
386     * @param Individual $individual2
387     * @param int        $recursion How many levels of recursion to use
388     * @param bool       $ancestor  Restrict to relationships via a common ancestor
389     *
390     * @return string[][]
391     */
392    private function calculateRelationships(Individual $individual1, Individual $individual2, $recursion, $ancestor = false): array
393    {
394        $tree = $individual1->tree();
395
396        $rows = DB::table('link')
397            ->where('l_file', '=', $tree->id())
398            ->whereIn('l_type', ['FAMS', 'FAMC'])
399            ->select(['l_from', 'l_to'])
400            ->get();
401
402        // Optionally restrict the graph to the ancestors of the individuals.
403        if ($ancestor) {
404            $ancestors = $this->allAncestors($individual1->xref(), $individual2->xref(), $tree->id());
405            $exclude   = $this->excludeFamilies($individual1->xref(), $individual2->xref(), $tree->id());
406        } else {
407            $ancestors = [];
408            $exclude   = [];
409        }
410
411        $graph = [];
412
413        foreach ($rows as $row) {
414            if (empty($ancestors) || in_array($row->l_from, $ancestors) && !in_array($row->l_to, $exclude)) {
415                $graph[$row->l_from][$row->l_to] = 1;
416                $graph[$row->l_to][$row->l_from] = 1;
417            }
418        }
419
420        $xref1    = $individual1->xref();
421        $xref2    = $individual2->xref();
422        $dijkstra = new Dijkstra($graph);
423        $paths    = $dijkstra->shortestPaths($xref1, $xref2);
424
425        // Only process each exclusion list once;
426        $excluded = [];
427
428        $queue = [];
429        foreach ($paths as $path) {
430            // Insert the paths into the queue, with an exclusion list.
431            $queue[] = [
432                'path'    => $path,
433                'exclude' => [],
434            ];
435            // While there are un-extended paths
436            for ($next = current($queue); $next !== false; $next = next($queue)) {
437                // For each family on the path
438                for ($n = count($next['path']) - 2; $n >= 1; $n -= 2) {
439                    $exclude = $next['exclude'];
440                    if (count($exclude) >= $recursion) {
441                        continue;
442                    }
443                    $exclude[] = $next['path'][$n];
444                    sort($exclude);
445                    $tmp = implode('-', $exclude);
446                    if (in_array($tmp, $excluded)) {
447                        continue;
448                    }
449
450                    $excluded[] = $tmp;
451                    // Add any new path to the queue
452                    foreach ($dijkstra->shortestPaths($xref1, $xref2, $exclude) as $new_path) {
453                        $queue[] = [
454                            'path'    => $new_path,
455                            'exclude' => $exclude,
456                        ];
457                    }
458                }
459            }
460        }
461        // Extract the paths from the queue, removing duplicates.
462        $paths = [];
463        foreach ($queue as $next) {
464            $paths[implode('-', $next['path'])] = $next['path'];
465        }
466
467        return $paths;
468    }
469
470    /**
471     * Convert a path (list of XREFs) to an "old-style" string of relationships.
472     * Return an empty array, if privacy rules prevent us viewing any node.
473     *
474     * @param Tree     $tree
475     * @param string[] $path Alternately Individual / Family
476     *
477     * @return string[]
478     */
479    private function oldStyleRelationshipPath(Tree $tree, array $path): array
480    {
481        $spouse_codes  = [
482            'M' => 'hus',
483            'F' => 'wif',
484            'U' => 'spo',
485        ];
486        $parent_codes  = [
487            'M' => 'fat',
488            'F' => 'mot',
489            'U' => 'par',
490        ];
491        $child_codes   = [
492            'M' => 'son',
493            'F' => 'dau',
494            'U' => 'chi',
495        ];
496        $sibling_codes = [
497            'M' => 'bro',
498            'F' => 'sis',
499            'U' => 'sib',
500        ];
501        $relationships = [];
502
503        for ($i = 1, $count = count($path); $i < $count; $i += 2) {
504            $family = Family::getInstance($path[$i], $tree);
505            $prev   = Individual::getInstance($path[$i - 1], $tree);
506            $next   = Individual::getInstance($path[$i + 1], $tree);
507            if (preg_match('/\n\d (HUSB|WIFE|CHIL) @' . $prev->xref() . '@/', $family->gedcom(), $match)) {
508                $rel1 = $match[1];
509            } else {
510                return [];
511            }
512            if (preg_match('/\n\d (HUSB|WIFE|CHIL) @' . $next->xref() . '@/', $family->gedcom(), $match)) {
513                $rel2 = $match[1];
514            } else {
515                return [];
516            }
517            if (($rel1 === 'HUSB' || $rel1 === 'WIFE') && ($rel2 === 'HUSB' || $rel2 === 'WIFE')) {
518                $relationships[$i] = $spouse_codes[$next->getSex()];
519            } elseif (($rel1 === 'HUSB' || $rel1 === 'WIFE') && $rel2 === 'CHIL') {
520                $relationships[$i] = $child_codes[$next->getSex()];
521            } elseif ($rel1 === 'CHIL' && ($rel2 === 'HUSB' || $rel2 === 'WIFE')) {
522                $relationships[$i] = $parent_codes[$next->getSex()];
523            } elseif ($rel1 === 'CHIL' && $rel2 === 'CHIL') {
524                $relationships[$i] = $sibling_codes[$next->getSex()];
525            }
526        }
527
528        return $relationships;
529    }
530
531    /**
532     * Find all ancestors of a list of individuals
533     *
534     * @param string $xref1
535     * @param string $xref2
536     * @param int    $tree_id
537     *
538     * @return string[]
539     */
540    private function allAncestors($xref1, $xref2, $tree_id): array
541    {
542        $ancestors = [
543            $xref1,
544            $xref2,
545        ];
546
547        $queue = [
548            $xref1,
549            $xref2,
550        ];
551        while (!empty($queue)) {
552            $parents = DB::table('link AS l1')
553                ->join('link AS l2', function (JoinClause $join): void {
554                    $join
555                        ->on('l1.l_to', '=', 'l2.l_to')
556                        ->on('l1.l_file', '=', 'l2.l_file');
557                })
558                ->where('l1.l_file', '=', $tree_id)
559                ->where('l1.l_type', '=', 'FAMC')
560                ->where('l2.l_type', '=', 'FAMS')
561                ->whereIn('l1.l_from', $queue)
562                ->pluck('l2.l_from');
563
564            $queue = [];
565            foreach ($parents as $parent) {
566                if (!in_array($parent, $ancestors)) {
567                    $ancestors[] = $parent;
568                    $queue[]     = $parent;
569                }
570            }
571        }
572
573        return $ancestors;
574    }
575
576    /**
577     * Find all families of two individuals
578     *
579     * @param string $xref1
580     * @param string $xref2
581     * @param int    $tree_id
582     *
583     * @return string[]
584     */
585    private function excludeFamilies($xref1, $xref2, $tree_id): array
586    {
587        return DB::table('link AS l1')
588            ->join('link AS l2', function (JoinClause $join): void {
589                $join
590                    ->on('l1.l_to', '=', 'l2.l_to')
591                    ->on('l1.l_type', '=', 'l2.l_type')
592                    ->on('l1.l_file', '=', 'l2.l_file');
593            })
594            ->where('l1.l_file', '=', $tree_id)
595            ->where('l1.l_type', '=', 'FAMS')
596            ->where('l1.l_from', '=', $xref1)
597            ->where('l2.l_from', '=', $xref2)
598            ->pluck('l1.l_to')
599            ->all();
600    }
601
602    /**
603     * Possible options for the recursion option
604     *
605     * @param int $max_recursion
606     *
607     * @return array
608     */
609    private function recursionOptions(int $max_recursion): array
610    {
611        if ($max_recursion === static::UNLIMITED_RECURSION) {
612            $text = I18N::translate('Find all possible relationships');
613        } else {
614            $text = I18N::translate('Find other relationships');
615        }
616
617        return [
618            '0'            => I18N::translate('Find the closest relationships'),
619            $max_recursion => $text,
620        ];
621    }
622}
623