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