xref: /webtrees/app/Module/StatisticsChartModule.php (revision 73b8c04073f4aae42af235f4485847d0a07c8f8c)
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\Exceptions\HttpNotFoundException;
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;
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, ModuleChartInterface::class, $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            'tree'   => $tree,
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 postCustomChartAction(ServerRequestInterface $request): ResponseInterface
250    {
251        $statistics = app(Statistics::class);
252        assert($statistics instanceof Statistics);
253
254        $params = (array) $request->getParsedBody();
255
256        $x_axis_type = (int) $params['x-as'];
257        $y_axis_type = (int) $params['y-as'];
258        $z_axis_type = (int) $params['z-as'];
259        $ydata       = [];
260
261        switch ($x_axis_type) {
262            case self::X_AXIS_INDIVIDUAL_MAP:
263                return response($statistics->chartDistribution(
264                    $params['chart_shows'],
265                    $params['chart_type'],
266                    $params['SURN']
267                ));
268
269            case self::X_AXIS_BIRTH_MAP:
270                return response($statistics->chartDistribution(
271                    $params['chart_shows'],
272                    'birth_distribution_chart'
273                ));
274
275            case self::X_AXIS_DEATH_MAP:
276                return response($statistics->chartDistribution(
277                    $params['chart_shows'],
278                    'death_distribution_chart'
279                ));
280
281            case self::X_AXIS_MARRIAGE_MAP:
282                return response($statistics->chartDistribution(
283                    $params['chart_shows'],
284                    'marriage_distribution_chart'
285                ));
286
287            case self::X_AXIS_BIRTH_MONTH:
288                $chart_title  = I18N::translate('Month of birth');
289                $x_axis_title = I18N::translate('Month');
290                $x_axis       = $this->axisMonths();
291
292                switch ($y_axis_type) {
293                    case self::Y_AXIS_NUMBERS:
294                        $y_axis_title = I18N::translate('Individuals');
295                        break;
296                    case self::Y_AXIS_PERCENT:
297                        $y_axis_title = '%';
298                        break;
299                    default:
300                        throw new HttpNotFoundException();
301                }
302
303                switch ($z_axis_type) {
304                    case self::Z_AXIS_ALL:
305                        $z_axis = $this->axisAll();
306                        $rows   = $statistics->statsBirthQuery()->get();
307                        foreach ($rows as $row) {
308                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
309                        }
310                        break;
311                    case self::Z_AXIS_SEX:
312                        $z_axis = $this->axisSexes();
313                        $rows   = $statistics->statsBirthBySexQuery()->get();
314                        foreach ($rows as $row) {
315                            $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata);
316                        }
317                        break;
318                    case self::Z_AXIS_TIME:
319                        $boundaries_csv = $params['z-axis-boundaries-periods'];
320                        $z_axis         = $this->axisYears($boundaries_csv);
321                        $prev_boundary  = 0;
322                        foreach (array_keys($z_axis) as $boundary) {
323                            $rows = $statistics->statsBirthQuery($prev_boundary, $boundary)->get();
324                            foreach ($rows as $row) {
325                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
326                            }
327                            $prev_boundary = $boundary + 1;
328                        }
329                        break;
330                    default:
331                        throw new HttpNotFoundException();
332                }
333
334                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
335
336            case self::X_AXIS_DEATH_MONTH:
337                $chart_title  = I18N::translate('Month of death');
338                $x_axis_title = I18N::translate('Month');
339                $x_axis       = $this->axisMonths();
340
341                switch ($y_axis_type) {
342                    case self::Y_AXIS_NUMBERS:
343                        $y_axis_title = I18N::translate('Individuals');
344                        break;
345                    case self::Y_AXIS_PERCENT:
346                        $y_axis_title = '%';
347                        break;
348                    default:
349                        throw new HttpNotFoundException();
350                }
351
352                switch ($z_axis_type) {
353                    case self::Z_AXIS_ALL:
354                        $z_axis = $this->axisAll();
355                        $rows   = $statistics->statsDeathQuery()->get();
356                        foreach ($rows as $row) {
357                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
358                        }
359                        break;
360                    case self::Z_AXIS_SEX:
361                        $z_axis = $this->axisSexes();
362                        $rows   = $statistics->statsDeathBySexQuery()->get();
363                        foreach ($rows as $row) {
364                            $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata);
365                        }
366                        break;
367                    case self::Z_AXIS_TIME:
368                        $boundaries_csv = $params['z-axis-boundaries-periods'];
369                        $z_axis         = $this->axisYears($boundaries_csv);
370                        $prev_boundary  = 0;
371                        foreach (array_keys($z_axis) as $boundary) {
372                            $rows = $statistics->statsDeathQuery($prev_boundary, $boundary)->get();
373                            foreach ($rows as $row) {
374                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
375                            }
376                            $prev_boundary = $boundary + 1;
377                        }
378                        break;
379                    default:
380                        throw new HttpNotFoundException();
381                }
382
383                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
384
385            case self::X_AXIS_MARRIAGE_MONTH:
386                $chart_title  = I18N::translate('Month of marriage');
387                $x_axis_title = I18N::translate('Month');
388                $x_axis       = $this->axisMonths();
389
390                switch ($y_axis_type) {
391                    case self::Y_AXIS_NUMBERS:
392                        $y_axis_title = I18N::translate('Families');
393                        break;
394                    case self::Y_AXIS_PERCENT:
395                        $y_axis_title = '%';
396                        break;
397                    default:
398                        throw new HttpNotFoundException();
399                }
400
401                switch ($z_axis_type) {
402                    case self::Z_AXIS_ALL:
403                        $z_axis = $this->axisAll();
404                        $rows   = $statistics->statsMarriageQuery()->get();
405                        foreach ($rows as $row) {
406                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
407                        }
408                        break;
409                    case self::Z_AXIS_TIME:
410                        $boundaries_csv = $params['z-axis-boundaries-periods'];
411                        $z_axis         = $this->axisYears($boundaries_csv);
412                        $prev_boundary  = 0;
413                        foreach (array_keys($z_axis) as $boundary) {
414                            $rows = $statistics->statsMarriageQuery($prev_boundary, $boundary)->get();
415                            foreach ($rows as $row) {
416                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
417                            }
418                            $prev_boundary = $boundary + 1;
419                        }
420                        break;
421                    default:
422                        throw new HttpNotFoundException();
423                }
424
425                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
426
427            case self::X_AXIS_FIRST_CHILD_MONTH:
428                $chart_title  = I18N::translate('Month of birth of first child in a relation');
429                $x_axis_title = I18N::translate('Month');
430                $x_axis       = $this->axisMonths();
431
432                switch ($y_axis_type) {
433                    case self::Y_AXIS_NUMBERS:
434                        $y_axis_title = I18N::translate('Children');
435                        break;
436                    case self::Y_AXIS_PERCENT:
437                        $y_axis_title = '%';
438                        break;
439                    default:
440                        throw new HttpNotFoundException();
441                }
442
443                switch ($z_axis_type) {
444                    case self::Z_AXIS_ALL:
445                        $z_axis = $this->axisAll();
446                        $rows   = $statistics->monthFirstChildQuery()->get();
447                        foreach ($rows as $row) {
448                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
449                        }
450                        break;
451                    case self::Z_AXIS_SEX:
452                        $z_axis = $this->axisSexes();
453                        $rows   = $statistics->monthFirstChildBySexQuery()->get();
454                        foreach ($rows as $row) {
455                            $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata);
456                        }
457                        break;
458                    case self::Z_AXIS_TIME:
459                        $boundaries_csv = $params['z-axis-boundaries-periods'];
460                        $z_axis         = $this->axisYears($boundaries_csv);
461                        $prev_boundary  = 0;
462                        foreach (array_keys($z_axis) as $boundary) {
463                            $rows = $statistics->monthFirstChildQuery($prev_boundary, $boundary)->get();
464                            foreach ($rows as $row) {
465                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
466                            }
467                            $prev_boundary = $boundary + 1;
468                        }
469                        break;
470                    default:
471                        throw new HttpNotFoundException();
472                }
473
474                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));
475
476            case self::X_AXIS_FIRST_MARRIAGE_MONTH:
477                $chart_title  = I18N::translate('Month of first marriage');
478                $x_axis_title = I18N::translate('Month');
479                $x_axis       = $this->axisMonths();
480
481                switch ($y_axis_type) {
482                    case self::Y_AXIS_NUMBERS:
483                        $y_axis_title = I18N::translate('Families');
484                        break;
485                    case self::Y_AXIS_PERCENT:
486                        $y_axis_title = '%';
487                        break;
488                    default:
489                        throw new HttpNotFoundException();
490                }
491
492                switch ($z_axis_type) {
493                    case self::Z_AXIS_ALL:
494                        $z_axis = $this->axisAll();
495                        $rows   = $statistics->statsFirstMarriageQuery()->get();
496                        $indi   = [];
497                        foreach ($rows as $row) {
498                            if (!in_array($row->f_husb, $indi, true) && !in_array($row->f_wife, $indi, true)) {
499                                $this->fillYData($row->month, 0, 1, $x_axis, $z_axis, $ydata);
500                            }
501                            $indi[]  = $row->f_husb;
502                            $indi[]  = $row->f_wife;
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                        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->f_husb, $indi, true) && !in_array($row->f_wife, $indi, true)) {
514                                    $this->fillYData($row->month, $boundary, 1, $x_axis, $z_axis, $ydata);
515                                }
516                                $indi[]  = $row->f_husb;
517                                $indi[]  = $row->f_wife;
518                            }
519                            $prev_boundary = $boundary + 1;
520                        }
521                        break;
522                    default:
523                        throw new HttpNotFoundException();
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 HttpNotFoundException();
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 HttpNotFoundException();
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 HttpNotFoundException();
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 HttpNotFoundException();
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 HttpNotFoundException();
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 HttpNotFoundException();
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 HttpNotFoundException();
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 HttpNotFoundException();
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 HttpNotFoundException();
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<string>
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 appropriate 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('statistics/other/charts/custom', [
1041            'data'          => $data,
1042            'chart_options' => $chart_options,
1043            'chart_title'   => $chart_title,
1044            'language'      => I18N::languageTag(),
1045        ]);
1046    }
1047}
1048