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