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