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