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