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