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