xref: /webtrees/app/Module/LifespansChartModule.php (revision cab242e7d7a773b0a6dab130048696e26fd6612c)
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 stdClass;
32use Symfony\Component\HttpFoundation\Request;
33use Symfony\Component\HttpFoundation\Response;
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 labelled on tabs, menus, 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 Request       $request
102     * @param Tree          $tree
103     * @param UserInterface $user
104     *
105     * @return Response
106     */
107    public function getChartAction(Request $request, Tree $tree, UserInterface $user): Response
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, 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 Response
188     */
189    protected function chart(Tree $tree, array $xrefs, string $subtitle): Response
190    {
191        /** @var Individual[] $individuals */
192        $individuals = array_map(function (string $xref) use ($tree) {
193            return Individual::getInstance($xref, $tree);
194        }, $xrefs);
195
196        $individuals = array_filter($individuals, 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, function (Individual $a, Individual $b) {
202            return Date::compare($a->getEstimatedBirthDate(), $b->getEstimatedBirthDate());
203        });
204
205        // Round to whole decades
206        $start_year = (int) floor($this->minYear($individuals) / 10) * 10;
207        $end_year   = (int) ceil($this->maxYear($individuals) / 10) * 10;
208
209        $lifespans = $this->layoutIndividuals($individuals);
210
211        $max_rows = array_reduce($lifespans, function ($carry, stdClass $item) {
212            return max($carry, $item->row);
213        }, 0);
214
215        $html = view('modules/lifespans-chart/chart', [
216            'dir'        => I18N::direction(),
217            'end_year'   => $end_year,
218            'lifespans'  => $lifespans,
219            'max_rows'   => $max_rows,
220            'start_year' => $start_year,
221            'subtitle'   => $subtitle,
222        ]);
223
224        return new Response($html);
225    }
226
227    /**
228     * @param Individual[] $individuals
229     *
230     * @return stdClass[]
231     */
232    private function layoutIndividuals(array $individuals): array
233    {
234        $colors = [
235            'M' => new ColorGenerator(240, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE * -1),
236            'F' => new ColorGenerator(000, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE),
237            'U' => new ColorGenerator(120, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE),
238        ];
239
240        $current_year = (int) date('Y');
241
242        // Latest year used in each row
243        $rows = [];
244
245        $lifespans = [];
246
247        foreach ($individuals as $individual) {
248            $birth_jd   = $individual->getEstimatedBirthDate()->minimumJulianDay();
249            $birth_year = $this->jdToYear($birth_jd);
250            $death_jd   = $individual->getEstimatedDeathDate()->maximumJulianDay();
251            $death_year = $this->jdToYear($death_jd);
252
253            // Don't show death dates in the future.
254            $death_year = min($death_year, $current_year);
255
256            // Add this individual to the next row in the chart...
257            $next_row = count($rows);
258            // ...unless we can find an existing row where it fits.
259            foreach ($rows as $row => $year) {
260                if ($year < $birth_year) {
261                    $next_row = $row;
262                    break;
263                }
264            }
265
266            // Fill the row up to the year (leaving a small gap)
267            $rows[$next_row] = $death_year;
268
269            $lifespans[] = (object) [
270                'background' => $colors[$individual->sex()]->getNextColor(),
271                'birth_year' => $birth_year,
272                'death_year' => $death_year,
273                'id'         => 'individual-' . md5($individual->xref()),
274                'individual' => $individual,
275                'row'        => $next_row,
276            ];
277        }
278
279        return $lifespans;
280    }
281
282    /**
283     * Find the latest event year for individuals
284     *
285     * @param array $individuals
286     *
287     * @return int
288     */
289    protected function maxYear(array $individuals): int
290    {
291        $jd = array_reduce($individuals, function ($carry, Individual $item) {
292            return max($carry, $item->getEstimatedDeathDate()->maximumJulianDay());
293        }, 0);
294
295        $year = $this->jdToYear($jd);
296
297        // Don't show future dates
298        return min($year, (int) date('Y'));
299    }
300
301    /**
302     * Find the earliest event year for individuals
303     *
304     * @param array $individuals
305     *
306     * @return int
307     */
308    protected function minYear(array $individuals): int
309    {
310        $jd = array_reduce($individuals, function ($carry, Individual $item) {
311            return min($carry, $item->getEstimatedBirthDate()->minimumJulianDay());
312        }, PHP_INT_MAX);
313
314        return $this->jdToYear($jd);
315    }
316
317    /**
318     * Convert a julian day to a gregorian year
319     *
320     * @param int $jd
321     *
322     * @return int
323     */
324    protected function jdToYear(int $jd): int
325    {
326        if ($jd === 0) {
327            return 0;
328        }
329
330        $gregorian = new GregorianCalendar();
331        [$y] = $gregorian->jdToYmd($jd);
332
333        return $y;
334    }
335
336    /**
337     * @param Date $start
338     * @param Date $end
339     * @param Tree $tree
340     *
341     * @return string[]
342     */
343    protected function findIndividualsByDate(Date $start, Date $end, Tree $tree): array
344    {
345        return DB::table('individuals')
346            ->join('dates', function (JoinClause $join): void {
347                $join
348                    ->on('d_file', '=', 'i_file')
349                    ->on('d_gid', '=', 'i_id');
350            })
351            ->where('i_file', '=', $tree->id())
352            ->where('d_julianday1', '<=', $end->maximumJulianDay())
353            ->where('d_julianday2', '>=', $start->minimumJulianDay())
354            ->whereNotIn('d_fact', ['BAPL', 'ENDL', 'SLGC', 'SLGS', '_TODO', 'CHAN'])
355            ->pluck('i_id')
356            ->all();
357    }
358
359    /**
360     * @param Place $place
361     * @param Tree  $tree
362     *
363     * @return string[]
364     */
365    protected function findIndividualsByPlace(Place $place, Tree $tree): array
366    {
367        return DB::table('individuals')
368            ->join('placelinks', function (JoinClause $join): void {
369                $join
370                    ->on('pl_file', '=', 'i_file')
371                    ->on('pl_gid', '=', 'i_id');
372            })
373            ->where('i_file', '=', $tree->id())
374            ->where('pl_p_id', '=', $place->id())
375            ->pluck('i_id')
376            ->all();
377    }
378
379    /**
380     * Find the close family members of an individual.
381     *
382     * @param Individual $individual
383     *
384     * @return string[]
385     */
386    protected function closeFamily(Individual $individual): array
387    {
388        $xrefs = [];
389
390        foreach ($individual->spouseFamilies() as $family) {
391            foreach ($family->children() as $child) {
392                $xrefs[] = $child->xref();
393            }
394
395            foreach ($family->spouses() as $spouse) {
396                $xrefs[] = $spouse->xref();
397            }
398        }
399
400        foreach ($individual->childFamilies() as $family) {
401            foreach ($family->children() as $child) {
402                $xrefs[] = $child->xref();
403            }
404
405            foreach ($family->spouses() as $spouse) {
406                $xrefs[] = $spouse->xref();
407            }
408        }
409
410        return $xrefs;
411    }
412
413    /**
414     * Generate a subtitle, based on filter parameters
415     *
416     * @param int    $count
417     * @param Date   $start
418     * @param Date   $end
419     * @param string $placename
420     *
421     * @return string
422     */
423    protected function subtitle(int $count, Date $start, Date $end, string $placename): string
424    {
425        if ($start->isOK() && $end->isOK() && $placename !== '') {
426            return I18N::plural(
427                '%s individual with events in %s between %s and %s',
428                '%s individuals with events in %s between %s and %s',
429                $count,
430                I18N::number($count),
431                $placename,
432                $start->display(false, '%Y'),
433                $end->display(false, '%Y')
434            );
435        }
436
437        if ($placename !== '') {
438            return I18N::plural(
439                '%s individual with events in %s',
440                '%s individuals with events in %s',
441                $count,
442                I18N::number($count),
443                $placename
444            );
445        }
446
447        if ($start->isOK() && $end->isOK()) {
448            return I18N::plural(
449                '%s individual with events between %s and %s',
450                '%s individuals with events between %s and %s',
451                $count,
452                I18N::number($count),
453                $start->display(false, '%Y'),
454                $end->display(false, '%Y')
455            );
456        }
457
458        return I18N::plural('%s individual', '%s individuals', $count, I18N::number($count));
459    }
460}
461