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