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