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