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