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