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