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