xref: /webtrees/app/Module/LifespansChartModule.php (revision 0c0910bf0f275a14f35d2ccdf698f91f79e269d4)
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 Aura\Router\RouterContainer;
22use Fig\Http\Message\RequestMethodInterface;
23use Fisharebest\ExtCalendar\GregorianCalendar;
24use Fisharebest\Webtrees\Auth;
25use Fisharebest\Webtrees\ColorGenerator;
26use Fisharebest\Webtrees\Date;
27use Fisharebest\Webtrees\I18N;
28use Fisharebest\Webtrees\Individual;
29use Fisharebest\Webtrees\Place;
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;
35use Psr\Http\Server\RequestHandlerInterface;
36use stdClass;
37
38/**
39 * Class LifespansChartModule
40 */
41class LifespansChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface
42{
43    use ModuleChartTrait;
44
45    private const ROUTE_NAME = 'lifespans-chart';
46    private const ROUTE_URL  = '/tree/{tree}/lifespans';
47
48    // Defaults
49    protected const DEFAULT_PARAMETERS = [];
50
51    // Parameters for generating colors
52    protected const RANGE      = 120; // degrees
53    protected const SATURATION = 100; // percent
54    protected const LIGHTNESS  = 30; // percent
55    protected const ALPHA      = 0.25;
56
57    /**
58     * Initialization.
59     *
60     * @param RouterContainer $router_container
61     */
62    public function boot(RouterContainer $router_container)
63    {
64        $router_container->getMap()
65            ->get(self::ROUTE_NAME, self::ROUTE_URL, self::class)
66            ->allows(RequestMethodInterface::METHOD_POST);
67    }
68
69    /**
70     * How should this module be identified in the control panel, etc.?
71     *
72     * @return string
73     */
74    public function title(): string
75    {
76        /* I18N: Name of a module/chart */
77        return I18N::translate('Lifespans');
78    }
79
80    /**
81     * A sentence describing what this module does.
82     *
83     * @return string
84     */
85    public function description(): string
86    {
87        /* I18N: Description of the “LifespansChart” module */
88        return I18N::translate('A chart of individuals’ lifespans.');
89    }
90
91    /**
92     * CSS class for the URL.
93     *
94     * @return string
95     */
96    public function chartMenuClass(): string
97    {
98        return 'menu-chart-lifespan';
99    }
100
101    /**
102     * The URL for this chart.
103     *
104     * @param Individual $individual
105     * @param string[]   $parameters
106     *
107     * @return string
108     */
109    public function chartUrl(Individual $individual, array $parameters = []): string
110    {
111        return route(self::ROUTE_NAME, [
112                'tree' => $individual->tree()->name(),
113            ] + $parameters + self::DEFAULT_PARAMETERS);
114    }
115
116    /**
117     * @param ServerRequestInterface $request
118     *
119     * @return ResponseInterface
120     */
121    public function handle(ServerRequestInterface $request): ResponseInterface
122    {
123        $tree      = $request->getAttribute('tree');
124        $user      = $request->getAttribute('user');
125        $xrefs     = $request->getQueryParams()['xrefs'] ?? [];
126        $ajax      = $request->getQueryParams()['ajax'] ?? '';
127        $addxref   = $request->getParsedBody()['addxref'] ?? '';
128        $addfam    = (bool) ($request->getParsedBody()['addfam'] ?? false);
129        $placename = $request->getParsedBody()['placename'] ?? '';
130        $start     = $request->getParsedBody()['start'] ?? '';
131        $end       = $request->getParsedBody()['end'] ?? '';
132
133        $place      = new Place($placename, $tree);
134        $start_date = new Date($start);
135        $end_date   = new Date($end);
136
137        $xrefs = array_unique($xrefs);
138
139        // Add an individual, and family members
140        $individual = Individual::getInstance($addxref, $tree);
141        if ($individual !== null) {
142            $xrefs[] = $addxref;
143            if ($addfam) {
144                $xrefs = array_merge($xrefs, $this->closeFamily($individual));
145            }
146        }
147
148        // Select by date and/or place.
149        if ($placename !== '' && $start_date->isOK() && $end_date->isOK()) {
150            $date_xrefs  = $this->findIndividualsByDate($start_date, $end_date, $tree);
151            $place_xrefs = $this->findIndividualsByPlace($place, $tree);
152            $xrefs       = array_intersect($date_xrefs, $place_xrefs);
153        } elseif ($start_date->isOK() && $end_date->isOK()) {
154            $xrefs = $this->findIndividualsByDate($start_date, $end_date, $tree);
155        } elseif ($placename !== '') {
156            $xrefs = $this->findIndividualsByPlace($place, $tree);
157        }
158
159        // Filter duplicates and private individuals.
160        $xrefs = array_unique($xrefs);
161        $xrefs = array_filter($xrefs, static function (string $xref) use ($tree): bool {
162            $individual = Individual::getInstance($xref, $tree);
163
164            return $individual !== null && $individual->canShow();
165        });
166
167        // Convert POST requests into GET requests for pretty URLs.
168        if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
169            return redirect(route(self::ROUTE_NAME, [
170                'tree'  => $request->getAttribute('tree')->name(),
171                'xrefs' => $xrefs,
172            ]));
173        }
174
175        Auth::checkComponentAccess($this, 'chart', $tree, $user);
176
177        if ($ajax === '1') {
178            $this->layout = 'layouts/ajax';
179
180            $subtitle = $this->subtitle(count($xrefs), $start_date, $end_date, $placename);
181
182            return $this->chart($tree, $xrefs, $subtitle);
183        }
184
185        $reset_url = route(self::ROUTE_NAME, ['tree' => $tree->name()]);
186
187        $ajax_url = route(self::ROUTE_NAME, [
188            'ajax'  => true,
189            'tree'  => $tree->name(),
190            'xrefs' => $xrefs,
191        ]);
192
193        return $this->viewResponse('modules/lifespans-chart/page', [
194            'ajax_url'  => $ajax_url,
195            'module'    => $this->name(),
196            'reset_url' => $reset_url,
197            'title'     => $this->title(),
198            'xrefs'     => $xrefs,
199        ]);
200    }
201
202    /**
203     * @param Tree   $tree
204     * @param array  $xrefs
205     * @param string $subtitle
206     *
207     * @return ResponseInterface
208     */
209    protected function chart(Tree $tree, array $xrefs, string $subtitle): ResponseInterface
210    {
211        /** @var Individual[] $individuals */
212        $individuals = array_map(static function (string $xref) use ($tree): ?Individual {
213            return Individual::getInstance($xref, $tree);
214        }, $xrefs);
215
216        $individuals = array_filter($individuals, static function (?Individual $individual): bool {
217            return $individual instanceof Individual && $individual->canShow();
218        });
219
220        // Sort the array in order of birth year
221        usort($individuals, Individual::birthDateComparator());
222
223        // Round to whole decades
224        $start_year = (int) floor($this->minYear($individuals) / 10) * 10;
225        $end_year   = (int) ceil($this->maxYear($individuals) / 10) * 10;
226
227        $lifespans = $this->layoutIndividuals($individuals);
228
229        $max_rows = array_reduce($lifespans, static function ($carry, stdClass $item) {
230            return max($carry, $item->row);
231        }, 0);
232
233        $html = view('modules/lifespans-chart/chart', [
234            'dir'        => I18N::direction(),
235            'end_year'   => $end_year,
236            'lifespans'  => $lifespans,
237            'max_rows'   => $max_rows,
238            'start_year' => $start_year,
239            'subtitle'   => $subtitle,
240        ]);
241
242        return response($html);
243    }
244
245    /**
246     * Find the latest event year for individuals
247     *
248     * @param array $individuals
249     *
250     * @return int
251     */
252    protected function maxYear(array $individuals): int
253    {
254        $jd = array_reduce($individuals, static function ($carry, Individual $item) {
255            return max($carry, $item->getEstimatedDeathDate()->maximumJulianDay());
256        }, 0);
257
258        $year = $this->jdToYear($jd);
259
260        // Don't show future dates
261        return min($year, (int) date('Y'));
262    }
263
264    /**
265     * Find the earliest event year for individuals
266     *
267     * @param array $individuals
268     *
269     * @return int
270     */
271    protected function minYear(array $individuals): int
272    {
273        $jd = array_reduce($individuals, static function ($carry, Individual $item) {
274            return min($carry, $item->getEstimatedBirthDate()->minimumJulianDay());
275        }, PHP_INT_MAX);
276
277        return $this->jdToYear($jd);
278    }
279
280    /**
281     * Convert a julian day to a gregorian year
282     *
283     * @param int $jd
284     *
285     * @return int
286     */
287    protected function jdToYear(int $jd): int
288    {
289        if ($jd === 0) {
290            return 0;
291        }
292
293        $gregorian = new GregorianCalendar();
294        [$y] = $gregorian->jdToYmd($jd);
295
296        return $y;
297    }
298
299    /**
300     * @param Date $start
301     * @param Date $end
302     * @param Tree $tree
303     *
304     * @return string[]
305     */
306    protected function findIndividualsByDate(Date $start, Date $end, Tree $tree): array
307    {
308        return DB::table('individuals')
309            ->join('dates', static function (JoinClause $join): void {
310                $join
311                    ->on('d_file', '=', 'i_file')
312                    ->on('d_gid', '=', 'i_id');
313            })
314            ->where('i_file', '=', $tree->id())
315            ->where('d_julianday1', '<=', $end->maximumJulianDay())
316            ->where('d_julianday2', '>=', $start->minimumJulianDay())
317            ->whereNotIn('d_fact', ['BAPL', 'ENDL', 'SLGC', 'SLGS', '_TODO', 'CHAN'])
318            ->pluck('i_id')
319            ->all();
320    }
321
322    /**
323     * @param Place $place
324     * @param Tree  $tree
325     *
326     * @return string[]
327     */
328    protected function findIndividualsByPlace(Place $place, Tree $tree): array
329    {
330        return DB::table('individuals')
331            ->join('placelinks', static function (JoinClause $join): void {
332                $join
333                    ->on('pl_file', '=', 'i_file')
334                    ->on('pl_gid', '=', 'i_id');
335            })
336            ->where('i_file', '=', $tree->id())
337            ->where('pl_p_id', '=', $place->id())
338            ->pluck('i_id')
339            ->all();
340    }
341
342    /**
343     * Find the close family members of an individual.
344     *
345     * @param Individual $individual
346     *
347     * @return string[]
348     */
349    protected function closeFamily(Individual $individual): array
350    {
351        $xrefs = [];
352
353        foreach ($individual->spouseFamilies() as $family) {
354            foreach ($family->children() as $child) {
355                $xrefs[] = $child->xref();
356            }
357
358            foreach ($family->spouses() as $spouse) {
359                $xrefs[] = $spouse->xref();
360            }
361        }
362
363        foreach ($individual->childFamilies() as $family) {
364            foreach ($family->children() as $child) {
365                $xrefs[] = $child->xref();
366            }
367
368            foreach ($family->spouses() as $spouse) {
369                $xrefs[] = $spouse->xref();
370            }
371        }
372
373        return $xrefs;
374    }
375
376    /**
377     * Generate a subtitle, based on filter parameters
378     *
379     * @param int    $count
380     * @param Date   $start
381     * @param Date   $end
382     * @param string $placename
383     *
384     * @return string
385     */
386    protected function subtitle(int $count, Date $start, Date $end, string $placename): string
387    {
388        if ($placename !== '' && $start->isOK() && $end->isOK()) {
389            return I18N::plural(
390                '%s individual with events in %s between %s and %s',
391                '%s individuals with events in %s between %s and %s',
392                $count,
393                I18N::number($count),
394                $placename,
395                $start->display(false, '%Y'),
396                $end->display(false, '%Y')
397            );
398        }
399
400        if ($placename !== '') {
401            return I18N::plural(
402                '%s individual with events in %s',
403                '%s individuals with events in %s',
404                $count,
405                I18N::number($count),
406                $placename
407            );
408        }
409
410        if ($start->isOK() && $end->isOK()) {
411            return I18N::plural(
412                '%s individual with events between %s and %s',
413                '%s individuals with events between %s and %s',
414                $count,
415                I18N::number($count),
416                $start->display(false, '%Y'),
417                $end->display(false, '%Y')
418            );
419        }
420
421        return I18N::plural('%s individual', '%s individuals', $count, I18N::number($count));
422    }
423
424    /**
425     * @param Individual[] $individuals
426     *
427     * @return stdClass[]
428     */
429    private function layoutIndividuals(array $individuals): array
430    {
431        $colors = [
432            'M' => new ColorGenerator(240, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE * -1),
433            'F' => new ColorGenerator(000, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE),
434            'U' => new ColorGenerator(120, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE),
435        ];
436
437        $current_year = (int) date('Y');
438
439        // Latest year used in each row
440        $rows = [];
441
442        $lifespans = [];
443
444        foreach ($individuals as $individual) {
445            $birth_jd   = $individual->getEstimatedBirthDate()->minimumJulianDay();
446            $birth_year = $this->jdToYear($birth_jd);
447            $death_jd   = $individual->getEstimatedDeathDate()->maximumJulianDay();
448            $death_year = $this->jdToYear($death_jd);
449
450            // Died before they were born?  Swapping the dates allows them to be shown.
451            if ($death_year < $birth_year) {
452                $death_year = $birth_year;
453            }
454
455            // Don't show death dates in the future.
456            $death_year = min($death_year, $current_year);
457
458            // Add this individual to the next row in the chart...
459            $next_row = count($rows);
460            // ...unless we can find an existing row where it fits.
461            foreach ($rows as $row => $year) {
462                if ($year < $birth_year) {
463                    $next_row = $row;
464                    break;
465                }
466            }
467
468            // Fill the row up to the year (leaving a small gap)
469            $rows[$next_row] = $death_year;
470
471            $lifespans[] = (object) [
472                'background' => $colors[$individual->sex()]->getNextColor(),
473                'birth_year' => $birth_year,
474                'death_year' => $death_year,
475                'id'         => 'individual-' . md5($individual->xref()),
476                'individual' => $individual,
477                'row'        => $next_row,
478            ];
479        }
480
481        return $lifespans;
482    }
483}
484