xref: /webtrees/app/Module/StatisticsChartModule.php (revision f567c3d8c682bc271cb765480294cb4273f7632a)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>.
16 */
17declare(strict_types=1);
18
19namespace Fisharebest\Webtrees\Module;
20
21use Fisharebest\Webtrees\Auth;
22use Fisharebest\Webtrees\Date;
23use Fisharebest\Webtrees\I18N;
24use Fisharebest\Webtrees\Individual;
25use Fisharebest\Webtrees\Statistics;
26use Psr\Http\Message\ResponseInterface;
27use Psr\Http\Message\ServerRequestInterface;
28use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
29
30use function app;
31use function array_key_exists;
32use function array_keys;
33use function array_map;
34use function array_merge;
35use function array_sum;
36use function array_values;
37use function array_walk;
38use function count;
39use function explode;
40use function in_array;
41use function is_numeric;
42use function sprintf;
43use function strip_tags;
44
45/**
46 * Class StatisticsChartModule
47 */
48class StatisticsChartModule extends AbstractModule implements ModuleChartInterface
49{
50    use ModuleChartTrait;
51
52    // We generate a bitmap chart with these dimensions in image pixels.
53    // These set the aspect ratio.  The actual image is sized using CSS
54    // The maximum size (width x height) is 300,000
55    private const CHART_WIDTH  = 950;
56    private const CHART_HEIGHT = 315;
57
58    public const X_AXIS_INDIVIDUAL_MAP        = 1;
59    public const X_AXIS_BIRTH_MAP             = 2;
60    public const X_AXIS_DEATH_MAP             = 3;
61    public const X_AXIS_MARRIAGE_MAP          = 4;
62    public const X_AXIS_BIRTH_MONTH           = 11;
63    public const X_AXIS_DEATH_MONTH           = 12;
64    public const X_AXIS_MARRIAGE_MONTH        = 13;
65    public const X_AXIS_FIRST_CHILD_MONTH     = 14;
66    public const X_AXIS_FIRST_MARRIAGE_MONTH  = 15;
67    public const X_AXIS_AGE_AT_DEATH          = 18;
68    public const X_AXIS_AGE_AT_MARRIAGE       = 19;
69    public const X_AXIS_AGE_AT_FIRST_MARRIAGE = 20;
70    public const X_AXIS_NUMBER_OF_CHILDREN    = 21;
71
72    public const Y_AXIS_NUMBERS = 201;
73    public const Y_AXIS_PERCENT = 202;
74
75    public const Z_AXIS_ALL  = 300;
76    public const Z_AXIS_SEX  = 301;
77    public const Z_AXIS_TIME = 302;
78
79    // First two colors are blue/pink, to work with Z_AXIS_SEX.
80    private const Z_AXIS_COLORS = ['0000FF', 'FFA0CB', '9F00FF', 'FF7000', '905030', 'FF0000', '00FF00', 'F0F000'];
81
82    private const DAYS_IN_YEAR = 365.25;
83
84    /**
85     * How should this module be identified in the control panel, etc.?
86     *
87     * @return string
88     */
89    public function title(): string
90    {
91        /* I18N: Name of a module/chart */
92        return I18N::translate('Statistics');
93    }
94
95    /**
96     * A sentence describing what this module does.
97     *
98     * @return string
99     */
100    public function description(): string
101    {
102        /* I18N: Description of the “StatisticsChart” module */
103        return I18N::translate('Various statistics charts.');
104    }
105
106    /**
107     * CSS class for the URL.
108     *
109     * @return string
110     */
111    public function chartMenuClass(): string
112    {
113        return 'menu-chart-statistics';
114    }
115
116    /**
117     * The URL for this chart.
118     *
119     * @param Individual $individual
120     * @param string[]   $parameters
121     *
122     * @return string
123     */
124    public function chartUrl(Individual $individual, array $parameters = []): string
125    {
126        return route('module', [
127                'module' => $this->name(),
128                'action' => 'Chart',
129                'tree'    => $individual->tree()->name(),
130            ] + $parameters);
131    }
132
133    /**
134     * A form to request the chart parameters.
135     *
136     * @param ServerRequestInterface $request
137     *
138     * @return ResponseInterface
139     */
140    public function getChartAction(ServerRequestInterface $request): ResponseInterface
141    {
142        $tree = $request->getAttribute('tree');
143        $user = $request->getAttribute('user');
144
145        Auth::checkComponentAccess($this, 'chart', $tree, $user);
146
147        $tabs = [
148            I18N::translate('Individuals') => route('module', [
149                'module' => $this->name(),
150                'action' => 'Individuals',
151                'tree'    => $tree->name(),
152            ]),
153            I18N::translate('Families')    => route('module', [
154                'module' => $this->name(),
155                'action' => 'Families',
156                'tree'    => $tree->name(),
157            ]),
158            I18N::translate('Other')       => route('module', [
159                'module' => $this->name(),
160                'action' => 'Other',
161                'tree'    => $tree->name(),
162            ]),
163            I18N::translate('Custom')      => route('module', [
164                'module' => $this->name(),
165                'action' => 'Custom',
166                'tree'    => $tree->name(),
167            ]),
168        ];
169
170        return $this->viewResponse('modules/statistics-chart/page', [
171            'module' => $this->name(),
172            'tabs'   => $tabs,
173            'title'  => $this->title(),
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
231        return $this->viewResponse('modules/statistics-chart/custom', [
232            'module' => $this,
233            'tree'   => $tree,
234        ]);
235    }
236
237    /**
238     * @param ServerRequestInterface $request
239     *
240     * @return ResponseInterface
241     */
242    public function getCustomChartAction(ServerRequestInterface $request): ResponseInterface
243    {
244        $statistics = app(Statistics::class);
245
246        $params = $request->getQueryParams();
247
248        $x_axis_type = (int) $params['x-as'];
249        $y_axis_type = (int) $params['y-as'];
250        $z_axis_type = (int) $params['z-as'];
251        $ydata       = [];
252
253        switch ($x_axis_type) {
254            case self::X_AXIS_INDIVIDUAL_MAP:
255                return response($statistics->chartDistribution(
256                    $params['chart_shows'],
257                    $params['chart_type'],
258                    $params['SURN']
259                ));
260
261            case self::X_AXIS_BIRTH_MAP:
262                return response($statistics->chartDistribution(
263                    $params['chart_shows'],
264                    'birth_distribution_chart'
265                ));
266
267            case self::X_AXIS_DEATH_MAP:
268                return response($statistics->chartDistribution(
269                    $params['chart_shows'],
270                    'death_distribution_chart'
271                ));
272
273            case self::X_AXIS_MARRIAGE_MAP:
274                return response($statistics->chartDistribution(
275                    $params['chart_shows'],
276                    'marriage_distribution_chart'
277                ));
278
279            case self::X_AXIS_BIRTH_MONTH:
280                $chart_title  = I18N::translate('Month of birth');
281                $x_axis_title = I18N::translate('Month');
282                $x_axis       = $this->axisMonths();
283
284                switch ($y_axis_type) {
285                    case self::Y_AXIS_NUMBERS:
286                        $y_axis_title = I18N::translate('Individuals');
287                        break;
288                    case self::Y_AXIS_PERCENT:
289                        $y_axis_title = '%';
290                        break;
291                    default:
292                        throw new NotFoundHttpException();
293                }
294
295                switch ($z_axis_type) {
296                    case self::Z_AXIS_ALL:
297                        $z_axis = $this->axisAll();
298                        $rows   = $statistics->statsBirthQuery()->get();
299                        foreach ($rows as $row) {
300                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
301                        }
302                        break;
303                    case self::Z_AXIS_SEX:
304                        $z_axis = $this->axisSexes();
305                        $rows   = $statistics->statsBirthBySexQuery()->get();
306                        foreach ($rows as $row) {
307                            $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata);
308                        }
309                        break;
310                    case self::Z_AXIS_TIME:
311                        $boundaries_csv = $params['z-axis-boundaries-periods'];
312                        $z_axis         = $this->axisYears($boundaries_csv);
313                        $prev_boundary  = 0;
314                        foreach (array_keys($z_axis) as $boundary) {
315                            $rows = $statistics->statsBirthQuery($prev_boundary, $boundary)->get();
316                            foreach ($rows as $row) {
317                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
318                            }
319                            $prev_boundary = $boundary + 1;
320                        }
321                        break;
322                    default:
323                        throw new NotFoundHttpException();
324                }
325
326                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
327
328            case self::X_AXIS_DEATH_MONTH:
329                $chart_title  = I18N::translate('Month of death');
330                $x_axis_title = I18N::translate('Month');
331                $x_axis       = $this->axisMonths();
332
333                switch ($y_axis_type) {
334                    case self::Y_AXIS_NUMBERS:
335                        $y_axis_title = I18N::translate('Individuals');
336                        break;
337                    case self::Y_AXIS_PERCENT:
338                        $y_axis_title = '%';
339                        break;
340                    default:
341                        throw new NotFoundHttpException();
342                }
343
344                switch ($z_axis_type) {
345                    case self::Z_AXIS_ALL:
346                        $z_axis = $this->axisAll();
347                        $rows   = $statistics->statsDeathQuery()->get();
348                        foreach ($rows as $row) {
349                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
350                        }
351                        break;
352                    case self::Z_AXIS_SEX:
353                        $z_axis = $this->axisSexes();
354                        $rows   = $statistics->statsDeathBySexQuery()->get();
355                        foreach ($rows as $row) {
356                            $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata);
357                        }
358                        break;
359                    case self::Z_AXIS_TIME:
360                        $boundaries_csv = $params['z-axis-boundaries-periods'];
361                        $z_axis         = $this->axisYears($boundaries_csv);
362                        $prev_boundary  = 0;
363                        foreach (array_keys($z_axis) as $boundary) {
364                            $rows = $statistics->statsDeathQuery($prev_boundary, $boundary)->get();
365                            foreach ($rows as $row) {
366                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
367                            }
368                            $prev_boundary = $boundary + 1;
369                        }
370                        break;
371                    default:
372                        throw new NotFoundHttpException();
373                }
374
375                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
376
377            case self::X_AXIS_MARRIAGE_MONTH:
378                $chart_title  = I18N::translate('Month of marriage');
379                $x_axis_title = I18N::translate('Month');
380                $x_axis       = $this->axisMonths();
381
382                switch ($y_axis_type) {
383                    case self::Y_AXIS_NUMBERS:
384                        $y_axis_title = I18N::translate('Families');
385                        break;
386                    case self::Y_AXIS_PERCENT:
387                        $y_axis_title = '%';
388                        break;
389                    default:
390                        throw new NotFoundHttpException();
391                }
392
393                switch ($z_axis_type) {
394                    case self::Z_AXIS_ALL:
395                        $z_axis = $this->axisAll();
396                        $rows   = $statistics->statsMarriageQuery()->get();
397                        foreach ($rows as $row) {
398                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
399                        }
400                        break;
401                    case self::Z_AXIS_TIME:
402                        $boundaries_csv = $params['z-axis-boundaries-periods'];
403                        $z_axis         = $this->axisYears($boundaries_csv);
404                        $prev_boundary  = 0;
405                        foreach (array_keys($z_axis) as $boundary) {
406                            $rows = $statistics->statsMarriageQuery($prev_boundary, $boundary)->get();
407                            foreach ($rows as $row) {
408                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
409                            }
410                            $prev_boundary = $boundary + 1;
411                        }
412                        break;
413                    default:
414                        throw new NotFoundHttpException();
415                }
416
417                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
418
419            case self::X_AXIS_FIRST_CHILD_MONTH:
420                $chart_title  = I18N::translate('Month of birth of first child in a relation');
421                $x_axis_title = I18N::translate('Month');
422                $x_axis       = $this->axisMonths();
423
424                switch ($y_axis_type) {
425                    case self::Y_AXIS_NUMBERS:
426                        $y_axis_title = I18N::translate('Children');
427                        break;
428                    case self::Y_AXIS_PERCENT:
429                        $y_axis_title = '%';
430                        break;
431                    default:
432                        throw new NotFoundHttpException();
433                }
434
435                switch ($z_axis_type) {
436                    case self::Z_AXIS_ALL:
437                        $z_axis = $this->axisAll();
438                        $rows   = $statistics->monthFirstChildQuery()->get();
439                        foreach ($rows as $row) {
440                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
441                        }
442                        break;
443                    case self::Z_AXIS_SEX:
444                        $z_axis = $this->axisSexes();
445                        $rows   = $statistics->monthFirstChildBySexQuery()->get();
446                        foreach ($rows as $row) {
447                            $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata);
448                        }
449                        break;
450                    case self::Z_AXIS_TIME:
451                        $boundaries_csv = $params['z-axis-boundaries-periods'];
452                        $z_axis         = $this->axisYears($boundaries_csv);
453                        $prev_boundary  = 0;
454                        foreach (array_keys($z_axis) as $boundary) {
455                            $rows = $statistics->monthFirstChildQuery($prev_boundary, $boundary)->get();
456                            foreach ($rows as $row) {
457                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
458                            }
459                            $prev_boundary = $boundary + 1;
460                        }
461                        break;
462                    default:
463                        throw new NotFoundHttpException();
464                }
465
466                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
467
468            case self::X_AXIS_FIRST_MARRIAGE_MONTH:
469                $chart_title  = I18N::translate('Month of first marriage');
470                $x_axis_title = I18N::translate('Month');
471                $x_axis       = $this->axisMonths();
472
473                switch ($y_axis_type) {
474                    case self::Y_AXIS_NUMBERS:
475                        $y_axis_title = I18N::translate('Families');
476                        break;
477                    case self::Y_AXIS_PERCENT:
478                        $y_axis_title = '%';
479                        break;
480                    default:
481                        throw new NotFoundHttpException();
482                }
483
484                switch ($z_axis_type) {
485                    case self::Z_AXIS_ALL:
486                        $z_axis = $this->axisAll();
487                        $rows   = $statistics->statsFirstMarriageQuery()->get();
488                        $indi   = [];
489                        $fam    = [];
490                        foreach ($rows as $row) {
491                            if (!in_array($row->indi, $indi, true) && !in_array($row->fams, $fam, true)) {
492                                $this->fillYData($row->month, 0, 1, $x_axis, $z_axis, $ydata);
493                            }
494                            $indi[] = $row->indi;
495                            $fam[]  = $row->fams;
496                        }
497                        break;
498                    case self::Z_AXIS_TIME:
499                        $boundaries_csv = $params['z-axis-boundaries-periods'];
500                        $z_axis         = $this->axisYears($boundaries_csv);
501                        $prev_boundary  = 0;
502                        $indi           = [];
503                        $fam            = [];
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->indi, $indi, true) && !in_array($row->fams, $fam, true)) {
508                                    $this->fillYData($row->month, $boundary, 1, $x_axis, $z_axis, $ydata);
509                                }
510                                $indi[] = $row->indi;
511                                $fam[]  = $row->fams;
512                            }
513                            $prev_boundary = $boundary + 1;
514                        }
515                        break;
516                    default:
517                        throw new NotFoundHttpException();
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 NotFoundHttpException();
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 NotFoundHttpException();
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 NotFoundHttpException();
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 NotFoundHttpException();
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 NotFoundHttpException();
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 NotFoundHttpException();
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 NotFoundHttpException();
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 NotFoundHttpException();
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 NotFoundHttpException();
762                break;
763        }
764    }
765
766    /**
767     * @return string[]
768     */
769    private function axisAll(): array
770    {
771        return [
772            I18N::translate('Total'),
773        ];
774    }
775
776    /**
777     * @return 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 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 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
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 approprate range.
909     *
910     * @param int|float|string $value
911     * @param string[]         $axis
912     *
913     * @return int|string
914     */
915    private function findAxisEntry($value, $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(
1035            'statistics/other/charts/custom',
1036            [
1037                'data'          => $data,
1038                'chart_options' => $chart_options,
1039                'chart_title'   => $chart_title,
1040            ]
1041        );
1042    }
1043}
1044