xref: /webtrees/app/Module/LifespansChartModule.php (revision 48f08416725e4311af2f3c16f83029e6949af2ce)
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 Fisharebest\ExtCalendar\GregorianCalendar;
21use Fisharebest\Webtrees\Auth;
22use Fisharebest\Webtrees\ColorGenerator;
23use Fisharebest\Webtrees\Contracts\UserInterface;
24use Fisharebest\Webtrees\Date;
25use Fisharebest\Webtrees\I18N;
26use Fisharebest\Webtrees\Individual;
27use Fisharebest\Webtrees\Place;
28use Fisharebest\Webtrees\Tree;
29use Illuminate\Database\Capsule\Manager as DB;
30use Illuminate\Database\Query\JoinClause;
31use Psr\Http\Message\ResponseInterface;
32use Psr\Http\Message\ServerRequestInterface;
33use stdClass;
34
35/**
36 * Class LifespansChartModule
37 */
38class LifespansChartModule extends AbstractModule implements ModuleChartInterface
39{
40    use ModuleChartTrait;
41
42    // Parameters for generating colors
43    protected const RANGE      = 120; // degrees
44    protected const SATURATION = 100; // percent
45    protected const LIGHTNESS  = 30; // percent
46    protected const ALPHA      = 0.25;
47
48    /**
49     * How should this module be identified in the control panel, etc.?
50     *
51     * @return string
52     */
53    public function title(): string
54    {
55        /* I18N: Name of a module/chart */
56        return I18N::translate('Lifespans');
57    }
58
59    /**
60     * A sentence describing what this module does.
61     *
62     * @return string
63     */
64    public function description(): string
65    {
66        /* I18N: Description of the “LifespansChart” module */
67        return I18N::translate('A chart of individuals’ lifespans.');
68    }
69
70    /**
71     * CSS class for the URL.
72     *
73     * @return string
74     */
75    public function chartMenuClass(): string
76    {
77        return 'menu-chart-lifespan';
78    }
79
80    /**
81     * The URL for this chart.
82     *
83     * @param Individual $individual
84     * @param string[]   $parameters
85     *
86     * @return string
87     */
88    public function chartUrl(Individual $individual, array $parameters = []): string
89    {
90        return route('module', [
91                'module'  => $this->name(),
92                'action'  => 'Chart',
93                'xrefs[]' => $individual->xref(),
94                'ged'     => $individual->tree()->name(),
95            ] + $parameters);
96    }
97
98    /**
99     * A form to request the chart parameters.
100     *
101     * @param ServerRequestInterface $request
102     * @param Tree                   $tree
103     * @param UserInterface          $user
104     *
105     * @return ResponseInterface
106     */
107    public function getChartAction(ServerRequestInterface $request, Tree $tree, UserInterface $user): ResponseInterface
108    {
109        Auth::checkComponentAccess($this, 'chart', $tree, $user);
110
111        $ajax      = (bool) $request->get('ajax');
112        $xrefs     = (array) $request->get('xrefs', []);
113        $addxref   = $request->get('addxref', '');
114        $addfam    = (bool) $request->get('addfam', false);
115        $placename = $request->get('placename', '');
116        $start     = $request->get('start', '');
117        $end       = $request->get('end', '');
118
119        $place      = new Place($placename, $tree);
120        $start_date = new Date($start);
121        $end_date   = new Date($end);
122
123        $xrefs = array_unique($xrefs);
124
125        // Add an individual, and family members
126        $individual = Individual::getInstance($addxref, $tree);
127        if ($individual !== null) {
128            $xrefs[] = $addxref;
129            if ($addfam) {
130                $xrefs = array_merge($xrefs, $this->closeFamily($individual));
131            }
132        }
133
134        // Select by date and/or place.
135        if ($start_date->isOK() && $end_date->isOK() && $placename !== '') {
136            $date_xrefs  = $this->findIndividualsByDate($start_date, $end_date, $tree);
137            $place_xrefs = $this->findIndividualsByPlace($place, $tree);
138            $xrefs       = array_intersect($date_xrefs, $place_xrefs);
139        } elseif ($start_date->isOK() && $end_date->isOK()) {
140            $xrefs = $this->findIndividualsByDate($start_date, $end_date, $tree);
141        } elseif ($placename !== '') {
142            $xrefs = $this->findIndividualsByPlace($place, $tree);
143        }
144
145        // Filter duplicates and private individuals.
146        $xrefs = array_unique($xrefs);
147        $xrefs = array_filter($xrefs, static function (string $xref) use ($tree): bool {
148            $individual = Individual::getInstance($xref, $tree);
149
150            return $individual !== null && $individual->canShow();
151        });
152
153        if ($ajax) {
154            $subtitle = $this->subtitle(count($xrefs), $start_date, $end_date, $placename);
155
156            return $this->chart($tree, $xrefs, $subtitle);
157        }
158
159        $ajax_url = route('module', [
160            'ajax'   => true,
161            'module' => $this->name(),
162            'action' => 'Chart',
163            'ged'    => $tree->name(),
164            'xrefs'  => $xrefs,
165        ]);
166
167        $reset_url = route('module', [
168            'module' => $this->name(),
169            'action' => 'Chart',
170            'ged'    => $tree->name(),
171        ]);
172
173        return $this->viewResponse('modules/lifespans-chart/page', [
174            'ajax_url'    => $ajax_url,
175            'module_name' => $this->name(),
176            'reset_url'   => $reset_url,
177            'title'       => $this->title(),
178            'xrefs'       => $xrefs,
179        ]);
180    }
181
182    /**
183     * @param Tree   $tree
184     * @param array  $xrefs
185     * @param string $subtitle
186     *
187     * @return ResponseInterface
188     */
189    protected function chart(Tree $tree, array $xrefs, string $subtitle): ResponseInterface
190    {
191        /** @var Individual[] $individuals */
192        $individuals = array_map(static function (string $xref) use ($tree) {
193            return Individual::getInstance($xref, $tree);
194        }, $xrefs);
195
196        $individuals = array_filter($individuals, static function (Individual $individual = null): bool {
197            return $individual !== null && $individual->canShow();
198        });
199
200        // Sort the array in order of birth year
201        usort($individuals, Individual::birthDateComparator());
202
203        // Round to whole decades
204        $start_year = (int) floor($this->minYear($individuals) / 10) * 10;
205        $end_year   = (int) ceil($this->maxYear($individuals) / 10) * 10;
206
207        $lifespans = $this->layoutIndividuals($individuals);
208
209        $max_rows = array_reduce($lifespans, static function ($carry, stdClass $item) {
210            return max($carry, $item->row);
211        }, 0);
212
213        $html = view('modules/lifespans-chart/chart', [
214            'dir'        => I18N::direction(),
215            'end_year'   => $end_year,
216            'lifespans'  => $lifespans,
217            'max_rows'   => $max_rows,
218            'start_year' => $start_year,
219            'subtitle'   => $subtitle,
220        ]);
221
222        return response($html);
223    }
224
225    /**
226     * @param Individual[] $individuals
227     *
228     * @return stdClass[]
229     */
230    private function layoutIndividuals(array $individuals): array
231    {
232        $colors = [
233            'M' => new ColorGenerator(240, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE * -1),
234            'F' => new ColorGenerator(000, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE),
235            'U' => new ColorGenerator(120, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE),
236        ];
237
238        $current_year = (int) date('Y');
239
240        // Latest year used in each row
241        $rows = [];
242
243        $lifespans = [];
244
245        foreach ($individuals as $individual) {
246            $birth_jd   = $individual->getEstimatedBirthDate()->minimumJulianDay();
247            $birth_year = $this->jdToYear($birth_jd);
248            $death_jd   = $individual->getEstimatedDeathDate()->maximumJulianDay();
249            $death_year = $this->jdToYear($death_jd);
250
251            // Died before they were born?  Swapping the dates allows them to be shown.
252            if ($death_year < $birth_year) {
253                $death_year = $birth_year;
254            }
255
256            // Don't show death dates in the future.
257            $death_year = min($death_year, $current_year);
258
259            // Add this individual to the next row in the chart...
260            $next_row = count($rows);
261            // ...unless we can find an existing row where it fits.
262            foreach ($rows as $row => $year) {
263                if ($year < $birth_year) {
264                    $next_row = $row;
265                    break;
266                }
267            }
268
269            // Fill the row up to the year (leaving a small gap)
270            $rows[$next_row] = $death_year;
271
272            $lifespans[] = (object) [
273                'background' => $colors[$individual->sex()]->getNextColor(),
274                'birth_year' => $birth_year,
275                'death_year' => $death_year,
276                'id'         => 'individual-' . md5($individual->xref()),
277                'individual' => $individual,
278                'row'        => $next_row,
279            ];
280        }
281
282        return $lifespans;
283    }
284
285    /**
286     * Find the latest event year for individuals
287     *
288     * @param array $individuals
289     *
290     * @return int
291     */
292    protected function maxYear(array $individuals): int
293    {
294        $jd = array_reduce($individuals, static function ($carry, Individual $item) {
295            return max($carry, $item->getEstimatedDeathDate()->maximumJulianDay());
296        }, 0);
297
298        $year = $this->jdToYear($jd);
299
300        // Don't show future dates
301        return min($year, (int) date('Y'));
302    }
303
304    /**
305     * Find the earliest event year for individuals
306     *
307     * @param array $individuals
308     *
309     * @return int
310     */
311    protected function minYear(array $individuals): int
312    {
313        $jd = array_reduce($individuals, static function ($carry, Individual $item) {
314            return min($carry, $item->getEstimatedBirthDate()->minimumJulianDay());
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 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 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 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     * Generate a subtitle, based on filter parameters
418     *
419     * @param int    $count
420     * @param Date   $start
421     * @param Date   $end
422     * @param string $placename
423     *
424     * @return string
425     */
426    protected function subtitle(int $count, Date $start, Date $end, string $placename): string
427    {
428        if ($start->isOK() && $end->isOK() && $placename !== '') {
429            return I18N::plural(
430                '%s individual with events in %s between %s and %s',
431                '%s individuals with events in %s between %s and %s',
432                $count,
433                I18N::number($count),
434                $placename,
435                $start->display(false, '%Y'),
436                $end->display(false, '%Y')
437            );
438        }
439
440        if ($placename !== '') {
441            return I18N::plural(
442                '%s individual with events in %s',
443                '%s individuals with events in %s',
444                $count,
445                I18N::number($count),
446                $placename
447            );
448        }
449
450        if ($start->isOK() && $end->isOK()) {
451            return I18N::plural(
452                '%s individual with events between %s and %s',
453                '%s individuals with events between %s and %s',
454                $count,
455                I18N::number($count),
456                $start->display(false, '%Y'),
457                $end->display(false, '%Y')
458            );
459        }
460
461        return I18N::plural('%s individual', '%s individuals', $count, I18N::number($count));
462    }
463}
464