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