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