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