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