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