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