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