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