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