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