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