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