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