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