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