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