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 'ged' => $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 'ged' => $tree->name() 152 ]), 153 I18N::translate('Families') => route('module', [ 154 'module' => $this->name(), 155 'action' => 'Families', 156 'ged' => $tree->name() 157 ]), 158 I18N::translate('Other') => route('module', [ 159 'module' => $this->name(), 160 'action' => 'Other', 161 'ged' => $tree->name() 162 ]), 163 I18N::translate('Custom') => route('module', [ 164 'module' => $this->name(), 165 'action' => 'Custom', 166 'ged' => $tree->name() 167 ]), 168 ]; 169 170 return $this->viewResponse('modules/statistics-chart/page', [ 171 'tabs' => $tabs, 172 'title' => $this->title(), 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 230 return $this->viewResponse('modules/statistics-chart/custom', [ 231 'module' => $this, 232 'tree' => $tree, 233 ]); 234 } 235 236 /** 237 * @param ServerRequestInterface $request 238 * 239 * @return ResponseInterface 240 */ 241 public function getCustomChartAction(ServerRequestInterface $request): ResponseInterface 242 { 243 $statistics = app(Statistics::class); 244 245 $params = $request->getQueryParams(); 246 247 $x_axis_type = (int) $params['x-as']; 248 $y_axis_type = (int) $params['y-as']; 249 $z_axis_type = (int) $params['z-as']; 250 $ydata = []; 251 252 switch ($x_axis_type) { 253 case self::X_AXIS_INDIVIDUAL_MAP: 254 return response($statistics->chartDistribution( 255 $params['chart_shows'], 256 $params['chart_type'], 257 $params['SURN'] 258 )); 259 260 case self::X_AXIS_BIRTH_MAP: 261 return response($statistics->chartDistribution( 262 $params['chart_shows'], 263 'birth_distribution_chart' 264 )); 265 266 case self::X_AXIS_DEATH_MAP: 267 return response($statistics->chartDistribution( 268 $params['chart_shows'], 269 'death_distribution_chart' 270 )); 271 272 case self::X_AXIS_MARRIAGE_MAP: 273 return response($statistics->chartDistribution( 274 $params['chart_shows'], 275 'marriage_distribution_chart' 276 )); 277 278 case self::X_AXIS_BIRTH_MONTH: 279 $chart_title = I18N::translate('Month of birth'); 280 $x_axis_title = I18N::translate('Month'); 281 $x_axis = $this->axisMonths(); 282 283 switch ($y_axis_type) { 284 case self::Y_AXIS_NUMBERS: 285 $y_axis_title = I18N::translate('Individuals'); 286 break; 287 case self::Y_AXIS_PERCENT: 288 $y_axis_title = '%'; 289 break; 290 default: 291 throw new NotFoundHttpException(); 292 } 293 294 switch ($z_axis_type) { 295 case self::Z_AXIS_ALL: 296 $z_axis = $this->axisAll(); 297 $rows = $statistics->statsBirthQuery()->get(); 298 foreach ($rows as $row) { 299 $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata); 300 } 301 break; 302 case self::Z_AXIS_SEX: 303 $z_axis = $this->axisSexes(); 304 $rows = $statistics->statsBirthBySexQuery()->get(); 305 foreach ($rows as $row) { 306 $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata); 307 } 308 break; 309 case self::Z_AXIS_TIME: 310 $boundaries_csv = $params['z-axis-boundaries-periods']; 311 $z_axis = $this->axisYears($boundaries_csv); 312 $prev_boundary = 0; 313 foreach (array_keys($z_axis) as $boundary) { 314 $rows = $statistics->statsBirthQuery($prev_boundary, $boundary)->get(); 315 foreach ($rows as $row) { 316 $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata); 317 } 318 $prev_boundary = $boundary + 1; 319 } 320 break; 321 default: 322 throw new NotFoundHttpException(); 323 } 324 325 return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type)); 326 327 case self::X_AXIS_DEATH_MONTH: 328 $chart_title = I18N::translate('Month of death'); 329 $x_axis_title = I18N::translate('Month'); 330 $x_axis = $this->axisMonths(); 331 332 switch ($y_axis_type) { 333 case self::Y_AXIS_NUMBERS: 334 $y_axis_title = I18N::translate('Individuals'); 335 break; 336 case self::Y_AXIS_PERCENT: 337 $y_axis_title = '%'; 338 break; 339 default: 340 throw new NotFoundHttpException(); 341 } 342 343 switch ($z_axis_type) { 344 case self::Z_AXIS_ALL: 345 $z_axis = $this->axisAll(); 346 $rows = $statistics->statsDeathQuery()->get(); 347 foreach ($rows as $row) { 348 $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata); 349 } 350 break; 351 case self::Z_AXIS_SEX: 352 $z_axis = $this->axisSexes(); 353 $rows = $statistics->statsDeathBySexQuery()->get(); 354 foreach ($rows as $row) { 355 $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata); 356 } 357 break; 358 case self::Z_AXIS_TIME: 359 $boundaries_csv = $params['z-axis-boundaries-periods']; 360 $z_axis = $this->axisYears($boundaries_csv); 361 $prev_boundary = 0; 362 foreach (array_keys($z_axis) as $boundary) { 363 $rows = $statistics->statsDeathQuery($prev_boundary, $boundary)->get(); 364 foreach ($rows as $row) { 365 $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata); 366 } 367 $prev_boundary = $boundary + 1; 368 } 369 break; 370 default: 371 throw new NotFoundHttpException(); 372 } 373 374 return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type)); 375 376 case self::X_AXIS_MARRIAGE_MONTH: 377 $chart_title = I18N::translate('Month of marriage'); 378 $x_axis_title = I18N::translate('Month'); 379 $x_axis = $this->axisMonths(); 380 381 switch ($y_axis_type) { 382 case self::Y_AXIS_NUMBERS: 383 $y_axis_title = I18N::translate('Families'); 384 break; 385 case self::Y_AXIS_PERCENT: 386 $y_axis_title = '%'; 387 break; 388 default: 389 throw new NotFoundHttpException(); 390 } 391 392 switch ($z_axis_type) { 393 case self::Z_AXIS_ALL: 394 $z_axis = $this->axisAll(); 395 $rows = $statistics->statsMarriageQuery()->get(); 396 foreach ($rows as $row) { 397 $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata); 398 } 399 break; 400 case self::Z_AXIS_TIME: 401 $boundaries_csv = $params['z-axis-boundaries-periods']; 402 $z_axis = $this->axisYears($boundaries_csv); 403 $prev_boundary = 0; 404 foreach (array_keys($z_axis) as $boundary) { 405 $rows = $statistics->statsMarriageQuery($prev_boundary, $boundary)->get(); 406 foreach ($rows as $row) { 407 $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata); 408 } 409 $prev_boundary = $boundary + 1; 410 } 411 break; 412 default: 413 throw new NotFoundHttpException(); 414 } 415 416 return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type)); 417 418 case self::X_AXIS_FIRST_CHILD_MONTH: 419 $chart_title = I18N::translate('Month of birth of first child in a relation'); 420 $x_axis_title = I18N::translate('Month'); 421 $x_axis = $this->axisMonths(); 422 423 switch ($y_axis_type) { 424 case self::Y_AXIS_NUMBERS: 425 $y_axis_title = I18N::translate('Children'); 426 break; 427 case self::Y_AXIS_PERCENT: 428 $y_axis_title = '%'; 429 break; 430 default: 431 throw new NotFoundHttpException(); 432 } 433 434 switch ($z_axis_type) { 435 case self::Z_AXIS_ALL: 436 $z_axis = $this->axisAll(); 437 $rows = $statistics->monthFirstChildQuery()->get(); 438 foreach ($rows as $row) { 439 $this->fillYData($row->d_month, 0, $row->total, $x_axis, $z_axis, $ydata); 440 } 441 break; 442 case self::Z_AXIS_SEX: 443 $z_axis = $this->axisSexes(); 444 $rows = $statistics->monthFirstChildBySexQuery()->get(); 445 foreach ($rows as $row) { 446 $this->fillYData($row->d_month, $row->i_sex, $row->total, $x_axis, $z_axis, $ydata); 447 } 448 break; 449 case self::Z_AXIS_TIME: 450 $boundaries_csv = $params['z-axis-boundaries-periods']; 451 $z_axis = $this->axisYears($boundaries_csv); 452 $prev_boundary = 0; 453 foreach (array_keys($z_axis) as $boundary) { 454 $rows = $statistics->monthFirstChildQuery($prev_boundary, $boundary)->get(); 455 foreach ($rows as $row) { 456 $this->fillYData($row->d_month, $boundary, $row->total, $x_axis, $z_axis, $ydata); 457 } 458 $prev_boundary = $boundary + 1; 459 } 460 break; 461 default: 462 throw new NotFoundHttpException(); 463 } 464 465 return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type)); 466 467 case self::X_AXIS_FIRST_MARRIAGE_MONTH: 468 $chart_title = I18N::translate('Month of first marriage'); 469 $x_axis_title = I18N::translate('Month'); 470 $x_axis = $this->axisMonths(); 471 472 switch ($y_axis_type) { 473 case self::Y_AXIS_NUMBERS: 474 $y_axis_title = I18N::translate('Families'); 475 break; 476 case self::Y_AXIS_PERCENT: 477 $y_axis_title = '%'; 478 break; 479 default: 480 throw new NotFoundHttpException(); 481 } 482 483 switch ($z_axis_type) { 484 case self::Z_AXIS_ALL: 485 $z_axis = $this->axisAll(); 486 $rows = $statistics->statsFirstMarriageQuery()->get(); 487 $indi = []; 488 $fam = []; 489 foreach ($rows as $row) { 490 if (!in_array($row->indi, $indi, true) && !in_array($row->fams, $fam, true)) { 491 $this->fillYData($row->month, 0, 1, $x_axis, $z_axis, $ydata); 492 } 493 $indi[] = $row->indi; 494 $fam[] = $row->fams; 495 } 496 break; 497 case self::Z_AXIS_TIME: 498 $boundaries_csv = $params['z-axis-boundaries-periods']; 499 $z_axis = $this->axisYears($boundaries_csv); 500 $prev_boundary = 0; 501 $indi = []; 502 $fam = []; 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->indi, $indi, true) && !in_array($row->fams, $fam, true)) { 507 $this->fillYData($row->month, $boundary, 1, $x_axis, $z_axis, $ydata); 508 } 509 $indi[] = $row->indi; 510 $fam[] = $row->fams; 511 } 512 $prev_boundary = $boundary + 1; 513 } 514 break; 515 default: 516 throw new NotFoundHttpException(); 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 NotFoundHttpException(); 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 NotFoundHttpException(); 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 NotFoundHttpException(); 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 NotFoundHttpException(); 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 NotFoundHttpException(); 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 NotFoundHttpException(); 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 NotFoundHttpException(); 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 NotFoundHttpException(); 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 NotFoundHttpException(); 761 break; 762 } 763 } 764 765 /** 766 * @return string[] 767 */ 768 private function axisAll(): array 769 { 770 return [ 771 I18N::translate('Total'), 772 ]; 773 } 774 775 /** 776 * @return string[] 777 */ 778 private function axisSexes(): array 779 { 780 return [ 781 'M' => I18N::translate('Male'), 782 'F' => I18N::translate('Female'), 783 ]; 784 } 785 786 /** 787 * Labels for the X axis 788 * 789 * @return string[] 790 */ 791 private function axisMonths(): array 792 { 793 return [ 794 'JAN' => I18N::translateContext('NOMINATIVE', 'January'), 795 'FEB' => I18N::translateContext('NOMINATIVE', 'February'), 796 'MAR' => I18N::translateContext('NOMINATIVE', 'March'), 797 'APR' => I18N::translateContext('NOMINATIVE', 'April'), 798 'MAY' => I18N::translateContext('NOMINATIVE', 'May'), 799 'JUN' => I18N::translateContext('NOMINATIVE', 'June'), 800 'JUL' => I18N::translateContext('NOMINATIVE', 'July'), 801 'AUG' => I18N::translateContext('NOMINATIVE', 'August'), 802 'SEP' => I18N::translateContext('NOMINATIVE', 'September'), 803 'OCT' => I18N::translateContext('NOMINATIVE', 'October'), 804 'NOV' => I18N::translateContext('NOMINATIVE', 'November'), 805 'DEC' => I18N::translateContext('NOMINATIVE', 'December'), 806 ]; 807 } 808 809 /** 810 * Convert a list of N year-boundaries into N+1 year-ranges for the z-axis. 811 * 812 * @param string $boundaries_csv 813 * 814 * @return string[] 815 */ 816 private function axisYears(string $boundaries_csv): array 817 { 818 $boundaries = explode(',', $boundaries_csv); 819 820 $axis = []; 821 foreach ($boundaries as $n => $boundary) { 822 if ($n === 0) { 823 $date = new Date('BEF ' . $boundary); 824 } else { 825 $date = new Date('BET ' . $boundaries[$n - 1] . ' AND ' . ($boundary - 1)); 826 } 827 $axis[$boundary - 1] = strip_tags($date->display()); 828 } 829 830 $date = new Date('AFT ' . $boundaries[count($boundaries) - 1]); 831 $axis[PHP_INT_MAX] = strip_tags($date->display()); 832 833 return $axis; 834 } 835 836 /** 837 * Create the X axis. 838 * 839 * @param string $boundaries_csv 840 * 841 * @return array 842 */ 843 private function axisNumbers(string $boundaries_csv): array 844 { 845 $boundaries = explode(',', $boundaries_csv); 846 847 $boundaries = array_map(static function (string $x): int { 848 return (int) $x; 849 }, $boundaries); 850 851 $axis = []; 852 foreach ($boundaries as $n => $boundary) { 853 if ($n === 0) { 854 $prev_boundary = 0; 855 } else { 856 $prev_boundary = $boundaries[$n - 1] + 1; 857 } 858 859 if ($prev_boundary === $boundary) { 860 /* I18N: A range of numbers */ 861 $axis[$boundary] = I18N::number($boundary); 862 } else { 863 /* I18N: A range of numbers */ 864 $axis[$boundary] = I18N::translate('%1$s–%2$s', I18N::number($prev_boundary), I18N::number($boundary)); 865 } 866 } 867 868 /* I18N: Label on a graph; 40+ means 40 or more */ 869 $axis[PHP_INT_MAX] = I18N::translate('%s+', I18N::number($boundaries[count($boundaries) - 1])); 870 871 return $axis; 872 } 873 874 /** 875 * Calculate the Y axis. 876 * 877 * @param int|string $x 878 * @param int|string $z 879 * @param int|string $value 880 * @param array $x_axis 881 * @param array $z_axis 882 * @param int[][] $ydata 883 * 884 * @return void 885 */ 886 private function fillYData($x, $z, $value, array $x_axis, array $z_axis, array &$ydata): void 887 { 888 $x = $this->findAxisEntry($x, $x_axis); 889 $z = $this->findAxisEntry($z, $z_axis); 890 891 if (!array_key_exists($z, $z_axis)) { 892 foreach (array_keys($z_axis) as $key) { 893 if ($value <= $key) { 894 $z = $key; 895 break; 896 } 897 } 898 } 899 900 // Add the value to the appropriate data point. 901 $ydata[$z][$x] = ($ydata[$z][$x] ?? 0) + $value; 902 } 903 904 /** 905 * Find the axis entry for a given value. 906 * Some are direct lookup (e.g. M/F, JAN/FEB/MAR). 907 * Others need to find the approprate range. 908 * 909 * @param int|float|string $value 910 * @param string[] $axis 911 * 912 * @return int|string 913 */ 914 private function findAxisEntry($value, $axis) 915 { 916 if (is_numeric($value)) { 917 $value = (int) $value; 918 919 if (!array_key_exists($value, $axis)) { 920 foreach (array_keys($axis) as $boundary) { 921 if ($value <= $boundary) { 922 $value = $boundary; 923 break; 924 } 925 } 926 } 927 } 928 929 return $value; 930 } 931 932 /** 933 * Plot the data. 934 * 935 * @param string $chart_title 936 * @param string[] $x_axis 937 * @param string $x_axis_title 938 * @param int[][] $ydata 939 * @param string $y_axis_title 940 * @param string[] $z_axis 941 * @param int $y_axis_type 942 * 943 * @return string 944 */ 945 private function myPlot( 946 string $chart_title, 947 array $x_axis, 948 string $x_axis_title, 949 array $ydata, 950 string $y_axis_title, 951 array $z_axis, 952 int $y_axis_type 953 ): string { 954 if (!count($ydata)) { 955 return I18N::translate('This information is not available.'); 956 } 957 958 // Colors for z-axis 959 $colors = []; 960 $index = 0; 961 while (count($colors) < count($ydata)) { 962 $colors[] = self::Z_AXIS_COLORS[$index]; 963 $index = ($index + 1) % count(self::Z_AXIS_COLORS); 964 } 965 966 // Convert our sparse dataset into a fixed-size array 967 $tmp = []; 968 foreach (array_keys($z_axis) as $z) { 969 foreach (array_keys($x_axis) as $x) { 970 $tmp[$z][$x] = $ydata[$z][$x] ?? 0; 971 } 972 } 973 $ydata = $tmp; 974 975 // Convert the chart data to percentage 976 if ($y_axis_type === self::Y_AXIS_PERCENT) { 977 // Normalise each (non-zero!) set of data to total 100% 978 array_walk($ydata, static function (array &$x) { 979 $sum = array_sum($x); 980 if ($sum > 0) { 981 $x = array_map(static function ($y) use ($sum) { 982 return $y * 100.0 / $sum; 983 }, $x); 984 } 985 }); 986 } 987 988 $data = [ 989 array_merge( 990 [ I18N::translate('Century') ], 991 array_values($z_axis) 992 ) 993 ]; 994 995 $intermediate = []; 996 foreach ($ydata as $century => $months) { 997 foreach ($months as $month => $value) { 998 $intermediate[$month][] = [ 999 'v' => $value, 1000 'f' => ($y_axis_type === self::Y_AXIS_PERCENT) ? sprintf('%.1f%%', $value) : $value, 1001 ]; 1002 } 1003 } 1004 1005 foreach ($intermediate as $key => $values) { 1006 $data[] = array_merge( 1007 [ $x_axis[$key] ], 1008 $values 1009 ); 1010 } 1011 1012 $chart_options = [ 1013 'title' => '', 1014 'subtitle' => '', 1015 'height' => 400, 1016 'width' => '100%', 1017 'legend' => [ 1018 'position' => count($z_axis) > 1 ? 'right' : 'none', 1019 'alignment' => 'center', 1020 ], 1021 'tooltip' => [ 1022 'format' => '\'%\'', 1023 ], 1024 'vAxis' => [ 1025 'title' => $y_axis_title ?? '', 1026 ], 1027 'hAxis' => [ 1028 'title' => $x_axis_title ?? '', 1029 ], 1030 'colors' => $colors, 1031 ]; 1032 1033 return view( 1034 'statistics/other/charts/custom', 1035 [ 1036 'data' => $data, 1037 'chart_options' => $chart_options, 1038 'chart_title' => $chart_title, 1039 ] 1040 ); 1041 } 1042} 1043