xref: /webtrees/app/Module/LifespansChartModule.php (revision 7413816e6dd2d50e569034fb804f3dce7471bb94)
1168ff6f3Sric2016<?php
23976b470SGreg Roach
3168ff6f3Sric2016/**
4168ff6f3Sric2016 * webtrees: online genealogy
5d11be702SGreg Roach * Copyright (C) 2023 webtrees development team
6168ff6f3Sric2016 * This program is free software: you can redistribute it and/or modify
7168ff6f3Sric2016 * it under the terms of the GNU General Public License as published by
8168ff6f3Sric2016 * the Free Software Foundation, either version 3 of the License, or
9168ff6f3Sric2016 * (at your option) any later version.
10168ff6f3Sric2016 * This program is distributed in the hope that it will be useful,
11168ff6f3Sric2016 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12168ff6f3Sric2016 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13168ff6f3Sric2016 * GNU General Public License for more details.
14168ff6f3Sric2016 * You should have received a copy of the GNU General Public License
1589f7189bSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>.
16168ff6f3Sric2016 */
17fcfa147eSGreg Roach
18e7f56f2aSGreg Roachdeclare(strict_types=1);
19e7f56f2aSGreg Roach
20168ff6f3Sric2016namespace Fisharebest\Webtrees\Module;
21168ff6f3Sric2016
2271378461SGreg Roachuse Fig\Http\Message\RequestMethodInterface;
23e2b8114dSGreg Roachuse Fisharebest\ExtCalendar\GregorianCalendar;
249867b2f0SGreg Roachuse Fisharebest\Webtrees\Auth;
25e2b8114dSGreg Roachuse Fisharebest\Webtrees\ColorGenerator;
26e2b8114dSGreg Roachuse Fisharebest\Webtrees\Date;
276f4ec3caSGreg Roachuse Fisharebest\Webtrees\DB;
2850955a14SGreg Roachuse Fisharebest\Webtrees\Http\Exceptions\HttpBadRequestException;
29168ff6f3Sric2016use Fisharebest\Webtrees\I18N;
30168ff6f3Sric2016use Fisharebest\Webtrees\Individual;
31e2b8114dSGreg Roachuse Fisharebest\Webtrees\Place;
32ac701fbdSGreg Roachuse Fisharebest\Webtrees\Registry;
33e2b8114dSGreg Roachuse Fisharebest\Webtrees\Tree;
34b55cbc6bSGreg Roachuse Fisharebest\Webtrees\Validator;
35e2b8114dSGreg Roachuse Illuminate\Database\Query\JoinClause;
366ccdf4f0SGreg Roachuse Psr\Http\Message\ResponseInterface;
376ccdf4f0SGreg Roachuse Psr\Http\Message\ServerRequestInterface;
3871378461SGreg Roachuse Psr\Http\Server\RequestHandlerInterface;
39168ff6f3Sric2016
407c2c99faSGreg Roachuse function array_filter;
417c2c99faSGreg Roachuse function array_intersect;
427c2c99faSGreg Roachuse function array_map;
437c2c99faSGreg Roachuse function array_merge;
447c2c99faSGreg Roachuse function array_reduce;
457c2c99faSGreg Roachuse function array_unique;
467c2c99faSGreg Roachuse function count;
477c2c99faSGreg Roachuse function date;
48da616f3dSGreg Roachuse function explode;
49da616f3dSGreg Roachuse function implode;
507c2c99faSGreg Roachuse function intdiv;
517c2c99faSGreg Roachuse function max;
527c2c99faSGreg Roachuse function md5;
537c2c99faSGreg Roachuse function min;
547c2c99faSGreg Roachuse function redirect;
557c2c99faSGreg Roachuse function response;
567c2c99faSGreg Roachuse function route;
577c2c99faSGreg Roachuse function usort;
587c2c99faSGreg Roachuse function view;
597c2c99faSGreg Roach
607c2c99faSGreg Roachuse const PHP_INT_MAX;
619e18e23bSGreg Roach
62168ff6f3Sric2016/**
63168ff6f3Sric2016 * Class LifespansChartModule
64168ff6f3Sric2016 */
6571378461SGreg Roachclass LifespansChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface
66c1010edaSGreg Roach{
6749a243cbSGreg Roach    use ModuleChartTrait;
6849a243cbSGreg Roach
6972f04adfSGreg Roach    protected const ROUTE_URL = '/tree/{tree}/lifespans';
7071378461SGreg Roach
71da616f3dSGreg Roach    // In theory, only "@" is a safe separator, but it gives longer and uglier URLs.
72da616f3dSGreg Roach    // Unless some other application generates XREFs with a ".", we are safe.
73da616f3dSGreg Roach    protected const SEPARATOR = '.';
74da616f3dSGreg Roach
7571378461SGreg Roach    // Defaults
7671378461SGreg Roach    protected const DEFAULT_PARAMETERS = [];
7771378461SGreg Roach
78e2b8114dSGreg Roach    // Parameters for generating colors
79e2b8114dSGreg Roach    protected const RANGE      = 120; // degrees
80e2b8114dSGreg Roach    protected const SATURATION = 100; // percent
81e2b8114dSGreg Roach    protected const LIGHTNESS  = 30; // percent
82e2b8114dSGreg Roach    protected const ALPHA      = 0.25;
83e2b8114dSGreg Roach
84168ff6f3Sric2016    /**
8571378461SGreg Roach     * Initialization.
8671378461SGreg Roach     *
879e18e23bSGreg Roach     * @return void
8871378461SGreg Roach     */
899e18e23bSGreg Roach    public function boot(): void
9071378461SGreg Roach    {
91158900c2SGreg Roach        Registry::routeFactory()->routeMap()
9272f04adfSGreg Roach            ->get(static::class, static::ROUTE_URL, $this)
9371378461SGreg Roach            ->allows(RequestMethodInterface::METHOD_POST);
9471378461SGreg Roach    }
9571378461SGreg Roach
9671378461SGreg Roach    /**
970cfd6963SGreg Roach     * How should this module be identified in the control panel, etc.?
98168ff6f3Sric2016     *
99168ff6f3Sric2016     * @return string
100168ff6f3Sric2016     */
10149a243cbSGreg Roach    public function title(): string
102c1010edaSGreg Roach    {
103bbb76c12SGreg Roach        /* I18N: Name of a module/chart */
104bbb76c12SGreg Roach        return I18N::translate('Lifespans');
105168ff6f3Sric2016    }
106168ff6f3Sric2016
10749a243cbSGreg Roach    public function description(): string
108c1010edaSGreg Roach    {
109bbb76c12SGreg Roach        /* I18N: Description of the “LifespansChart” module */
110bbb76c12SGreg Roach        return I18N::translate('A chart of individuals’ lifespans.');
111168ff6f3Sric2016    }
112168ff6f3Sric2016
113168ff6f3Sric2016    /**
114377a2979SGreg Roach     * CSS class for the URL.
115377a2979SGreg Roach     *
116377a2979SGreg Roach     * @return string
117377a2979SGreg Roach     */
118377a2979SGreg Roach    public function chartMenuClass(): string
119377a2979SGreg Roach    {
120377a2979SGreg Roach        return 'menu-chart-lifespan';
121377a2979SGreg Roach    }
122377a2979SGreg Roach
123377a2979SGreg Roach    /**
124e6562982SGreg Roach     * The URL for this chart.
125168ff6f3Sric2016     *
12660bc3e3fSGreg Roach     * @param Individual                                $individual
12776d39c55SGreg Roach     * @param array<bool|int|string|array<string>|null> $parameters
12860bc3e3fSGreg Roach     *
129e6562982SGreg Roach     * @return string
130168ff6f3Sric2016     */
131e6562982SGreg Roach    public function chartUrl(Individual $individual, array $parameters = []): string
132c1010edaSGreg Roach    {
13372f04adfSGreg Roach        return route(static::class, [
13471378461SGreg Roach                'tree'  => $individual->tree()->name(),
135da616f3dSGreg Roach                'xrefs' => $individual->xref(),
13671378461SGreg Roach            ] + $parameters + self::DEFAULT_PARAMETERS);
137168ff6f3Sric2016    }
138e2b8114dSGreg Roach
139e2b8114dSGreg Roach    /**
1406ccdf4f0SGreg Roach     * @param ServerRequestInterface $request
141e2b8114dSGreg Roach     *
1426ccdf4f0SGreg Roach     * @return ResponseInterface
143e2b8114dSGreg Roach     */
14471378461SGreg Roach    public function handle(ServerRequestInterface $request): ResponseInterface
145e2b8114dSGreg Roach    {
146b55cbc6bSGreg Roach        $tree  = Validator::attributes($request)->tree();
147b55cbc6bSGreg Roach        $user  = Validator::attributes($request)->user();
148158900c2SGreg Roach        $xrefs = Validator::queryParams($request)->string('xrefs', '');
149b55cbc6bSGreg Roach        $ajax  = Validator::queryParams($request)->boolean('ajax', false);
150da616f3dSGreg Roach
151158900c2SGreg Roach        if ($xrefs === '') {
15250955a14SGreg Roach            try {
15350955a14SGreg Roach                // URLs created by webtrees 2.0 and earlier used an array.
154158900c2SGreg Roach                $xrefs = Validator::queryParams($request)->array('xrefs');
15550955a14SGreg Roach            } catch (HttpBadRequestException) {
15650955a14SGreg Roach                // Not a 2.0 request, just an empty parameter.
15750955a14SGreg Roach                $xrefs = [];
15850955a14SGreg Roach            }
159158900c2SGreg Roach        } else {
160158900c2SGreg Roach            $xrefs = explode(self::SEPARATOR, $xrefs);
161158900c2SGreg Roach        }
162b46c87bdSGreg Roach
163158900c2SGreg Roach        $addxref   = Validator::parsedBody($request)->string('addxref', '');
164158900c2SGreg Roach        $addfam    = Validator::parsedBody($request)->boolean('addfam', false);
165158900c2SGreg Roach        $place_id  = Validator::parsedBody($request)->integer('place_id', 0);
166158900c2SGreg Roach        $start     = Validator::parsedBody($request)->string('start', '');
167158900c2SGreg Roach        $end       = Validator::parsedBody($request)->string('end', '');
168e2b8114dSGreg Roach
169a60f9d1cSGreg Roach        $place      = Place::find($place_id, $tree);
170e2b8114dSGreg Roach        $start_date = new Date($start);
171e2b8114dSGreg Roach        $end_date   = new Date($end);
172e2b8114dSGreg Roach
173e2b8114dSGreg Roach        $xrefs = array_unique($xrefs);
174e2b8114dSGreg Roach
175e2b8114dSGreg Roach        // Add an individual, and family members
1766b9cb339SGreg Roach        $individual = Registry::individualFactory()->make($addxref, $tree);
177e2b8114dSGreg Roach        if ($individual !== null) {
178e2b8114dSGreg Roach            $xrefs[] = $addxref;
179e2b8114dSGreg Roach            if ($addfam) {
180e2b8114dSGreg Roach                $xrefs = array_merge($xrefs, $this->closeFamily($individual));
181e2b8114dSGreg Roach            }
182e2b8114dSGreg Roach        }
183e2b8114dSGreg Roach
184e2b8114dSGreg Roach        // Select by date and/or place.
185a60f9d1cSGreg Roach        if ($place_id !== 0 && $start_date->isOK() && $end_date->isOK()) {
186e2b8114dSGreg Roach            $date_xrefs  = $this->findIndividualsByDate($start_date, $end_date, $tree);
187e2b8114dSGreg Roach            $place_xrefs = $this->findIndividualsByPlace($place, $tree);
188e2b8114dSGreg Roach            $xrefs       = array_intersect($date_xrefs, $place_xrefs);
189e2b8114dSGreg Roach        } elseif ($start_date->isOK() && $end_date->isOK()) {
190e2b8114dSGreg Roach            $xrefs = $this->findIndividualsByDate($start_date, $end_date, $tree);
191a60f9d1cSGreg Roach        } elseif ($place_id !== 0) {
192e2b8114dSGreg Roach            $xrefs = $this->findIndividualsByPlace($place, $tree);
193e2b8114dSGreg Roach        }
194e2b8114dSGreg Roach
195e2b8114dSGreg Roach        // Filter duplicates and private individuals.
196e2b8114dSGreg Roach        $xrefs = array_unique($xrefs);
1970b5fd0a6SGreg Roach        $xrefs = array_filter($xrefs, static function (string $xref) use ($tree): bool {
1986b9cb339SGreg Roach            $individual = Registry::individualFactory()->make($xref, $tree);
199e2b8114dSGreg Roach
200e2b8114dSGreg Roach            return $individual !== null && $individual->canShow();
201e2b8114dSGreg Roach        });
202e2b8114dSGreg Roach
20371378461SGreg Roach        // Convert POST requests into GET requests for pretty URLs.
20471378461SGreg Roach        if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
20572f04adfSGreg Roach            return redirect(route(static::class, [
2064ea62551SGreg Roach                'tree'  => $tree->name(),
207da616f3dSGreg Roach                'xrefs' => implode(self::SEPARATOR, $xrefs),
20871378461SGreg Roach            ]));
20971378461SGreg Roach        }
21071378461SGreg Roach
211ef483801SGreg Roach        Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user);
21271378461SGreg Roach
213b55cbc6bSGreg Roach        if ($ajax) {
21471378461SGreg Roach            $this->layout = 'layouts/ajax';
21571378461SGreg Roach
216a60f9d1cSGreg Roach            return $this->chart($tree, $xrefs);
217e2b8114dSGreg Roach        }
218e2b8114dSGreg Roach
21972f04adfSGreg Roach        $reset_url = route(static::class, ['tree' => $tree->name()]);
220e2b8114dSGreg Roach
22172f04adfSGreg Roach        $ajax_url = route(static::class, [
22271378461SGreg Roach            'ajax'  => true,
22371378461SGreg Roach            'tree'  => $tree->name(),
224da616f3dSGreg Roach            'xrefs' => implode(self::SEPARATOR, $xrefs),
225e2b8114dSGreg Roach        ]);
226e2b8114dSGreg Roach
2279b5537c3SGreg Roach        return $this->viewResponse('modules/lifespans-chart/page', [
228e2b8114dSGreg Roach            'ajax_url'  => $ajax_url,
22971378461SGreg Roach            'module'    => $this->name(),
230e2b8114dSGreg Roach            'reset_url' => $reset_url,
231e2b8114dSGreg Roach            'title'     => $this->title(),
232ef5d23f1SGreg Roach            'tree'      => $tree,
233e2b8114dSGreg Roach            'xrefs'     => $xrefs,
234e2b8114dSGreg Roach        ]);
235e2b8114dSGreg Roach    }
236e2b8114dSGreg Roach
237e2b8114dSGreg Roach    /**
238e2b8114dSGreg Roach     * @param Tree          $tree
239ac701fbdSGreg Roach     * @param array<string> $xrefs
240e2b8114dSGreg Roach     *
2416ccdf4f0SGreg Roach     * @return ResponseInterface
242e2b8114dSGreg Roach     */
243a60f9d1cSGreg Roach    protected function chart(Tree $tree, array $xrefs): ResponseInterface
244e2b8114dSGreg Roach    {
245e2b8114dSGreg Roach        /** @var Individual[] $individuals */
246*1ff45046SGreg Roach        $individuals = array_map(static fn (string $xref): Individual|null => Registry::individualFactory()->make($xref, $tree), $xrefs);
247e2b8114dSGreg Roach
248*1ff45046SGreg Roach        $individuals = array_filter($individuals, static fn (Individual|null $individual): bool => $individual instanceof Individual && $individual->canShow());
249e2b8114dSGreg Roach
250e2b8114dSGreg Roach        // Sort the array in order of birth year
2510c1a5edcSGreg Roach        usort($individuals, Individual::birthDateComparator());
252e2b8114dSGreg Roach
253e2b8114dSGreg Roach        // Round to whole decades
2547c2c99faSGreg Roach        $start_year = intdiv($this->minYear($individuals), 10) * 10;
255e4e7c547SGreg Roach        $end_year   = intdiv($this->maxYear($individuals) + 9, 10) * 10;
256e2b8114dSGreg Roach
257e2b8114dSGreg Roach        $lifespans = $this->layoutIndividuals($individuals);
258e2b8114dSGreg Roach
259f70bcff5SGreg Roach        $callback = static fn (int $carry, object $item): int => max($carry, $item->row);
2604c78e066SGreg Roach        $max_rows = array_reduce($lifespans, $callback, 0);
261e2b8114dSGreg Roach
262a60f9d1cSGreg Roach        $count    = count($xrefs);
263a60f9d1cSGreg Roach        $subtitle = I18N::plural('%s individual', '%s individuals', $count, I18N::number($count));
264a60f9d1cSGreg Roach
265e2b8114dSGreg Roach        $html = view('modules/lifespans-chart/chart', [
266e2b8114dSGreg Roach            'dir'        => I18N::direction(),
267e2b8114dSGreg Roach            'end_year'   => $end_year,
268e2b8114dSGreg Roach            'lifespans'  => $lifespans,
269e2b8114dSGreg Roach            'max_rows'   => $max_rows,
270e2b8114dSGreg Roach            'start_year' => $start_year,
271e2b8114dSGreg Roach            'subtitle'   => $subtitle,
272e2b8114dSGreg Roach        ]);
273e2b8114dSGreg Roach
2746ccdf4f0SGreg Roach        return response($html);
275e2b8114dSGreg Roach    }
276e2b8114dSGreg Roach
277e2b8114dSGreg Roach    /**
278e2b8114dSGreg Roach     * Find the latest event year for individuals
279e2b8114dSGreg Roach     *
280ac701fbdSGreg Roach     * @param array<Individual> $individuals
281e2b8114dSGreg Roach     *
282e2b8114dSGreg Roach     * @return int
283e2b8114dSGreg Roach     */
284e2b8114dSGreg Roach    protected function maxYear(array $individuals): int
285e2b8114dSGreg Roach    {
2864c78e066SGreg Roach        $jd = array_reduce($individuals, static function (int $carry, Individual $item): int {
287c09a4a40SGreg Roach            if ($item->getEstimatedDeathDate()->isOK()) {
288c09a4a40SGreg Roach                return max($carry, $item->getEstimatedDeathDate()->maximumJulianDay());
289c09a4a40SGreg Roach            }
290c09a4a40SGreg Roach
291c09a4a40SGreg Roach            return $carry;
292e2b8114dSGreg Roach        }, 0);
293e2b8114dSGreg Roach
294e2b8114dSGreg Roach        $year = $this->jdToYear($jd);
295e2b8114dSGreg Roach
296e2b8114dSGreg Roach        // Don't show future dates
297e2b8114dSGreg Roach        return min($year, (int) date('Y'));
298e2b8114dSGreg Roach    }
299e2b8114dSGreg Roach
300e2b8114dSGreg Roach    /**
301e2b8114dSGreg Roach     * Find the earliest event year for individuals
302e2b8114dSGreg Roach     *
303ac701fbdSGreg Roach     * @param array<Individual> $individuals
304e2b8114dSGreg Roach     *
305e2b8114dSGreg Roach     * @return int
306e2b8114dSGreg Roach     */
307e2b8114dSGreg Roach    protected function minYear(array $individuals): int
308e2b8114dSGreg Roach    {
3094c78e066SGreg Roach        $jd = array_reduce($individuals, static function (int $carry, Individual $item): int {
310c09a4a40SGreg Roach            if ($item->getEstimatedBirthDate()->isOK()) {
311c09a4a40SGreg Roach                return min($carry, $item->getEstimatedBirthDate()->minimumJulianDay());
312c09a4a40SGreg Roach            }
313c09a4a40SGreg Roach
314c09a4a40SGreg Roach            return $carry;
315e2b8114dSGreg Roach        }, PHP_INT_MAX);
316e2b8114dSGreg Roach
317e2b8114dSGreg Roach        return $this->jdToYear($jd);
318e2b8114dSGreg Roach    }
319e2b8114dSGreg Roach
320e2b8114dSGreg Roach    /**
321e2b8114dSGreg Roach     * Convert a julian day to a gregorian year
322e2b8114dSGreg Roach     *
323e2b8114dSGreg Roach     * @param int $jd
324e2b8114dSGreg Roach     *
325e2b8114dSGreg Roach     * @return int
326e2b8114dSGreg Roach     */
327e2b8114dSGreg Roach    protected function jdToYear(int $jd): int
328e2b8114dSGreg Roach    {
329e2b8114dSGreg Roach        if ($jd === 0) {
330e2b8114dSGreg Roach            return 0;
331e2b8114dSGreg Roach        }
332e2b8114dSGreg Roach
333e2b8114dSGreg Roach        $gregorian = new GregorianCalendar();
334e2b8114dSGreg Roach        [$y] = $gregorian->jdToYmd($jd);
335e2b8114dSGreg Roach
336e2b8114dSGreg Roach        return $y;
337e2b8114dSGreg Roach    }
338e2b8114dSGreg Roach
339e2b8114dSGreg Roach    /**
340e2b8114dSGreg Roach     * @param Date $start
341e2b8114dSGreg Roach     * @param Date $end
342e2b8114dSGreg Roach     * @param Tree $tree
343e2b8114dSGreg Roach     *
34424f2a3afSGreg Roach     * @return array<string>
345e2b8114dSGreg Roach     */
346e2b8114dSGreg Roach    protected function findIndividualsByDate(Date $start, Date $end, Tree $tree): array
347e2b8114dSGreg Roach    {
348e2b8114dSGreg Roach        return DB::table('individuals')
3490b5fd0a6SGreg Roach            ->join('dates', static function (JoinClause $join): void {
350e2b8114dSGreg Roach                $join
351e2b8114dSGreg Roach                    ->on('d_file', '=', 'i_file')
352e2b8114dSGreg Roach                    ->on('d_gid', '=', 'i_id');
353e2b8114dSGreg Roach            })
354e2b8114dSGreg Roach            ->where('i_file', '=', $tree->id())
355e2b8114dSGreg Roach            ->where('d_julianday1', '<=', $end->maximumJulianDay())
356e2b8114dSGreg Roach            ->where('d_julianday2', '>=', $start->minimumJulianDay())
357e2b8114dSGreg Roach            ->whereNotIn('d_fact', ['BAPL', 'ENDL', 'SLGC', 'SLGS', '_TODO', 'CHAN'])
358e2b8114dSGreg Roach            ->pluck('i_id')
359e2b8114dSGreg Roach            ->all();
360e2b8114dSGreg Roach    }
361e2b8114dSGreg Roach
362e2b8114dSGreg Roach    /**
363e2b8114dSGreg Roach     * @param Place $place
364e2b8114dSGreg Roach     * @param Tree  $tree
365e2b8114dSGreg Roach     *
36624f2a3afSGreg Roach     * @return array<string>
367e2b8114dSGreg Roach     */
368e2b8114dSGreg Roach    protected function findIndividualsByPlace(Place $place, Tree $tree): array
369e2b8114dSGreg Roach    {
370e2b8114dSGreg Roach        return DB::table('individuals')
3710b5fd0a6SGreg Roach            ->join('placelinks', static function (JoinClause $join): void {
372e2b8114dSGreg Roach                $join
373e2b8114dSGreg Roach                    ->on('pl_file', '=', 'i_file')
374e2b8114dSGreg Roach                    ->on('pl_gid', '=', 'i_id');
375e2b8114dSGreg Roach            })
376e2b8114dSGreg Roach            ->where('i_file', '=', $tree->id())
377392561bbSGreg Roach            ->where('pl_p_id', '=', $place->id())
378e2b8114dSGreg Roach            ->pluck('i_id')
379e2b8114dSGreg Roach            ->all();
380e2b8114dSGreg Roach    }
381e2b8114dSGreg Roach
382e2b8114dSGreg Roach    /**
383e2b8114dSGreg Roach     * Find the close family members of an individual.
384e2b8114dSGreg Roach     *
385e2b8114dSGreg Roach     * @param Individual $individual
386e2b8114dSGreg Roach     *
38724f2a3afSGreg Roach     * @return array<string>
388e2b8114dSGreg Roach     */
389e2b8114dSGreg Roach    protected function closeFamily(Individual $individual): array
390e2b8114dSGreg Roach    {
391e2b8114dSGreg Roach        $xrefs = [];
392e2b8114dSGreg Roach
39339ca88baSGreg Roach        foreach ($individual->spouseFamilies() as $family) {
39439ca88baSGreg Roach            foreach ($family->children() as $child) {
395e2b8114dSGreg Roach                $xrefs[] = $child->xref();
396e2b8114dSGreg Roach            }
397e2b8114dSGreg Roach
39839ca88baSGreg Roach            foreach ($family->spouses() as $spouse) {
399e2b8114dSGreg Roach                $xrefs[] = $spouse->xref();
400e2b8114dSGreg Roach            }
401e2b8114dSGreg Roach        }
402e2b8114dSGreg Roach
40339ca88baSGreg Roach        foreach ($individual->childFamilies() as $family) {
40439ca88baSGreg Roach            foreach ($family->children() as $child) {
405e2b8114dSGreg Roach                $xrefs[] = $child->xref();
406e2b8114dSGreg Roach            }
407e2b8114dSGreg Roach
40839ca88baSGreg Roach            foreach ($family->spouses() as $spouse) {
409e2b8114dSGreg Roach                $xrefs[] = $spouse->xref();
410e2b8114dSGreg Roach            }
411e2b8114dSGreg Roach        }
412e2b8114dSGreg Roach
413e2b8114dSGreg Roach        return $xrefs;
414e2b8114dSGreg Roach    }
415e2b8114dSGreg Roach
416e2b8114dSGreg Roach    /**
41709482a55SGreg Roach     * @param array<Individual> $individuals
41871378461SGreg Roach     *
419f70bcff5SGreg Roach     * @return array<object>
42071378461SGreg Roach     */
42171378461SGreg Roach    private function layoutIndividuals(array $individuals): array
42271378461SGreg Roach    {
42323a98013SGreg Roach        $color_generators = [
42471378461SGreg Roach            'M' => new ColorGenerator(240, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE * -1),
42571378461SGreg Roach            'F' => new ColorGenerator(000, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE),
42671378461SGreg Roach            'U' => new ColorGenerator(120, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE),
42771378461SGreg Roach        ];
42871378461SGreg Roach
42971378461SGreg Roach        $current_year = (int) date('Y');
43071378461SGreg Roach
43171378461SGreg Roach        // Latest year used in each row
43271378461SGreg Roach        $rows = [];
43371378461SGreg Roach
43471378461SGreg Roach        $lifespans = [];
43571378461SGreg Roach
43671378461SGreg Roach        foreach ($individuals as $individual) {
43771378461SGreg Roach            $birth_jd   = $individual->getEstimatedBirthDate()->minimumJulianDay();
43871378461SGreg Roach            $birth_year = $this->jdToYear($birth_jd);
43971378461SGreg Roach            $death_jd   = $individual->getEstimatedDeathDate()->maximumJulianDay();
44071378461SGreg Roach            $death_year = $this->jdToYear($death_jd);
44171378461SGreg Roach
44271378461SGreg Roach            // Died before they were born?  Swapping the dates allows them to be shown.
44371378461SGreg Roach            if ($death_year < $birth_year) {
44471378461SGreg Roach                $death_year = $birth_year;
44571378461SGreg Roach            }
44671378461SGreg Roach
44771378461SGreg Roach            // Don't show death dates in the future.
44871378461SGreg Roach            $death_year = min($death_year, $current_year);
44971378461SGreg Roach
45071378461SGreg Roach            // Add this individual to the next row in the chart...
45171378461SGreg Roach            $next_row = count($rows);
45271378461SGreg Roach            // ...unless we can find an existing row where it fits.
45371378461SGreg Roach            foreach ($rows as $row => $year) {
45471378461SGreg Roach                if ($year < $birth_year) {
45571378461SGreg Roach                    $next_row = $row;
45671378461SGreg Roach                    break;
45771378461SGreg Roach                }
45871378461SGreg Roach            }
45971378461SGreg Roach
46071378461SGreg Roach            // Fill the row up to the year (leaving a small gap)
46171378461SGreg Roach            $rows[$next_row] = $death_year;
46271378461SGreg Roach
46323a98013SGreg Roach            $color_generator = $color_generators[$individual->sex()] ?? $color_generators['U'];
46423a98013SGreg Roach
46571378461SGreg Roach            $lifespans[] = (object) [
46623a98013SGreg Roach                'background' => $color_generator->getNextColor(),
46771378461SGreg Roach                'birth_year' => $birth_year,
46871378461SGreg Roach                'death_year' => $death_year,
46971378461SGreg Roach                'id'         => 'individual-' . md5($individual->xref()),
47071378461SGreg Roach                'individual' => $individual,
47171378461SGreg Roach                'row'        => $next_row,
47271378461SGreg Roach            ];
47371378461SGreg Roach        }
47471378461SGreg Roach
47571378461SGreg Roach        return $lifespans;
47671378461SGreg Roach    }
477168ff6f3Sric2016}
478