fisharebest/webtrees

View on GitHub
app/Module/StatisticsChartModule.php

Summary

Maintainability
F
2 wks
Test Coverage
<?php

/**
 * webtrees: online genealogy
 * Copyright (C) 2023 webtrees development team
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace Fisharebest\Webtrees\Module;

use Fisharebest\Webtrees\Auth;
use Fisharebest\Webtrees\Http\Exceptions\HttpNotFoundException;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Statistics;
use Fisharebest\Webtrees\Validator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

use function array_key_exists;
use function array_keys;
use function array_map;
use function array_merge;
use function array_sum;
use function array_values;
use function array_walk;
use function assert;
use function count;
use function explode;
use function in_array;
use function intdiv;
use function is_numeric;
use function sprintf;

/**
 * Class StatisticsChartModule
 */
class StatisticsChartModule extends AbstractModule implements ModuleChartInterface
{
    use ModuleChartTrait;

    public const X_AXIS_INDIVIDUAL_MAP        = 1;
    public const X_AXIS_BIRTH_MAP             = 2;
    public const X_AXIS_DEATH_MAP             = 3;
    public const X_AXIS_MARRIAGE_MAP          = 4;
    public const X_AXIS_BIRTH_MONTH           = 11;
    public const X_AXIS_DEATH_MONTH           = 12;
    public const X_AXIS_MARRIAGE_MONTH        = 13;
    public const X_AXIS_FIRST_CHILD_MONTH     = 14;
    public const X_AXIS_FIRST_MARRIAGE_MONTH  = 15;
    public const X_AXIS_AGE_AT_DEATH          = 18;
    public const X_AXIS_AGE_AT_MARRIAGE       = 19;
    public const X_AXIS_AGE_AT_FIRST_MARRIAGE = 20;
    public const X_AXIS_NUMBER_OF_CHILDREN    = 21;

    public const Y_AXIS_NUMBERS = 201;
    public const Y_AXIS_PERCENT = 202;

    public const Z_AXIS_ALL  = 300;
    public const Z_AXIS_SEX  = 301;
    public const Z_AXIS_TIME = 302;

    // First two colors are blue/pink, to work with Z_AXIS_SEX.
    private const Z_AXIS_COLORS = ['0000FF', 'FFA0CB', '9F00FF', 'FF7000', '905030', 'FF0000', '00FF00', 'F0F000'];

    private const DAYS_IN_YEAR = 365.25;

    /**
     * How should this module be identified in the control panel, etc.?
     *
     * @return string
     */
    public function title(): string
    {
        /* I18N: Name of a module/chart */
        return I18N::translate('Statistics');
    }

    /**
     * A sentence describing what this module does.
     *
     * @return string
     */
    public function description(): string
    {
        /* I18N: Description of the “StatisticsChart” module */
        return I18N::translate('Various statistics charts.');
    }

    /**
     * CSS class for the URL.
     *
     * @return string
     */
    public function chartMenuClass(): string
    {
        return 'menu-chart-statistics';
    }

    /**
     * The URL for this chart.
     *
     * @param Individual                                $individual
     * @param array<bool|int|string|array<string>|null> $parameters
     *
     * @return string
     */
    public function chartUrl(Individual $individual, array $parameters = []): string
    {
        return route('module', [
                'module' => $this->name(),
                'action' => 'Chart',
                'tree'    => $individual->tree()->name(),
            ] + $parameters);
    }

    /**
     * A form to request the chart parameters.
     *
     * @param ServerRequestInterface $request
     *
     * @return ResponseInterface
     */
    public function getChartAction(ServerRequestInterface $request): ResponseInterface
    {
        $tree = Validator::attributes($request)->tree();
        $user = Validator::attributes($request)->user();

        Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user);

        $tabs = [
            I18N::translate('Individuals') => route('module', [
                'module' => $this->name(),
                'action' => 'Individuals',
                'tree'    => $tree->name(),
            ]),
            I18N::translate('Families')    => route('module', [
                'module' => $this->name(),
                'action' => 'Families',
                'tree'    => $tree->name(),
            ]),
            I18N::translate('Other')       => route('module', [
                'module' => $this->name(),
                'action' => 'Other',
                'tree'    => $tree->name(),
            ]),
            I18N::translate('Custom')      => route('module', [
                'module' => $this->name(),
                'action' => 'Custom',
                'tree'    => $tree->name(),
            ]),
        ];

        return $this->viewResponse('modules/statistics-chart/page', [
            'module' => $this->name(),
            'tabs'   => $tabs,
            'title'  => $this->title(),
            'tree'   => $tree,
        ]);
    }

    /**
     * @param ServerRequestInterface $request
     *
     * @return ResponseInterface
     */
    public function getIndividualsAction(ServerRequestInterface $request): ResponseInterface
    {
        $this->layout = 'layouts/ajax';

        return $this->viewResponse('modules/statistics-chart/individuals', [
            'show_oldest_living' => Auth::check(),
            'statistics'         => Registry::container()->get(Statistics::class),
        ]);
    }

    /**
     * @param ServerRequestInterface $request
     *
     * @return ResponseInterface
     */
    public function getFamiliesAction(ServerRequestInterface $request): ResponseInterface
    {
        $this->layout = 'layouts/ajax';

        return $this->viewResponse('modules/statistics-chart/families', [
            'statistics' => Registry::container()->get(Statistics::class),
        ]);
    }

    /**
     * @param ServerRequestInterface $request
     *
     * @return ResponseInterface
     */
    public function getOtherAction(ServerRequestInterface $request): ResponseInterface
    {
        $this->layout = 'layouts/ajax';

        return $this->viewResponse('modules/statistics-chart/other', [
            'statistics' => Registry::container()->get(Statistics::class),
        ]);
    }

    /**
     * @param ServerRequestInterface $request
     *
     * @return ResponseInterface
     */
    public function getCustomAction(ServerRequestInterface $request): ResponseInterface
    {
        $this->layout = 'layouts/ajax';

        $tree = Validator::attributes($request)->tree();

        return $this->viewResponse('modules/statistics-chart/custom', [
            'module' => $this,
            'tree'   => $tree,
        ]);
    }

    /**
     * @param ServerRequestInterface $request
     *
     * @return ResponseInterface
     */
    public function postCustomChartAction(ServerRequestInterface $request): ResponseInterface
    {
        $statistics = Registry::container()->get(Statistics::class);
        assert($statistics instanceof Statistics);

        $x_axis_type = Validator::parsedBody($request)->integer('x-as');
        $y_axis_type = Validator::parsedBody($request)->integer('y-as');
        $z_axis_type = Validator::parsedBody($request)->integer('z-as');
        $ydata       = [];

        switch ($x_axis_type) {
            case self::X_AXIS_INDIVIDUAL_MAP:
                return response($statistics->chartDistribution(
                    Validator::parsedBody($request)->string('chart_shows'),
                    Validator::parsedBody($request)->string('chart_type'),
                    Validator::parsedBody($request)->string('SURN')
                ));

            case self::X_AXIS_BIRTH_MAP:
                return response($statistics->chartDistribution(
                    Validator::parsedBody($request)->string('chart_shows'),
                    'birth_distribution_chart'
                ));

            case self::X_AXIS_DEATH_MAP:
                return response($statistics->chartDistribution(
                    Validator::parsedBody($request)->string('chart_shows'),
                    'death_distribution_chart'
                ));

            case self::X_AXIS_MARRIAGE_MAP:
                return response($statistics->chartDistribution(
                    Validator::parsedBody($request)->string('chart_shows'),
                    'marriage_distribution_chart'
                ));

            case self::X_AXIS_BIRTH_MONTH:
                $chart_title  = I18N::translate('Month of birth');
                $x_axis_title = I18N::translate('Month');
                $x_axis       = $this->axisMonths();

                switch ($y_axis_type) {
                    case self::Y_AXIS_NUMBERS:
                        $y_axis_title = I18N::translate('Individuals');
                        break;
                    case self::Y_AXIS_PERCENT:
                        $y_axis_title = '%';
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                switch ($z_axis_type) {
                    case self::Z_AXIS_ALL:
                        $z_axis = $this->axisAll();
                        $rows   = $statistics->statsBirthQuery()->get();
                        foreach ($rows as $row) {
                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
                        }
                        break;
                    case self::Z_AXIS_SEX:
                        $z_axis = $this->axisSexes();
                        $rows   = $statistics->statsBirthBySexQuery()->get();
                        foreach ($rows as $row) {
                            $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata);
                        }
                        break;
                    case self::Z_AXIS_TIME:
                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
                        $z_axis         = $this->axisYears($boundaries_csv);
                        $prev_boundary  = 0;
                        foreach (array_keys($z_axis) as $boundary) {
                            $rows = $statistics->statsBirthQuery($prev_boundary, $boundary)->get();
                            foreach ($rows as $row) {
                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
                            }
                            $prev_boundary = $boundary + 1;
                        }
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));

            case self::X_AXIS_DEATH_MONTH:
                $chart_title  = I18N::translate('Month of death');
                $x_axis_title = I18N::translate('Month');
                $x_axis       = $this->axisMonths();

                switch ($y_axis_type) {
                    case self::Y_AXIS_NUMBERS:
                        $y_axis_title = I18N::translate('Individuals');
                        break;
                    case self::Y_AXIS_PERCENT:
                        $y_axis_title = '%';
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                switch ($z_axis_type) {
                    case self::Z_AXIS_ALL:
                        $z_axis = $this->axisAll();
                        $rows   = $statistics->statsDeathQuery()->get();
                        foreach ($rows as $row) {
                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
                        }
                        break;
                    case self::Z_AXIS_SEX:
                        $z_axis = $this->axisSexes();
                        $rows   = $statistics->statsDeathBySexQuery()->get();
                        foreach ($rows as $row) {
                            $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata);
                        }
                        break;
                    case self::Z_AXIS_TIME:
                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
                        $z_axis         = $this->axisYears($boundaries_csv);
                        $prev_boundary  = 0;
                        foreach (array_keys($z_axis) as $boundary) {
                            $rows = $statistics->statsDeathQuery($prev_boundary, $boundary)->get();
                            foreach ($rows as $row) {
                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
                            }
                            $prev_boundary = $boundary + 1;
                        }
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));

            case self::X_AXIS_MARRIAGE_MONTH:
                $chart_title  = I18N::translate('Month of marriage');
                $x_axis_title = I18N::translate('Month');
                $x_axis       = $this->axisMonths();

                switch ($y_axis_type) {
                    case self::Y_AXIS_NUMBERS:
                        $y_axis_title = I18N::translate('Families');
                        break;
                    case self::Y_AXIS_PERCENT:
                        $y_axis_title = '%';
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                switch ($z_axis_type) {
                    case self::Z_AXIS_ALL:
                        $z_axis = $this->axisAll();
                        $rows   = $statistics->statsMarriageQuery()->get();
                        foreach ($rows as $row) {
                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
                        }
                        break;
                    case self::Z_AXIS_TIME:
                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
                        $z_axis         = $this->axisYears($boundaries_csv);
                        $prev_boundary  = 0;
                        foreach (array_keys($z_axis) as $boundary) {
                            $rows = $statistics->statsMarriageQuery($prev_boundary, $boundary)->get();
                            foreach ($rows as $row) {
                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
                            }
                            $prev_boundary = $boundary + 1;
                        }
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));

            case self::X_AXIS_FIRST_CHILD_MONTH:
                $chart_title  = I18N::translate('Month of birth of first child in a relation');
                $x_axis_title = I18N::translate('Month');
                $x_axis       = $this->axisMonths();

                switch ($y_axis_type) {
                    case self::Y_AXIS_NUMBERS:
                        $y_axis_title = I18N::translate('Children');
                        break;
                    case self::Y_AXIS_PERCENT:
                        $y_axis_title = '%';
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                switch ($z_axis_type) {
                    case self::Z_AXIS_ALL:
                        $z_axis = $this->axisAll();
                        $rows   = $statistics->monthFirstChildQuery()->get();
                        foreach ($rows as $row) {
                            $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata);
                        }
                        break;
                    case self::Z_AXIS_SEX:
                        $z_axis = $this->axisSexes();
                        $rows   = $statistics->monthFirstChildBySexQuery()->get();
                        foreach ($rows as $row) {
                            $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata);
                        }
                        break;
                    case self::Z_AXIS_TIME:
                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
                        $z_axis         = $this->axisYears($boundaries_csv);
                        $prev_boundary  = 0;
                        foreach (array_keys($z_axis) as $boundary) {
                            $rows = $statistics->monthFirstChildQuery($prev_boundary, $boundary)->get();
                            foreach ($rows as $row) {
                                $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata);
                            }
                            $prev_boundary = $boundary + 1;
                        }
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));

            case self::X_AXIS_FIRST_MARRIAGE_MONTH:
                $chart_title  = I18N::translate('Month of first marriage');
                $x_axis_title = I18N::translate('Month');
                $x_axis       = $this->axisMonths();

                switch ($y_axis_type) {
                    case self::Y_AXIS_NUMBERS:
                        $y_axis_title = I18N::translate('Families');
                        break;
                    case self::Y_AXIS_PERCENT:
                        $y_axis_title = '%';
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                switch ($z_axis_type) {
                    case self::Z_AXIS_ALL:
                        $z_axis = $this->axisAll();
                        $rows   = $statistics->statsFirstMarriageQuery()->get();
                        $indi   = [];
                        foreach ($rows as $row) {
                            if (!in_array($row->f_husb, $indi, true) && !in_array($row->f_wife, $indi, true)) {
                                $this->fillYData($row->month, 0, 1, $x_axis, $z_axis, $ydata);
                            }
                            $indi[]  = $row->f_husb;
                            $indi[]  = $row->f_wife;
                        }
                        break;
                    case self::Z_AXIS_TIME:
                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
                        $z_axis         = $this->axisYears($boundaries_csv);
                        $prev_boundary  = 0;
                        $indi           = [];
                        foreach (array_keys($z_axis) as $boundary) {
                            $rows = $statistics->statsFirstMarriageQuery($prev_boundary, $boundary)->get();
                            foreach ($rows as $row) {
                                if (!in_array($row->f_husb, $indi, true) && !in_array($row->f_wife, $indi, true)) {
                                    $this->fillYData($row->month, $boundary, 1, $x_axis, $z_axis, $ydata);
                                }
                                $indi[]  = $row->f_husb;
                                $indi[]  = $row->f_wife;
                            }
                            $prev_boundary = $boundary + 1;
                        }
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));

            case self::X_AXIS_AGE_AT_DEATH:
                $chart_title    = I18N::translate('Average age at death');
                $x_axis_title   = I18N::translate('age');
                $boundaries_csv = Validator::parsedBody($request)->string('x-axis-boundaries-ages');
                $x_axis         = $this->axisNumbers($boundaries_csv);

                switch ($y_axis_type) {
                    case self::Y_AXIS_NUMBERS:
                        $y_axis_title = I18N::translate('Individuals');
                        break;
                    case self::Y_AXIS_PERCENT:
                        $y_axis_title = '%';
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                switch ($z_axis_type) {
                    case self::Z_AXIS_ALL:
                        $z_axis = $this->axisAll();
                        $rows   = $statistics->statsAgeQuery('DEAT');
                        foreach ($rows as $row) {
                            foreach ($row as $age) {
                                $years = (int) ($age / self::DAYS_IN_YEAR);
                                $this->fillYData($years, 0, 1, $x_axis, $z_axis, $ydata);
                            }
                        }
                        break;
                    case self::Z_AXIS_SEX:
                        $z_axis = $this->axisSexes();
                        foreach (array_keys($z_axis) as $sex) {
                            $rows = $statistics->statsAgeQuery('DEAT', $sex);
                            foreach ($rows as $row) {
                                foreach ($row as $age) {
                                    $years = (int) ($age / self::DAYS_IN_YEAR);
                                    $this->fillYData($years, $sex, 1, $x_axis, $z_axis, $ydata);
                                }
                            }
                        }
                        break;
                    case self::Z_AXIS_TIME:
                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
                        $z_axis         = $this->axisYears($boundaries_csv);
                        $prev_boundary  = 0;
                        foreach (array_keys($z_axis) as $boundary) {
                            $rows = $statistics->statsAgeQuery('DEAT', 'BOTH', $prev_boundary, $boundary);
                            foreach ($rows as $row) {
                                foreach ($row as $age) {
                                    $years = (int) ($age / self::DAYS_IN_YEAR);
                                    $this->fillYData($years, $boundary, 1, $x_axis, $z_axis, $ydata);
                                }
                            }
                            $prev_boundary = $boundary + 1;
                        }

                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));

            case self::X_AXIS_AGE_AT_MARRIAGE:
                $chart_title    = I18N::translate('Age in year of marriage');
                $x_axis_title   = I18N::translate('age');
                $boundaries_csv = Validator::parsedBody($request)->string('x-axis-boundaries-ages_m');
                $x_axis         = $this->axisNumbers($boundaries_csv);

                switch ($y_axis_type) {
                    case self::Y_AXIS_NUMBERS:
                        $y_axis_title = I18N::translate('Individuals');
                        break;
                    case self::Y_AXIS_PERCENT:
                        $y_axis_title = '%';
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                switch ($z_axis_type) {
                    case self::Z_AXIS_ALL:
                        $z_axis = $this->axisAll();
                        // The stats query doesn't have an "all" function, so query M/F separately
                        foreach (['M', 'F'] as $sex) {
                            $rows = $statistics->statsMarrAgeQuery($sex);
                            foreach ($rows as $row) {
                                $years = (int) ($row->age / self::DAYS_IN_YEAR);
                                $this->fillYData($years, 0, 1, $x_axis, $z_axis, $ydata);
                            }
                        }
                        break;
                    case self::Z_AXIS_SEX:
                        $z_axis = $this->axisSexes();
                        foreach (array_keys($z_axis) as $sex) {
                            $rows = $statistics->statsMarrAgeQuery($sex);
                            foreach ($rows as $row) {
                                $years = (int) ($row->age / self::DAYS_IN_YEAR);
                                $this->fillYData($years, $sex, 1, $x_axis, $z_axis, $ydata);
                            }
                        }
                        break;
                    case self::Z_AXIS_TIME:
                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
                        $z_axis         = $this->axisYears($boundaries_csv);
                        // The stats query doesn't have an "all" function, so query M/F separately
                        foreach (['M', 'F'] as $sex) {
                            $prev_boundary = 0;
                            foreach (array_keys($z_axis) as $boundary) {
                                $rows = $statistics->statsMarrAgeQuery($sex, $prev_boundary, $boundary);
                                foreach ($rows as $row) {
                                    $years = (int) ($row->age / self::DAYS_IN_YEAR);
                                    $this->fillYData($years, $boundary, 1, $x_axis, $z_axis, $ydata);
                                }
                                $prev_boundary = $boundary + 1;
                            }
                        }
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));

            case self::X_AXIS_AGE_AT_FIRST_MARRIAGE:
                $chart_title    = I18N::translate('Age in year of first marriage');
                $x_axis_title   = I18N::translate('age');
                $boundaries_csv = Validator::parsedBody($request)->string('x-axis-boundaries-ages_m');
                $x_axis         = $this->axisNumbers($boundaries_csv);

                switch ($y_axis_type) {
                    case self::Y_AXIS_NUMBERS:
                        $y_axis_title = I18N::translate('Individuals');
                        break;
                    case self::Y_AXIS_PERCENT:
                        $y_axis_title = '%';
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                switch ($z_axis_type) {
                    case self::Z_AXIS_ALL:
                        $z_axis = $this->axisAll();
                        // The stats query doesn't have an "all" function, so query M/F separately
                        foreach (['M', 'F'] as $sex) {
                            $rows = $statistics->statsMarrAgeQuery($sex);
                            $indi = [];
                            foreach ($rows as $row) {
                                if (!in_array($row->d_gid, $indi, true)) {
                                    $years = (int) ($row->age / self::DAYS_IN_YEAR);
                                    $this->fillYData($years, 0, 1, $x_axis, $z_axis, $ydata);
                                    $indi[] = $row->d_gid;
                                }
                            }
                        }
                        break;
                    case self::Z_AXIS_SEX:
                        $z_axis = $this->axisSexes();
                        foreach (array_keys($z_axis) as $sex) {
                            $rows = $statistics->statsMarrAgeQuery($sex);
                            $indi = [];
                            foreach ($rows as $row) {
                                if (!in_array($row->d_gid, $indi, true)) {
                                    $years = (int) ($row->age / self::DAYS_IN_YEAR);
                                    $this->fillYData($years, $sex, 1, $x_axis, $z_axis, $ydata);
                                    $indi[] = $row->d_gid;
                                }
                            }
                        }
                        break;
                    case self::Z_AXIS_TIME:
                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
                        $z_axis         = $this->axisYears($boundaries_csv);
                        // The stats query doesn't have an "all" function, so query M/F separately
                        foreach (['M', 'F'] as $sex) {
                            $prev_boundary = 0;
                            $indi          = [];
                            foreach (array_keys($z_axis) as $boundary) {
                                $rows = $statistics->statsMarrAgeQuery($sex, $prev_boundary, $boundary);
                                foreach ($rows as $row) {
                                    if (!in_array($row->d_gid, $indi, true)) {
                                        $years = (int) ($row->age / self::DAYS_IN_YEAR);
                                        $this->fillYData($years, $boundary, 1, $x_axis, $z_axis, $ydata);
                                        $indi[] = $row->d_gid;
                                    }
                                }
                                $prev_boundary = $boundary + 1;
                            }
                        }
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));

            case self::X_AXIS_NUMBER_OF_CHILDREN:
                $chart_title  = I18N::translate('Number of children');
                $x_axis_title = I18N::translate('Children');
                $x_axis       = $this->axisNumbers('0,1,2,3,4,5,6,7,8,9,10');

                switch ($y_axis_type) {
                    case self::Y_AXIS_NUMBERS:
                        $y_axis_title = I18N::translate('Families');
                        break;
                    case self::Y_AXIS_PERCENT:
                        $y_axis_title = '%';
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                switch ($z_axis_type) {
                    case self::Z_AXIS_ALL:
                        $z_axis = $this->axisAll();
                        $rows   = $statistics->statsChildrenQuery();
                        foreach ($rows as $row) {
                            $this->fillYData($row->f_numchil, 0, $row->total, $x_axis, $z_axis, $ydata);
                        }
                        break;
                    case self::Z_AXIS_TIME:
                        $boundaries_csv = Validator::parsedBody($request)->string('z-axis-boundaries-periods');
                        $z_axis         = $this->axisYears($boundaries_csv);
                        $prev_boundary  = 0;
                        foreach (array_keys($z_axis) as $boundary) {
                            $rows = $statistics->statsChildrenQuery($prev_boundary, $boundary);
                            foreach ($rows as $row) {
                                $this->fillYData($row->f_numchil, $boundary, $row->total, $x_axis, $z_axis, $ydata);
                            }
                            $prev_boundary = $boundary + 1;
                        }
                        break;
                    default:
                        throw new HttpNotFoundException();
                }

                return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type));

            default:
                throw new HttpNotFoundException();
        }
    }

    /**
     * @return array<string>
     */
    private function axisAll(): array
    {
        return [
            I18N::translate('Total'),
        ];
    }

    /**
     * @return array<string>
     */
    private function axisSexes(): array
    {
        return [
            'M' => I18N::translate('Male'),
            'F' => I18N::translate('Female'),
        ];
    }

    /**
     * Labels for the X axis
     *
     * @return array<string>
     */
    private function axisMonths(): array
    {
        return [
            'JAN' => I18N::translateContext('NOMINATIVE', 'January'),
            'FEB' => I18N::translateContext('NOMINATIVE', 'February'),
            'MAR' => I18N::translateContext('NOMINATIVE', 'March'),
            'APR' => I18N::translateContext('NOMINATIVE', 'April'),
            'MAY' => I18N::translateContext('NOMINATIVE', 'May'),
            'JUN' => I18N::translateContext('NOMINATIVE', 'June'),
            'JUL' => I18N::translateContext('NOMINATIVE', 'July'),
            'AUG' => I18N::translateContext('NOMINATIVE', 'August'),
            'SEP' => I18N::translateContext('NOMINATIVE', 'September'),
            'OCT' => I18N::translateContext('NOMINATIVE', 'October'),
            'NOV' => I18N::translateContext('NOMINATIVE', 'November'),
            'DEC' => I18N::translateContext('NOMINATIVE', 'December'),
        ];
    }

    /**
     * Convert a list of N year-boundaries into N+1 year-ranges for the z-axis.
     *
     * @param string $boundaries_csv
     *
     * @return array<string>
     */
    private function axisYears(string $boundaries_csv): array
    {
        $boundaries = explode(',', $boundaries_csv);

        $axis = [];
        foreach ($boundaries as $n => $boundary) {
            if ($n === 0) {
                $axis[$boundary - 1] = '–' . I18N::digits($boundary);
            } else {
                $axis[$boundary - 1] = I18N::digits($boundaries[$n - 1]) . '–' . I18N::digits($boundary);
            }
        }

        $axis[PHP_INT_MAX] = I18N::digits($boundaries[count($boundaries) - 1]) . '–';

        return $axis;
    }

    /**
     * Create the X axis.
     *
     * @param string $boundaries_csv
     *
     * @return array<string>
     */
    private function axisNumbers(string $boundaries_csv): array
    {
        $boundaries = explode(',', $boundaries_csv);

        $boundaries = array_map(static fn (string $x): int => (int) $x, $boundaries);

        $axis = [];
        foreach ($boundaries as $n => $boundary) {
            if ($n === 0) {
                $prev_boundary = 0;
            } else {
                $prev_boundary = $boundaries[$n - 1] + 1;
            }

            if ($prev_boundary === $boundary) {
                /* I18N: A range of numbers */
                $axis[$boundary] = I18N::number($boundary);
            } else {
                /* I18N: A range of numbers */
                $axis[$boundary] = I18N::translate('%1$s–%2$s', I18N::number($prev_boundary), I18N::number($boundary));
            }
        }

        /* I18N: Label on a graph; 40+ means 40 or more */
        $axis[PHP_INT_MAX] = I18N::translate('%s+', I18N::number($boundaries[count($boundaries) - 1]));

        return $axis;
    }

    /**
     * Calculate the Y axis.
     *
     * @param int|string        $x
     * @param int|string        $z
     * @param int|string        $value
     * @param array<string>     $x_axis
     * @param array<string>     $z_axis
     * @param array<array<int>> $ydata
     *
     * @return void
     */
    private function fillYData($x, $z, $value, array $x_axis, array $z_axis, array &$ydata): void
    {
        $x = $this->findAxisEntry($x, $x_axis);
        $z = $this->findAxisEntry($z, $z_axis);

        if (!array_key_exists($z, $z_axis)) {
            foreach (array_keys($z_axis) as $key) {
                if ($value <= $key) {
                    $z = $key;
                    break;
                }
            }
        }

        // Add the value to the appropriate data point.
        $ydata[$z][$x] = ($ydata[$z][$x] ?? 0) + $value;
    }

    /**
     * Find the axis entry for a given value.
     * Some are direct lookup (e.g. M/F, JAN/FEB/MAR).
     * Others need to find the appropriate range.
     *
     * @param int|string    $value
     * @param array<string> $axis
     *
     * @return int|string
     */
    private function findAxisEntry($value, array $axis)
    {
        if (is_numeric($value)) {
            $value = (int) $value;

            if (!array_key_exists($value, $axis)) {
                foreach (array_keys($axis) as $boundary) {
                    if ($value <= $boundary) {
                        $value = $boundary;
                        break;
                    }
                }
            }
        }

        return $value;
    }

    /**
     * Plot the data.
     *
     * @param string            $chart_title
     * @param array<string>     $x_axis
     * @param string            $x_axis_title
     * @param array<array<int>> $ydata
     * @param string            $y_axis_title
     * @param array<string>     $z_axis
     * @param int               $y_axis_type
     *
     * @return string
     */
    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 {
        if (!count($ydata)) {
            return I18N::translate('This information is not available.');
        }

        // Colors for z-axis
        $colors = [];
        $index  = 0;
        while (count($colors) < count($ydata)) {
            $colors[] = self::Z_AXIS_COLORS[$index];
            $index    = ($index + 1) % count(self::Z_AXIS_COLORS);
        }

        // Convert our sparse dataset into a fixed-size array
        $tmp = [];
        foreach (array_keys($z_axis) as $z) {
            foreach (array_keys($x_axis) as $x) {
                $tmp[$z][$x] = $ydata[$z][$x] ?? 0;
            }
        }
        $ydata = $tmp;

        // Convert the chart data to percentage
        if ($y_axis_type === self::Y_AXIS_PERCENT) {
            // Normalise each (non-zero!) set of data to total 100%
            array_walk($ydata, static function (array &$x) {
                $sum = array_sum($x);
                if ($sum > 0) {
                    $x = array_map(static fn (float $y): float => $y * 100.0 / $sum, $x);
                }
            });
        }

        $data = [
            array_merge(
                [I18N::translate('Century')],
                array_values($z_axis)
            ),
        ];

        $intermediate = [];
        foreach ($ydata as $months) {
            foreach ($months as $month => $value) {
                $intermediate[$month][] = [
                    'v' => $value,
                    'f' => $y_axis_type === self::Y_AXIS_PERCENT ? sprintf('%.1f%%', $value) : $value,
                ];
            }
        }

        foreach ($intermediate as $key => $values) {
            $data[] = array_merge(
                [$x_axis[$key]],
                $values
            );
        }

        $chart_options = [
            'title'    => '',
            'subtitle' => '',
            'height'   => 400,
            'width'    => '100%',
            'legend'   => [
                'position'  => count($z_axis) > 1 ? 'right' : 'none',
                'alignment' => 'center',
            ],
            'tooltip'  => [
                'format' => '\'%\'',
            ],
            'vAxis'    => [
                'title' => $y_axis_title,
            ],
            'hAxis'    => [
                'title' => $x_axis_title,
            ],
            'colors'   => $colors,
        ];

        return view('statistics/other/charts/custom', [
            'data'          => $data,
            'chart_options' => $chart_options,
            'chart_title'   => $chart_title,
            'language'      => I18N::languageTag(),
        ]);
    }
}