xref: /webtrees/app/Module/StatisticsChartModule.php (revision 52550490b7095dd69811f3ec21ed5a3ca1a8968d)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2023 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Module;
21
22use Fisharebest\Webtrees\Auth;
23use Fisharebest\Webtrees\Http\Exceptions\HttpNotFoundException;
24use Fisharebest\Webtrees\I18N;
25use Fisharebest\Webtrees\Individual;
26use Fisharebest\Webtrees\Registry;
27use Fisharebest\Webtrees\Statistics;
28use Fisharebest\Webtrees\Validator;
29use Psr\Http\Message\ResponseInterface;
30use Psr\Http\Message\ServerRequestInterface;
31
32use function array_key_exists;
33use function array_keys;
34use function array_map;
35use function array_merge;
36use function array_sum;
37use function array_values;
38use function array_walk;
39use function assert;
40use function count;
41use function explode;
42use function in_array;
43use function intdiv;
44use function is_numeric;
45use function sprintf;
46
47/**
48 * Class StatisticsChartModule
49 */
50class StatisticsChartModule extends AbstractModule implements ModuleChartInterface
51{
52    use ModuleChartTrait;
53
54    public const X_AXIS_INDIVIDUAL_MAP        = 1;
55    public const X_AXIS_BIRTH_MAP             = 2;
56    public const X_AXIS_DEATH_MAP             = 3;
57    public const X_AXIS_MARRIAGE_MAP          = 4;
58    public const X_AXIS_BIRTH_MONTH           = 11;
59    public const X_AXIS_DEATH_MONTH           = 12;
60    public const X_AXIS_MARRIAGE_MONTH        = 13;
61    public const X_AXIS_FIRST_CHILD_MONTH     = 14;
62    public const X_AXIS_FIRST_MARRIAGE_MONTH  = 15;
63    public const X_AXIS_AGE_AT_DEATH          = 18;
64    public const X_AXIS_AGE_AT_MARRIAGE       = 19;
65    public const X_AXIS_AGE_AT_FIRST_MARRIAGE = 20;
66    public const X_AXIS_NUMBER_OF_CHILDREN    = 21;
67
68    public const Y_AXIS_NUMBERS = 201;
69    public const Y_AXIS_PERCENT = 202;
70
71    public const Z_AXIS_ALL  = 300;
72    public const Z_AXIS_SEX  = 301;
73    public const Z_AXIS_TIME = 302;
74
75    // First two colors are blue/pink, to work with Z_AXIS_SEX.
76    private const Z_AXIS_COLORS = ['0000FF', 'FFA0CB', '9F00FF', 'FF7000', '905030', 'FF0000', '00FF00', 'F0F000'];
77
78    private const DAYS_IN_YEAR = 365.25;
79
80    /**
81     * How should this module be identified in the control panel, etc.?
82     *
83     * @return string
84     */
85    public function title(): string
86    {
87        /* I18N: Name of a module/chart */
88        return I18N::translate('Statistics');
89    }
90
91    /**
92     * A sentence describing what this module does.
93     *
94     * @return string
95     */
96    public function description(): string
97    {
98        /* I18N: Description of the “StatisticsChart” module */
99        return I18N::translate('Various statistics charts.');
100    }
101
102    /**
103     * CSS class for the URL.
104     *
105     * @return string
106     */
107    public function chartMenuClass(): string
108    {
109        return 'menu-chart-statistics';
110    }
111
112    /**
113     * The URL for this chart.
114     *
115     * @param Individual                                $individual
116     * @param array<bool|int|string|array<string>|null> $parameters
117     *
118     * @return string
119     */
120    public function chartUrl(Individual $individual, array $parameters = []): string
121    {
122        return route('module', [
123                'module' => $this->name(),
124                'action' => 'Chart',
125                'tree'    => $individual->tree()->name(),
126            ] + $parameters);
127    }
128
129    /**
130     * A form to request the chart parameters.
131     *
132     * @param ServerRequestInterface $request
133     *
134     * @return ResponseInterface
135     */
136    public function getChartAction(ServerRequestInterface $request): ResponseInterface
137    {
138        $tree = Validator::attributes($request)->tree();
139        $user = Validator::attributes($request)->user();
140
141        Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user);
142
143        $tabs = [
144            I18N::translate('Individuals') => route('module', [
145                'module' => $this->name(),
146                'action' => 'Individuals',
147                'tree'    => $tree->name(),
148            ]),
149            I18N::translate('Families')    => route('module', [
150                'module' => $this->name(),
151                'action' => 'Families',
152                'tree'    => $tree->name(),
153            ]),
154            I18N::translate('Other')       => route('module', [
155                'module' => $this->name(),
156                'action' => 'Other',
157                'tree'    => $tree->name(),
158            ]),
159            I18N::translate('Custom')      => route('module', [
160                'module' => $this->name(),
161                'action' => 'Custom',
162                'tree'    => $tree->name(),
163            ]),
164        ];
165
166        return $this->viewResponse('modules/statistics-chart/page', [
167            'module' => $this->name(),
168            'tabs'   => $tabs,
169            'title'  => $this->title(),
170            'tree'   => $tree,
171        ]);
172    }
173
174    /**
175     * @param ServerRequestInterface $request
176     *
177     * @return ResponseInterface
178     */
179    public function getIndividualsAction(ServerRequestInterface $request): ResponseInterface
180    {
181        $this->layout = 'layouts/ajax';
182
183        return $this->viewResponse('modules/statistics-chart/individuals', [
184            'show_oldest_living' => Auth::check(),
185            'statistics'         => Registry::container()->get(Statistics::class),
186        ]);
187    }
188
189    /**
190     * @param ServerRequestInterface $request
191     *
192     * @return ResponseInterface
193     */
194    public function getFamiliesAction(ServerRequestInterface $request): ResponseInterface
195    {
196        $this->layout = 'layouts/ajax';
197
198        return $this->viewResponse('modules/statistics-chart/families', [
199            'statistics' => Registry::container()->get(Statistics::class),
200        ]);
201    }
202
203    /**
204     * @param ServerRequestInterface $request
205     *
206     * @return ResponseInterface
207     */
208    public function getOtherAction(ServerRequestInterface $request): ResponseInterface
209    {
210        $this->layout = 'layouts/ajax';
211
212        return $this->viewResponse('modules/statistics-chart/other', [
213            'statistics' => Registry::container()->get(Statistics::class),
214        ]);
215    }
216
217    /**
218     * @param ServerRequestInterface $request
219     *
220     * @return ResponseInterface
221     */
222    public function getCustomAction(ServerRequestInterface $request): ResponseInterface
223    {
224        $this->layout = 'layouts/ajax';
225
226        $tree = Validator::attributes($request)->tree();
227
228        return $this->viewResponse('modules/statistics-chart/custom', [
229            'module' => $this,
230            'tree'   => $tree,
231        ]);
232    }
233
234    /**
235     * @param ServerRequestInterface $request
236     *
237     * @return ResponseInterface
238     */
239    public function postCustomChartAction(ServerRequestInterface $request): ResponseInterface
240    {
241        $statistics = Registry::container()->get(Statistics::class);
242        assert($statistics instanceof Statistics);
243
244        $x_axis_type = Validator::parsedBody($request)->integer('x-as');
245        $y_axis_type = Validator::parsedBody($request)->integer('y-as');
246        $z_axis_type = Validator::parsedBody($request)->integer('z-as');
247        $ydata       = [];
248
249        switch ($x_axis_type) {
250            case self::X_AXIS_INDIVIDUAL_MAP:
251                return response($statistics->chartDistribution(
252                    Validator::parsedBody($request)->string('chart_shows'),
253                    Validator::parsedBody($request)->string('chart_type'),
254                    Validator::parsedBody($request)->string('SURN')
255                ));
256
257            case self::X_AXIS_BIRTH_MAP:
258                return response($statistics->chartDistribution(
259                    Validator::parsedBody($request)->string('chart_shows'),
260                    'birth_distribution_chart'
261                ));
262
263            case self::X_AXIS_DEATH_MAP:
264                return response($statistics->chartDistribution(
265                    Validator::parsedBody($request)->string('chart_shows'),
266                    'death_distribution_chart'
267                ));
268
269            case self::X_AXIS_MARRIAGE_MAP:
270                return response($statistics->chartDistribution(
271                    Validator::parsedBody($request)->string('chart_shows'),
272                    'marriage_distribution_chart'
273                ));
274
275            case self::X_AXIS_BIRTH_MONTH:
276                $chart_title  = I18N::translate('Month of birth');
277                $x_axis_title = I18N::translate('Month');
278                $x_axis       = $this->axisMonths();
279
280                switch ($y_axis_type) {
281                    case self::Y_AXIS_NUMBERS:
282                        $y_axis_title = I18N::translate('Individuals');
283                        break;
284                    case self::Y_AXIS_PERCENT:
285                        $y_axis_title = '%';
286                        break;
287                    default:
288                        throw new HttpNotFoundException();
289                }
290
291                switch ($z_axis_type) {
292                    case self::Z_AXIS_ALL:
293                        $z_axis = $this->axisAll();
294                        $rows   = $statistics->statsBirthQuery()->get();
295                        foreach ($rows as $row) {
296                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
297                        }
298                        break;
299                    case self::Z_AXIS_SEX:
300                        $z_axis = $this->axisSexes();
301                        $rows   = $statistics->statsBirthBySexQuery()->get();
302                        foreach ($rows as $row) {
303                            $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata);
304                        }
305                        break;
306                    case self::Z_AXIS_TIME:
307                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
308                        $z_axis         = $this->axisYears($boundaries_csv);
309                        $prev_boundary  = 0;
310                        foreach (array_keys($z_axis) as $boundary) {
311                            $rows = $statistics->statsBirthQuery($prev_boundary, $boundary)->get();
312                            foreach ($rows as $row) {
313                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
314                            }
315                            $prev_boundary = $boundary + 1;
316                        }
317                        break;
318                    default:
319                        throw new HttpNotFoundException();
320                }
321
322                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
323
324            case self::X_AXIS_DEATH_MONTH:
325                $chart_title  = I18N::translate('Month of death');
326                $x_axis_title = I18N::translate('Month');
327                $x_axis       = $this->axisMonths();
328
329                switch ($y_axis_type) {
330                    case self::Y_AXIS_NUMBERS:
331                        $y_axis_title = I18N::translate('Individuals');
332                        break;
333                    case self::Y_AXIS_PERCENT:
334                        $y_axis_title = '%';
335                        break;
336                    default:
337                        throw new HttpNotFoundException();
338                }
339
340                switch ($z_axis_type) {
341                    case self::Z_AXIS_ALL:
342                        $z_axis = $this->axisAll();
343                        $rows   = $statistics->statsDeathQuery()->get();
344                        foreach ($rows as $row) {
345                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
346                        }
347                        break;
348                    case self::Z_AXIS_SEX:
349                        $z_axis = $this->axisSexes();
350                        $rows   = $statistics->statsDeathBySexQuery()->get();
351                        foreach ($rows as $row) {
352                            $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata);
353                        }
354                        break;
355                    case self::Z_AXIS_TIME:
356                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
357                        $z_axis         = $this->axisYears($boundaries_csv);
358                        $prev_boundary  = 0;
359                        foreach (array_keys($z_axis) as $boundary) {
360                            $rows = $statistics->statsDeathQuery($prev_boundary, $boundary)->get();
361                            foreach ($rows as $row) {
362                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
363                            }
364                            $prev_boundary = $boundary + 1;
365                        }
366                        break;
367                    default:
368                        throw new HttpNotFoundException();
369                }
370
371                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
372
373            case self::X_AXIS_MARRIAGE_MONTH:
374                $chart_title  = I18N::translate('Month of marriage');
375                $x_axis_title = I18N::translate('Month');
376                $x_axis       = $this->axisMonths();
377
378                switch ($y_axis_type) {
379                    case self::Y_AXIS_NUMBERS:
380                        $y_axis_title = I18N::translate('Families');
381                        break;
382                    case self::Y_AXIS_PERCENT:
383                        $y_axis_title = '%';
384                        break;
385                    default:
386                        throw new HttpNotFoundException();
387                }
388
389                switch ($z_axis_type) {
390                    case self::Z_AXIS_ALL:
391                        $z_axis = $this->axisAll();
392                        $rows   = $statistics->statsMarriageQuery()->get();
393                        foreach ($rows as $row) {
394                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
395                        }
396                        break;
397                    case self::Z_AXIS_TIME:
398                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
399                        $z_axis         = $this->axisYears($boundaries_csv);
400                        $prev_boundary  = 0;
401                        foreach (array_keys($z_axis) as $boundary) {
402                            $rows = $statistics->statsMarriageQuery($prev_boundary, $boundary)->get();
403                            foreach ($rows as $row) {
404                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
405                            }
406                            $prev_boundary = $boundary + 1;
407                        }
408                        break;
409                    default:
410                        throw new HttpNotFoundException();
411                }
412
413                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
414
415            case self::X_AXIS_FIRST_CHILD_MONTH:
416                $chart_title  = I18N::translate('Month of birth of first child in a relation');
417                $x_axis_title = I18N::translate('Month');
418                $x_axis       = $this->axisMonths();
419
420                switch ($y_axis_type) {
421                    case self::Y_AXIS_NUMBERS:
422                        $y_axis_title = I18N::translate('Children');
423                        break;
424                    case self::Y_AXIS_PERCENT:
425                        $y_axis_title = '%';
426                        break;
427                    default:
428                        throw new HttpNotFoundException();
429                }
430
431                switch ($z_axis_type) {
432                    case self::Z_AXIS_ALL:
433                        $z_axis = $this->axisAll();
434                        $rows   = $statistics->monthFirstChildQuery()->get();
435                        foreach ($rows as $row) {
436                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
437                        }
438                        break;
439                    case self::Z_AXIS_SEX:
440                        $z_axis = $this->axisSexes();
441                        $rows   = $statistics->monthFirstChildBySexQuery()->get();
442                        foreach ($rows as $row) {
443                            $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata);
444                        }
445                        break;
446                    case self::Z_AXIS_TIME:
447                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
448                        $z_axis         = $this->axisYears($boundaries_csv);
449                        $prev_boundary  = 0;
450                        foreach (array_keys($z_axis) as $boundary) {
451                            $rows = $statistics->monthFirstChildQuery($prev_boundary, $boundary)->get();
452                            foreach ($rows as $row) {
453                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
454                            }
455                            $prev_boundary = $boundary + 1;
456                        }
457                        break;
458                    default:
459                        throw new HttpNotFoundException();
460                }
461
462                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
463
464            case self::X_AXIS_FIRST_MARRIAGE_MONTH:
465                $chart_title  = I18N::translate('Month of first marriage');
466                $x_axis_title = I18N::translate('Month');
467                $x_axis       = $this->axisMonths();
468
469                switch ($y_axis_type) {
470                    case self::Y_AXIS_NUMBERS:
471                        $y_axis_title = I18N::translate('Families');
472                        break;
473                    case self::Y_AXIS_PERCENT:
474                        $y_axis_title = '%';
475                        break;
476                    default:
477                        throw new HttpNotFoundException();
478                }
479
480                switch ($z_axis_type) {
481                    case self::Z_AXIS_ALL:
482                        $z_axis = $this->axisAll();
483                        $rows   = $statistics->statsFirstMarriageQuery()->get();
484                        $indi   = [];
485                        foreach ($rows as $row) {
486                            if (!in_array($row->f_husb, $indi, true) && !in_array($row->f_wife, $indi, true)) {
487                                $this->fillYData($row->month, 0, 1, $x_axis, $z_axis, $ydata);
488                            }
489                            $indi[]  = $row->f_husb;
490                            $indi[]  = $row->f_wife;
491                        }
492                        break;
493                    case self::Z_AXIS_TIME:
494                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
495                        $z_axis         = $this->axisYears($boundaries_csv);
496                        $prev_boundary  = 0;
497                        $indi           = [];
498                        foreach (array_keys($z_axis) as $boundary) {
499                            $rows = $statistics->statsFirstMarriageQuery($prev_boundary, $boundary)->get();
500                            foreach ($rows as $row) {
501                                if (!in_array($row->f_husb, $indi, true) && !in_array($row->f_wife, $indi, true)) {
502                                    $this->fillYData($row->month, $boundary, 1, $x_axis, $z_axis, $ydata);
503                                }
504                                $indi[]  = $row->f_husb;
505                                $indi[]  = $row->f_wife;
506                            }
507                            $prev_boundary = $boundary + 1;
508                        }
509                        break;
510                    default:
511                        throw new HttpNotFoundException();
512                }
513
514                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
515
516            case self::X_AXIS_AGE_AT_DEATH:
517                $chart_title    = I18N::translate('Average age at death');
518                $x_axis_title   = I18N::translate('age');
519                $boundaries_csv = Validator::parsedBody($request)->string('x-axis-boundaries-ages');
520                $x_axis         = $this->axisNumbers($boundaries_csv);
521
522                switch ($y_axis_type) {
523                    case self::Y_AXIS_NUMBERS:
524                        $y_axis_title = I18N::translate('Individuals');
525                        break;
526                    case self::Y_AXIS_PERCENT:
527                        $y_axis_title = '%';
528                        break;
529                    default:
530                        throw new HttpNotFoundException();
531                }
532
533                switch ($z_axis_type) {
534                    case self::Z_AXIS_ALL:
535                        $z_axis = $this->axisAll();
536                        $rows   = $statistics->statsAgeQuery('DEAT');
537                        foreach ($rows as $row) {
538                            foreach ($row as $age) {
539                                $years = (int) ($age / self::DAYS_IN_YEAR);
540                                $this->fillYData($years, 0, 1, $x_axis, $z_axis, $ydata);
541                            }
542                        }
543                        break;
544                    case self::Z_AXIS_SEX:
545                        $z_axis = $this->axisSexes();
546                        foreach (array_keys($z_axis) as $sex) {
547                            $rows = $statistics->statsAgeQuery('DEAT', $sex);
548                            foreach ($rows as $row) {
549                                foreach ($row as $age) {
550                                    $years = (int) ($age / self::DAYS_IN_YEAR);
551                                    $this->fillYData($years, $sex, 1, $x_axis, $z_axis, $ydata);
552                                }
553                            }
554                        }
555                        break;
556                    case self::Z_AXIS_TIME:
557                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
558                        $z_axis         = $this->axisYears($boundaries_csv);
559                        $prev_boundary  = 0;
560                        foreach (array_keys($z_axis) as $boundary) {
561                            $rows = $statistics->statsAgeQuery('DEAT', 'BOTH', $prev_boundary, $boundary);
562                            foreach ($rows as $row) {
563                                foreach ($row as $age) {
564                                    $years = (int) ($age / self::DAYS_IN_YEAR);
565                                    $this->fillYData($years, $boundary, 1, $x_axis, $z_axis, $ydata);
566                                }
567                            }
568                            $prev_boundary = $boundary + 1;
569                        }
570
571                        break;
572                    default:
573                        throw new HttpNotFoundException();
574                }
575
576                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
577
578            case self::X_AXIS_AGE_AT_MARRIAGE:
579                $chart_title    = I18N::translate('Age in year of marriage');
580                $x_axis_title   = I18N::translate('age');
581                $boundaries_csv = Validator::parsedBody($request)->string('x-axis-boundaries-ages_m');
582                $x_axis         = $this->axisNumbers($boundaries_csv);
583
584                switch ($y_axis_type) {
585                    case self::Y_AXIS_NUMBERS:
586                        $y_axis_title = I18N::translate('Individuals');
587                        break;
588                    case self::Y_AXIS_PERCENT:
589                        $y_axis_title = '%';
590                        break;
591                    default:
592                        throw new HttpNotFoundException();
593                }
594
595                switch ($z_axis_type) {
596                    case self::Z_AXIS_ALL:
597                        $z_axis = $this->axisAll();
598                        // The stats query doesn't have an "all" function, so query M/F separately
599                        foreach (['M', 'F'] as $sex) {
600                            $rows = $statistics->statsMarrAgeQuery($sex);
601                            foreach ($rows as $row) {
602                                $years = (int) ($row->age / self::DAYS_IN_YEAR);
603                                $this->fillYData($years, 0, 1, $x_axis, $z_axis, $ydata);
604                            }
605                        }
606                        break;
607                    case self::Z_AXIS_SEX:
608                        $z_axis = $this->axisSexes();
609                        foreach (array_keys($z_axis) as $sex) {
610                            $rows = $statistics->statsMarrAgeQuery($sex);
611                            foreach ($rows as $row) {
612                                $years = (int) ($row->age / self::DAYS_IN_YEAR);
613                                $this->fillYData($years, $sex, 1, $x_axis, $z_axis, $ydata);
614                            }
615                        }
616                        break;
617                    case self::Z_AXIS_TIME:
618                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
619                        $z_axis         = $this->axisYears($boundaries_csv);
620                        // The stats query doesn't have an "all" function, so query M/F separately
621                        foreach (['M', 'F'] as $sex) {
622                            $prev_boundary = 0;
623                            foreach (array_keys($z_axis) as $boundary) {
624                                $rows = $statistics->statsMarrAgeQuery($sex, $prev_boundary, $boundary);
625                                foreach ($rows as $row) {
626                                    $years = (int) ($row->age / self::DAYS_IN_YEAR);
627                                    $this->fillYData($years, $boundary, 1, $x_axis, $z_axis, $ydata);
628                                }
629                                $prev_boundary = $boundary + 1;
630                            }
631                        }
632                        break;
633                    default:
634                        throw new HttpNotFoundException();
635                }
636
637                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
638
639            case self::X_AXIS_AGE_AT_FIRST_MARRIAGE:
640                $chart_title    = I18N::translate('Age in year of first marriage');
641                $x_axis_title   = I18N::translate('age');
642                $boundaries_csv = Validator::parsedBody($request)->string('x-axis-boundaries-ages_m');
643                $x_axis         = $this->axisNumbers($boundaries_csv);
644
645                switch ($y_axis_type) {
646                    case self::Y_AXIS_NUMBERS:
647                        $y_axis_title = I18N::translate('Individuals');
648                        break;
649                    case self::Y_AXIS_PERCENT:
650                        $y_axis_title = '%';
651                        break;
652                    default:
653                        throw new HttpNotFoundException();
654                }
655
656                switch ($z_axis_type) {
657                    case self::Z_AXIS_ALL:
658                        $z_axis = $this->axisAll();
659                        // The stats query doesn't have an "all" function, so query M/F separately
660                        foreach (['M', 'F'] as $sex) {
661                            $rows = $statistics->statsMarrAgeQuery($sex);
662                            $indi = [];
663                            foreach ($rows as $row) {
664                                if (!in_array($row->d_gid, $indi, true)) {
665                                    $years = (int) ($row->age / self::DAYS_IN_YEAR);
666                                    $this->fillYData($years, 0, 1, $x_axis, $z_axis, $ydata);
667                                    $indi[] = $row->d_gid;
668                                }
669                            }
670                        }
671                        break;
672                    case self::Z_AXIS_SEX:
673                        $z_axis = $this->axisSexes();
674                        foreach (array_keys($z_axis) as $sex) {
675                            $rows = $statistics->statsMarrAgeQuery($sex);
676                            $indi = [];
677                            foreach ($rows as $row) {
678                                if (!in_array($row->d_gid, $indi, true)) {
679                                    $years = (int) ($row->age / self::DAYS_IN_YEAR);
680                                    $this->fillYData($years, $sex, 1, $x_axis, $z_axis, $ydata);
681                                    $indi[] = $row->d_gid;
682                                }
683                            }
684                        }
685                        break;
686                    case self::Z_AXIS_TIME:
687                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
688                        $z_axis         = $this->axisYears($boundaries_csv);
689                        // The stats query doesn't have an "all" function, so query M/F separately
690                        foreach (['M', 'F'] as $sex) {
691                            $prev_boundary = 0;
692                            $indi          = [];
693                            foreach (array_keys($z_axis) as $boundary) {
694                                $rows = $statistics->statsMarrAgeQuery($sex, $prev_boundary, $boundary);
695                                foreach ($rows as $row) {
696                                    if (!in_array($row->d_gid, $indi, true)) {
697                                        $years = (int) ($row->age / self::DAYS_IN_YEAR);
698                                        $this->fillYData($years, $boundary, 1, $x_axis, $z_axis, $ydata);
699                                        $indi[] = $row->d_gid;
700                                    }
701                                }
702                                $prev_boundary = $boundary + 1;
703                            }
704                        }
705                        break;
706                    default:
707                        throw new HttpNotFoundException();
708                }
709
710                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
711
712            case self::X_AXIS_NUMBER_OF_CHILDREN:
713                $chart_title  = I18N::translate('Number of children');
714                $x_axis_title = I18N::translate('Children');
715                $x_axis       = $this->axisNumbers('0,1,2,3,4,5,6,7,8,9,10');
716
717                switch ($y_axis_type) {
718                    case self::Y_AXIS_NUMBERS:
719                        $y_axis_title = I18N::translate('Families');
720                        break;
721                    case self::Y_AXIS_PERCENT:
722                        $y_axis_title = '%';
723                        break;
724                    default:
725                        throw new HttpNotFoundException();
726                }
727
728                switch ($z_axis_type) {
729                    case self::Z_AXIS_ALL:
730                        $z_axis = $this->axisAll();
731                        $rows   = $statistics->statsChildrenQuery();
732                        foreach ($rows as $row) {
733                            $this->fillYData($row->f_numchil, 0, $row->total, $x_axis, $z_axis, $ydata);
734                        }
735                        break;
736                    case self::Z_AXIS_TIME:
737                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
738                        $z_axis         = $this->axisYears($boundaries_csv);
739                        $prev_boundary  = 0;
740                        foreach (array_keys($z_axis) as $boundary) {
741                            $rows = $statistics->statsChildrenQuery($prev_boundary, $boundary);
742                            foreach ($rows as $row) {
743                                $this->fillYData($row->f_numchil, $boundary, $row->total, $x_axis, $z_axis, $ydata);
744                            }
745                            $prev_boundary = $boundary + 1;
746                        }
747                        break;
748                    default:
749                        throw new HttpNotFoundException();
750                }
751
752                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
753
754            default:
755                throw new HttpNotFoundException();
756        }
757    }
758
759    /**
760     * @return array<string>
761     */
762    private function axisAll(): array
763    {
764        return [
765            I18N::translate('Total'),
766        ];
767    }
768
769    /**
770     * @return array<string>
771     */
772    private function axisSexes(): array
773    {
774        return [
775            'M' => I18N::translate('Male'),
776            'F' => I18N::translate('Female'),
777        ];
778    }
779
780    /**
781     * Labels for the X axis
782     *
783     * @return array<string>
784     */
785    private function axisMonths(): array
786    {
787        return [
788            'JAN' => I18N::translateContext('NOMINATIVE', 'January'),
789            'FEB' => I18N::translateContext('NOMINATIVE', 'February'),
790            'MAR' => I18N::translateContext('NOMINATIVE', 'March'),
791            'APR' => I18N::translateContext('NOMINATIVE', 'April'),
792            'MAY' => I18N::translateContext('NOMINATIVE', 'May'),
793            'JUN' => I18N::translateContext('NOMINATIVE', 'June'),
794            'JUL' => I18N::translateContext('NOMINATIVE', 'July'),
795            'AUG' => I18N::translateContext('NOMINATIVE', 'August'),
796            'SEP' => I18N::translateContext('NOMINATIVE', 'September'),
797            'OCT' => I18N::translateContext('NOMINATIVE', 'October'),
798            'NOV' => I18N::translateContext('NOMINATIVE', 'November'),
799            'DEC' => I18N::translateContext('NOMINATIVE', 'December'),
800        ];
801    }
802
803    /**
804     * Convert a list of N year-boundaries into N+1 year-ranges for the z-axis.
805     *
806     * @param string $boundaries_csv
807     *
808     * @return array<string>
809     */
810    private function axisYears(string $boundaries_csv): array
811    {
812        $boundaries = explode(',', $boundaries_csv);
813
814        $axis = [];
815        foreach ($boundaries as $n => $boundary) {
816            if ($n === 0) {
817                $axis[$boundary - 1] = '–' . I18N::digits($boundary);
818            } else {
819                $axis[$boundary - 1] = I18N::digits($boundaries[$n - 1]) . '–' . I18N::digits($boundary);
820            }
821        }
822
823        $axis[PHP_INT_MAX] = I18N::digits($boundaries[count($boundaries) - 1]) . '–';
824
825        return $axis;
826    }
827
828    /**
829     * Create the X axis.
830     *
831     * @param string $boundaries_csv
832     *
833     * @return array<string>
834     */
835    private function axisNumbers(string $boundaries_csv): array
836    {
837        $boundaries = explode(',', $boundaries_csv);
838
839        $boundaries = array_map(static fn (string $x): int => (int) $x, $boundaries);
840
841        $axis = [];
842        foreach ($boundaries as $n => $boundary) {
843            if ($n === 0) {
844                $prev_boundary = 0;
845            } else {
846                $prev_boundary = $boundaries[$n - 1] + 1;
847            }
848
849            if ($prev_boundary === $boundary) {
850                /* I18N: A range of numbers */
851                $axis[$boundary] = I18N::number($boundary);
852            } else {
853                /* I18N: A range of numbers */
854                $axis[$boundary] = I18N::translate('%1$s–%2$s', I18N::number($prev_boundary), I18N::number($boundary));
855            }
856        }
857
858        /* I18N: Label on a graph; 40+ means 40 or more */
859        $axis[PHP_INT_MAX] = I18N::translate('%s+', I18N::number($boundaries[count($boundaries) - 1]));
860
861        return $axis;
862    }
863
864    /**
865     * Calculate the Y axis.
866     *
867     * @param int|string        $x
868     * @param int|string        $z
869     * @param int|string        $value
870     * @param array<string>     $x_axis
871     * @param array<string>     $z_axis
872     * @param array<array<int>> $ydata
873     *
874     * @return void
875     */
876    private function fillYData($x, $z, $value, array $x_axis, array $z_axis, array &$ydata): void
877    {
878        $x = $this->findAxisEntry($x, $x_axis);
879        $z = $this->findAxisEntry($z, $z_axis);
880
881        if (!array_key_exists($z, $z_axis)) {
882            foreach (array_keys($z_axis) as $key) {
883                if ($value <= $key) {
884                    $z = $key;
885                    break;
886                }
887            }
888        }
889
890        // Add the value to the appropriate data point.
891        $ydata[$z][$x] = ($ydata[$z][$x] ?? 0) + $value;
892    }
893
894    /**
895     * Find the axis entry for a given value.
896     * Some are direct lookup (e.g. M/F, JAN/FEB/MAR).
897     * Others need to find the appropriate range.
898     *
899     * @param int|string    $value
900     * @param array<string> $axis
901     *
902     * @return int|string
903     */
904    private function findAxisEntry($value, array $axis)
905    {
906        if (is_numeric($value)) {
907            $value = (int) $value;
908
909            if (!array_key_exists($value, $axis)) {
910                foreach (array_keys($axis) as $boundary) {
911                    if ($value <= $boundary) {
912                        $value = $boundary;
913                        break;
914                    }
915                }
916            }
917        }
918
919        return $value;
920    }
921
922    /**
923     * Plot the data.
924     *
925     * @param string            $chart_title
926     * @param array<string>     $x_axis
927     * @param string            $x_axis_title
928     * @param array<array<int>> $ydata
929     * @param string            $y_axis_title
930     * @param array<string>     $z_axis
931     * @param int               $y_axis_type
932     *
933     * @return string
934     */
935    private function myPlot(
936        string $chart_title,
937        array $x_axis,
938        string $x_axis_title,
939        array $ydata,
940        string $y_axis_title,
941        array $z_axis,
942        int $y_axis_type
943    ): string {
944        if (!count($ydata)) {
945            return I18N::translate('This information is not available.');
946        }
947
948        // Colors for z-axis
949        $colors = [];
950        $index  = 0;
951        while (count($colors) < count($ydata)) {
952            $colors[] = self::Z_AXIS_COLORS[$index];
953            $index    = ($index + 1) % count(self::Z_AXIS_COLORS);
954        }
955
956        // Convert our sparse dataset into a fixed-size array
957        $tmp = [];
958        foreach (array_keys($z_axis) as $z) {
959            foreach (array_keys($x_axis) as $x) {
960                $tmp[$z][$x] = $ydata[$z][$x] ?? 0;
961            }
962        }
963        $ydata = $tmp;
964
965        // Convert the chart data to percentage
966        if ($y_axis_type === self::Y_AXIS_PERCENT) {
967            // Normalise each (non-zero!) set of data to total 100%
968            array_walk($ydata, static function (array &$x) {
969                $sum = array_sum($x);
970                if ($sum > 0) {
971                    $x = array_map(static fn (float $y): float => $y * 100.0 / $sum, $x);
972                }
973            });
974        }
975
976        $data = [
977            array_merge(
978                [I18N::translate('Century')],
979                array_values($z_axis)
980            ),
981        ];
982
983        $intermediate = [];
984        foreach ($ydata as $months) {
985            foreach ($months as $month => $value) {
986                $intermediate[$month][] = [
987                    'v' => $value,
988                    'f' => $y_axis_type === self::Y_AXIS_PERCENT ? sprintf('%.1f%%', $value) : $value,
989                ];
990            }
991        }
992
993        foreach ($intermediate as $key => $values) {
994            $data[] = array_merge(
995                [$x_axis[$key]],
996                $values
997            );
998        }
999
1000        $chart_options = [
1001            'title'    => '',
1002            'subtitle' => '',
1003            'height'   => 400,
1004            'width'    => '100%',
1005            'legend'   => [
1006                'position'  => count($z_axis) > 1 ? 'right' : 'none',
1007                'alignment' => 'center',
1008            ],
1009            'tooltip'  => [
1010                'format' => '\'%\'',
1011            ],
1012            'vAxis'    => [
1013                'title' => $y_axis_title,
1014            ],
1015            'hAxis'    => [
1016                'title' => $x_axis_title,
1017            ],
1018            'colors'   => $colors,
1019        ];
1020
1021        return view('statistics/other/charts/custom', [
1022            'data'          => $data,
1023            'chart_options' => $chart_options,
1024            'chart_title'   => $chart_title,
1025            'language'      => I18N::languageTag(),
1026        ]);
1027    }
1028}
1029