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