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