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\Webtrees\Auth; 23use Fisharebest\Webtrees\Date; 24use Fisharebest\Webtrees\Exceptions\HttpNotFoundException; 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; 31 32use function app; 33use function array_key_exists; 34use function array_keys; 35use function array_map; 36use function array_merge; 37use function array_sum; 38use function array_values; 39use function array_walk; 40use function assert; 41use function count; 42use function explode; 43use function in_array; 44use function is_numeric; 45use function sprintf; 46use function strip_tags; 47 48/** 49 * Class StatisticsChartModule 50 */ 51class StatisticsChartModule extends AbstractModule implements ModuleChartInterface 52{ 53 use ModuleChartTrait; 54 55 // We generate a bitmap chart with these dimensions in image pixels. 56 // These set the aspect ratio. The actual image is sized using CSS 57 // The maximum size (width x height) is 300,000 58 private const CHART_WIDTH = 950; 59 private const CHART_HEIGHT = 315; 60 61 public const X_AXIS_INDIVIDUAL_MAP = 1; 62 public const X_AXIS_BIRTH_MAP = 2; 63 public const X_AXIS_DEATH_MAP = 3; 64 public const X_AXIS_MARRIAGE_MAP = 4; 65 public const X_AXIS_BIRTH_MONTH = 11; 66 public const X_AXIS_DEATH_MONTH = 12; 67 public const X_AXIS_MARRIAGE_MONTH = 13; 68 public const X_AXIS_FIRST_CHILD_MONTH = 14; 69 public const X_AXIS_FIRST_MARRIAGE_MONTH = 15; 70 public const X_AXIS_AGE_AT_DEATH = 18; 71 public const X_AXIS_AGE_AT_MARRIAGE = 19; 72 public const X_AXIS_AGE_AT_FIRST_MARRIAGE = 20; 73 public const X_AXIS_NUMBER_OF_CHILDREN = 21; 74 75 public const Y_AXIS_NUMBERS = 201; 76 public const Y_AXIS_PERCENT = 202; 77 78 public const Z_AXIS_ALL = 300; 79 public const Z_AXIS_SEX = 301; 80 public const Z_AXIS_TIME = 302; 81 82 // First two colors are blue/pink, to work with Z_AXIS_SEX. 83 private const Z_AXIS_COLORS = ['0000FF', 'FFA0CB', '9F00FF', 'FF7000', '905030', 'FF0000', '00FF00', 'F0F000']; 84 85 private const DAYS_IN_YEAR = 365.25; 86 87 /** 88 * How should this module be identified in the control panel, etc.? 89 * 90 * @return string 91 */ 92 public function title(): string 93 { 94 /* I18N: Name of a module/chart */ 95 return I18N::translate('Statistics'); 96 } 97 98 /** 99 * A sentence describing what this module does. 100 * 101 * @return string 102 */ 103 public function description(): string 104 { 105 /* I18N: Description of the “StatisticsChart” module */ 106 return I18N::translate('Various statistics charts.'); 107 } 108 109 /** 110 * CSS class for the URL. 111 * 112 * @return string 113 */ 114 public function chartMenuClass(): string 115 { 116 return 'menu-chart-statistics'; 117 } 118 119 /** 120 * The URL for this chart. 121 * 122 * @param Individual $individual 123 * @param mixed[] $parameters 124 * 125 * @return string 126 */ 127 public function chartUrl(Individual $individual, array $parameters = []): string 128 { 129 return route('module', [ 130 'module' => $this->name(), 131 'action' => 'Chart', 132 'tree' => $individual->tree()->name(), 133 ] + $parameters); 134 } 135 136 /** 137 * A form to request the chart parameters. 138 * 139 * @param ServerRequestInterface $request 140 * 141 * @return ResponseInterface 142 */ 143 public function getChartAction(ServerRequestInterface $request): ResponseInterface 144 { 145 $tree = $request->getAttribute('tree'); 146 assert($tree instanceof Tree); 147 148 $user = $request->getAttribute('user'); 149 150 Auth::checkComponentAccess($this, 'chart', $tree, $user); 151 152 $tabs = [ 153 I18N::translate('Individuals') => route('module', [ 154 'module' => $this->name(), 155 'action' => 'Individuals', 156 'tree' => $tree->name(), 157 ]), 158 I18N::translate('Families') => route('module', [ 159 'module' => $this->name(), 160 'action' => 'Families', 161 'tree' => $tree->name(), 162 ]), 163 I18N::translate('Other') => route('module', [ 164 'module' => $this->name(), 165 'action' => 'Other', 166 'tree' => $tree->name(), 167 ]), 168 I18N::translate('Custom') => route('module', [ 169 'module' => $this->name(), 170 'action' => 'Custom', 171 'tree' => $tree->name(), 172 ]), 173 ]; 174 175 return $this->viewResponse('modules/statistics-chart/page', [ 176 'module' => $this->name(), 177 'tabs' => $tabs, 178 'title' => $this->title(), 179 'tree' => $tree, 180 ]); 181 } 182 183 /** 184 * @param ServerRequestInterface $request 185 * 186 * @return ResponseInterface 187 */ 188 public function getIndividualsAction(ServerRequestInterface $request): ResponseInterface 189 { 190 $this->layout = 'layouts/ajax'; 191 192 return $this->viewResponse('modules/statistics-chart/individuals', [ 193 'show_oldest_living' => Auth::check(), 194 'stats' => app(Statistics::class), 195 ]); 196 } 197 198 /** 199 * @param ServerRequestInterface $request 200 * 201 * @return ResponseInterface 202 */ 203 public function getFamiliesAction(ServerRequestInterface $request): ResponseInterface 204 { 205 $this->layout = 'layouts/ajax'; 206 207 return $this->viewResponse('modules/statistics-chart/families', [ 208 'stats' => app(Statistics::class), 209 ]); 210 } 211 212 /** 213 * @param ServerRequestInterface $request 214 * 215 * @return ResponseInterface 216 */ 217 public function getOtherAction(ServerRequestInterface $request): ResponseInterface 218 { 219 $this->layout = 'layouts/ajax'; 220 221 return $this->viewResponse('modules/statistics-chart/other', [ 222 'stats' => app(Statistics::class), 223 ]); 224 } 225 226 /** 227 * @param ServerRequestInterface $request 228 * 229 * @return ResponseInterface 230 */ 231 public function getCustomAction(ServerRequestInterface $request): ResponseInterface 232 { 233 $this->layout = 'layouts/ajax'; 234 235 $tree = $request->getAttribute('tree'); 236 assert($tree instanceof Tree); 237 238 return $this->viewResponse('modules/statistics-chart/custom', [ 239 'module' => $this, 240 'tree' => $tree, 241 ]); 242 } 243 244 /** 245 * @param ServerRequestInterface $request 246 * 247 * @return ResponseInterface 248 */ 249 public function postCustomChartAction(ServerRequestInterface $request): ResponseInterface 250 { 251 $statistics = app(Statistics::class); 252 assert($statistics instanceof Statistics); 253 254 $params = (array) $request->getParsedBody(); 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 HttpNotFoundException(); 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 HttpNotFoundException(); 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 HttpNotFoundException(); 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 HttpNotFoundException(); 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 HttpNotFoundException(); 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 HttpNotFoundException(); 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 HttpNotFoundException(); 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 HttpNotFoundException(); 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 HttpNotFoundException(); 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 foreach ($rows as $row) { 498 if (!in_array($row->f_husb, $indi, true) && !in_array($row->f_wife, $indi, true)) { 499 $this->fillYData($row->month, 0, 1, $x_axis, $z_axis, $ydata); 500 } 501 $indi[] = $row->f_husb; 502 $indi[] = $row->f_wife; 503 } 504 break; 505 case self::Z_AXIS_TIME: 506 $boundaries_csv = $params['z-axis-boundaries-periods']; 507 $z_axis = $this->axisYears($boundaries_csv); 508 $prev_boundary = 0; 509 $indi = []; 510 foreach (array_keys($z_axis) as $boundary) { 511 $rows = $statistics->statsFirstMarriageQuery($prev_boundary, $boundary)->get(); 512 foreach ($rows as $row) { 513 if (!in_array($row->f_husb, $indi, true) && !in_array($row->f_wife, $indi, true)) { 514 $this->fillYData($row->month, $boundary, 1, $x_axis, $z_axis, $ydata); 515 } 516 $indi[] = $row->f_husb; 517 $indi[] = $row->f_wife; 518 } 519 $prev_boundary = $boundary + 1; 520 } 521 break; 522 default: 523 throw new HttpNotFoundException(); 524 } 525 526 return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type)); 527 528 case self::X_AXIS_AGE_AT_DEATH: 529 $chart_title = I18N::translate('Average age at death'); 530 $x_axis_title = I18N::translate('age'); 531 $boundaries_csv = $params['x-axis-boundaries-ages']; 532 $x_axis = $this->axisNumbers($boundaries_csv); 533 534 switch ($y_axis_type) { 535 case self::Y_AXIS_NUMBERS: 536 $y_axis_title = I18N::translate('Individuals'); 537 break; 538 case self::Y_AXIS_PERCENT: 539 $y_axis_title = '%'; 540 break; 541 default: 542 throw new HttpNotFoundException(); 543 } 544 545 switch ($z_axis_type) { 546 case self::Z_AXIS_ALL: 547 $z_axis = $this->axisAll(); 548 $rows = $statistics->statsAgeQuery('DEAT'); 549 foreach ($rows as $row) { 550 foreach ($row as $age) { 551 $years = (int) ($age / self::DAYS_IN_YEAR); 552 $this->fillYData($years, 0, 1, $x_axis, $z_axis, $ydata); 553 } 554 } 555 break; 556 case self::Z_AXIS_SEX: 557 $z_axis = $this->axisSexes(); 558 foreach (array_keys($z_axis) as $sex) { 559 $rows = $statistics->statsAgeQuery('DEAT', $sex); 560 foreach ($rows as $row) { 561 foreach ($row as $age) { 562 $years = (int) ($age / self::DAYS_IN_YEAR); 563 $this->fillYData($years, $sex, 1, $x_axis, $z_axis, $ydata); 564 } 565 } 566 } 567 break; 568 case self::Z_AXIS_TIME: 569 $boundaries_csv = $params['z-axis-boundaries-periods']; 570 $z_axis = $this->axisYears($boundaries_csv); 571 $prev_boundary = 0; 572 foreach (array_keys($z_axis) as $boundary) { 573 $rows = $statistics->statsAgeQuery('DEAT', 'BOTH', $prev_boundary, $boundary); 574 foreach ($rows as $row) { 575 foreach ($row as $age) { 576 $years = (int) ($age / self::DAYS_IN_YEAR); 577 $this->fillYData($years, $boundary, 1, $x_axis, $z_axis, $ydata); 578 } 579 } 580 $prev_boundary = $boundary + 1; 581 } 582 583 break; 584 default: 585 throw new HttpNotFoundException(); 586 } 587 588 return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type)); 589 590 case self::X_AXIS_AGE_AT_MARRIAGE: 591 $chart_title = I18N::translate('Age in year of marriage'); 592 $x_axis_title = I18N::translate('age'); 593 $boundaries_csv = $params['x-axis-boundaries-ages_m']; 594 $x_axis = $this->axisNumbers($boundaries_csv); 595 596 switch ($y_axis_type) { 597 case self::Y_AXIS_NUMBERS: 598 $y_axis_title = I18N::translate('Individuals'); 599 break; 600 case self::Y_AXIS_PERCENT: 601 $y_axis_title = '%'; 602 break; 603 default: 604 throw new HttpNotFoundException(); 605 } 606 607 switch ($z_axis_type) { 608 case self::Z_AXIS_ALL: 609 $z_axis = $this->axisAll(); 610 // The stats query doesn't have an "all" function, so query M/F separately 611 foreach (['M', 'F'] as $sex) { 612 $rows = $statistics->statsMarrAgeQuery($sex); 613 foreach ($rows as $row) { 614 $years = (int) ($row->age / self::DAYS_IN_YEAR); 615 $this->fillYData($years, 0, 1, $x_axis, $z_axis, $ydata); 616 } 617 } 618 break; 619 case self::Z_AXIS_SEX: 620 $z_axis = $this->axisSexes(); 621 foreach (array_keys($z_axis) as $sex) { 622 $rows = $statistics->statsMarrAgeQuery($sex); 623 foreach ($rows as $row) { 624 $years = (int) ($row->age / self::DAYS_IN_YEAR); 625 $this->fillYData($years, $sex, 1, $x_axis, $z_axis, $ydata); 626 } 627 } 628 break; 629 case self::Z_AXIS_TIME: 630 $boundaries_csv = $params['z-axis-boundaries-periods']; 631 $z_axis = $this->axisYears($boundaries_csv); 632 // The stats query doesn't have an "all" function, so query M/F separately 633 foreach (['M', 'F'] as $sex) { 634 $prev_boundary = 0; 635 foreach (array_keys($z_axis) as $boundary) { 636 $rows = $statistics->statsMarrAgeQuery($sex, $prev_boundary, $boundary); 637 foreach ($rows as $row) { 638 $years = (int) ($row->age / self::DAYS_IN_YEAR); 639 $this->fillYData($years, $boundary, 1, $x_axis, $z_axis, $ydata); 640 } 641 $prev_boundary = $boundary + 1; 642 } 643 } 644 break; 645 default: 646 throw new HttpNotFoundException(); 647 } 648 649 return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type)); 650 651 case self::X_AXIS_AGE_AT_FIRST_MARRIAGE: 652 $chart_title = I18N::translate('Age in year of first marriage'); 653 $x_axis_title = I18N::translate('age'); 654 $boundaries_csv = $params['x-axis-boundaries-ages_m']; 655 $x_axis = $this->axisNumbers($boundaries_csv); 656 657 switch ($y_axis_type) { 658 case self::Y_AXIS_NUMBERS: 659 $y_axis_title = I18N::translate('Individuals'); 660 break; 661 case self::Y_AXIS_PERCENT: 662 $y_axis_title = '%'; 663 break; 664 default: 665 throw new HttpNotFoundException(); 666 } 667 668 switch ($z_axis_type) { 669 case self::Z_AXIS_ALL: 670 $z_axis = $this->axisAll(); 671 // The stats query doesn't have an "all" function, so query M/F separately 672 foreach (['M', 'F'] as $sex) { 673 $rows = $statistics->statsMarrAgeQuery($sex); 674 $indi = []; 675 foreach ($rows as $row) { 676 if (!in_array($row->d_gid, $indi, true)) { 677 $years = (int) ($row->age / self::DAYS_IN_YEAR); 678 $this->fillYData($years, 0, 1, $x_axis, $z_axis, $ydata); 679 $indi[] = $row->d_gid; 680 } 681 } 682 } 683 break; 684 case self::Z_AXIS_SEX: 685 $z_axis = $this->axisSexes(); 686 foreach (array_keys($z_axis) as $sex) { 687 $rows = $statistics->statsMarrAgeQuery($sex); 688 $indi = []; 689 foreach ($rows as $row) { 690 if (!in_array($row->d_gid, $indi, true)) { 691 $years = (int) ($row->age / self::DAYS_IN_YEAR); 692 $this->fillYData($years, $sex, 1, $x_axis, $z_axis, $ydata); 693 $indi[] = $row->d_gid; 694 } 695 } 696 } 697 break; 698 case self::Z_AXIS_TIME: 699 $boundaries_csv = $params['z-axis-boundaries-periods']; 700 $z_axis = $this->axisYears($boundaries_csv); 701 // The stats query doesn't have an "all" function, so query M/F separately 702 foreach (['M', 'F'] as $sex) { 703 $prev_boundary = 0; 704 $indi = []; 705 foreach (array_keys($z_axis) as $boundary) { 706 $rows = $statistics->statsMarrAgeQuery($sex, $prev_boundary, $boundary); 707 foreach ($rows as $row) { 708 if (!in_array($row->d_gid, $indi, true)) { 709 $years = (int) ($row->age / self::DAYS_IN_YEAR); 710 $this->fillYData($years, $boundary, 1, $x_axis, $z_axis, $ydata); 711 $indi[] = $row->d_gid; 712 } 713 } 714 $prev_boundary = $boundary + 1; 715 } 716 } 717 break; 718 default: 719 throw new HttpNotFoundException(); 720 } 721 722 return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type)); 723 724 case self::X_AXIS_NUMBER_OF_CHILDREN: 725 $chart_title = I18N::translate('Number of children'); 726 $x_axis_title = I18N::translate('Children'); 727 $x_axis = $this->axisNumbers('0,1,2,3,4,5,6,7,8,9,10'); 728 729 switch ($y_axis_type) { 730 case self::Y_AXIS_NUMBERS: 731 $y_axis_title = I18N::translate('Families'); 732 break; 733 case self::Y_AXIS_PERCENT: 734 $y_axis_title = '%'; 735 break; 736 default: 737 throw new HttpNotFoundException(); 738 } 739 740 switch ($z_axis_type) { 741 case self::Z_AXIS_ALL: 742 $z_axis = $this->axisAll(); 743 $rows = $statistics->statsChildrenQuery(); 744 foreach ($rows as $row) { 745 $this->fillYData($row->f_numchil, 0, $row->total, $x_axis, $z_axis, $ydata); 746 } 747 break; 748 case self::Z_AXIS_TIME: 749 $boundaries_csv = $params['z-axis-boundaries-periods']; 750 $z_axis = $this->axisYears($boundaries_csv); 751 $prev_boundary = 0; 752 foreach (array_keys($z_axis) as $boundary) { 753 $rows = $statistics->statsChildrenQuery($prev_boundary, $boundary); 754 foreach ($rows as $row) { 755 $this->fillYData($row->f_numchil, $boundary, $row->total, $x_axis, $z_axis, $ydata); 756 } 757 $prev_boundary = $boundary + 1; 758 } 759 break; 760 default: 761 throw new HttpNotFoundException(); 762 } 763 764 return response($this->myPlot($chart_title, $x_axis, $x_axis_title, $ydata, $y_axis_title, $z_axis, $y_axis_type)); 765 766 default: 767 throw new HttpNotFoundException(); 768 break; 769 } 770 } 771 772 /** 773 * @return string[] 774 */ 775 private function axisAll(): array 776 { 777 return [ 778 I18N::translate('Total'), 779 ]; 780 } 781 782 /** 783 * @return string[] 784 */ 785 private function axisSexes(): array 786 { 787 return [ 788 'M' => I18N::translate('Male'), 789 'F' => I18N::translate('Female'), 790 ]; 791 } 792 793 /** 794 * Labels for the X axis 795 * 796 * @return string[] 797 */ 798 private function axisMonths(): array 799 { 800 return [ 801 'JAN' => I18N::translateContext('NOMINATIVE', 'January'), 802 'FEB' => I18N::translateContext('NOMINATIVE', 'February'), 803 'MAR' => I18N::translateContext('NOMINATIVE', 'March'), 804 'APR' => I18N::translateContext('NOMINATIVE', 'April'), 805 'MAY' => I18N::translateContext('NOMINATIVE', 'May'), 806 'JUN' => I18N::translateContext('NOMINATIVE', 'June'), 807 'JUL' => I18N::translateContext('NOMINATIVE', 'July'), 808 'AUG' => I18N::translateContext('NOMINATIVE', 'August'), 809 'SEP' => I18N::translateContext('NOMINATIVE', 'September'), 810 'OCT' => I18N::translateContext('NOMINATIVE', 'October'), 811 'NOV' => I18N::translateContext('NOMINATIVE', 'November'), 812 'DEC' => I18N::translateContext('NOMINATIVE', 'December'), 813 ]; 814 } 815 816 /** 817 * Convert a list of N year-boundaries into N+1 year-ranges for the z-axis. 818 * 819 * @param string $boundaries_csv 820 * 821 * @return string[] 822 */ 823 private function axisYears(string $boundaries_csv): array 824 { 825 $boundaries = explode(',', $boundaries_csv); 826 827 $axis = []; 828 foreach ($boundaries as $n => $boundary) { 829 if ($n === 0) { 830 $date = new Date('BEF ' . $boundary); 831 } else { 832 $date = new Date('BET ' . $boundaries[$n - 1] . ' AND ' . ($boundary - 1)); 833 } 834 $axis[$boundary - 1] = strip_tags($date->display()); 835 } 836 837 $date = new Date('AFT ' . $boundaries[count($boundaries) - 1]); 838 $axis[PHP_INT_MAX] = strip_tags($date->display()); 839 840 return $axis; 841 } 842 843 /** 844 * Create the X axis. 845 * 846 * @param string $boundaries_csv 847 * 848 * @return array<string> 849 */ 850 private function axisNumbers(string $boundaries_csv): array 851 { 852 $boundaries = explode(',', $boundaries_csv); 853 854 $boundaries = array_map(static function (string $x): int { 855 return (int) $x; 856 }, $boundaries); 857 858 $axis = []; 859 foreach ($boundaries as $n => $boundary) { 860 if ($n === 0) { 861 $prev_boundary = 0; 862 } else { 863 $prev_boundary = $boundaries[$n - 1] + 1; 864 } 865 866 if ($prev_boundary === $boundary) { 867 /* I18N: A range of numbers */ 868 $axis[$boundary] = I18N::number($boundary); 869 } else { 870 /* I18N: A range of numbers */ 871 $axis[$boundary] = I18N::translate('%1$s–%2$s', I18N::number($prev_boundary), I18N::number($boundary)); 872 } 873 } 874 875 /* I18N: Label on a graph; 40+ means 40 or more */ 876 $axis[PHP_INT_MAX] = I18N::translate('%s+', I18N::number($boundaries[count($boundaries) - 1])); 877 878 return $axis; 879 } 880 881 /** 882 * Calculate the Y axis. 883 * 884 * @param int|string $x 885 * @param int|string $z 886 * @param int|string $value 887 * @param array $x_axis 888 * @param array $z_axis 889 * @param int[][] $ydata 890 * 891 * @return void 892 */ 893 private function fillYData($x, $z, $value, array $x_axis, array $z_axis, array &$ydata): void 894 { 895 $x = $this->findAxisEntry($x, $x_axis); 896 $z = $this->findAxisEntry($z, $z_axis); 897 898 if (!array_key_exists($z, $z_axis)) { 899 foreach (array_keys($z_axis) as $key) { 900 if ($value <= $key) { 901 $z = $key; 902 break; 903 } 904 } 905 } 906 907 // Add the value to the appropriate data point. 908 $ydata[$z][$x] = ($ydata[$z][$x] ?? 0) + $value; 909 } 910 911 /** 912 * Find the axis entry for a given value. 913 * Some are direct lookup (e.g. M/F, JAN/FEB/MAR). 914 * Others need to find the approprate range. 915 * 916 * @param int|float|string $value 917 * @param string[] $axis 918 * 919 * @return int|string 920 */ 921 private function findAxisEntry($value, $axis) 922 { 923 if (is_numeric($value)) { 924 $value = (int) $value; 925 926 if (!array_key_exists($value, $axis)) { 927 foreach (array_keys($axis) as $boundary) { 928 if ($value <= $boundary) { 929 $value = $boundary; 930 break; 931 } 932 } 933 } 934 } 935 936 return $value; 937 } 938 939 /** 940 * Plot the data. 941 * 942 * @param string $chart_title 943 * @param string[] $x_axis 944 * @param string $x_axis_title 945 * @param int[][] $ydata 946 * @param string $y_axis_title 947 * @param string[] $z_axis 948 * @param int $y_axis_type 949 * 950 * @return string 951 */ 952 private function myPlot( 953 string $chart_title, 954 array $x_axis, 955 string $x_axis_title, 956 array $ydata, 957 string $y_axis_title, 958 array $z_axis, 959 int $y_axis_type 960 ): string { 961 if (!count($ydata)) { 962 return I18N::translate('This information is not available.'); 963 } 964 965 // Colors for z-axis 966 $colors = []; 967 $index = 0; 968 while (count($colors) < count($ydata)) { 969 $colors[] = self::Z_AXIS_COLORS[$index]; 970 $index = ($index + 1) % count(self::Z_AXIS_COLORS); 971 } 972 973 // Convert our sparse dataset into a fixed-size array 974 $tmp = []; 975 foreach (array_keys($z_axis) as $z) { 976 foreach (array_keys($x_axis) as $x) { 977 $tmp[$z][$x] = $ydata[$z][$x] ?? 0; 978 } 979 } 980 $ydata = $tmp; 981 982 // Convert the chart data to percentage 983 if ($y_axis_type === self::Y_AXIS_PERCENT) { 984 // Normalise each (non-zero!) set of data to total 100% 985 array_walk($ydata, static function (array &$x) { 986 $sum = array_sum($x); 987 if ($sum > 0) { 988 $x = array_map(static function ($y) use ($sum) { 989 return $y * 100.0 / $sum; 990 }, $x); 991 } 992 }); 993 } 994 995 $data = [ 996 array_merge( 997 [I18N::translate('Century')], 998 array_values($z_axis) 999 ), 1000 ]; 1001 1002 $intermediate = []; 1003 foreach ($ydata as $century => $months) { 1004 foreach ($months as $month => $value) { 1005 $intermediate[$month][] = [ 1006 'v' => $value, 1007 'f' => ($y_axis_type === self::Y_AXIS_PERCENT) ? sprintf('%.1f%%', $value) : $value, 1008 ]; 1009 } 1010 } 1011 1012 foreach ($intermediate as $key => $values) { 1013 $data[] = array_merge( 1014 [$x_axis[$key]], 1015 $values 1016 ); 1017 } 1018 1019 $chart_options = [ 1020 'title' => '', 1021 'subtitle' => '', 1022 'height' => 400, 1023 'width' => '100%', 1024 'legend' => [ 1025 'position' => count($z_axis) > 1 ? 'right' : 'none', 1026 'alignment' => 'center', 1027 ], 1028 'tooltip' => [ 1029 'format' => '\'%\'', 1030 ], 1031 'vAxis' => [ 1032 'title' => $y_axis_title ?? '', 1033 ], 1034 'hAxis' => [ 1035 'title' => $x_axis_title ?? '', 1036 ], 1037 'colors' => $colors, 1038 ]; 1039 1040 return view('statistics/other/charts/custom', [ 1041 'data' => $data, 1042 'chart_options' => $chart_options, 1043 'chart_title' => $chart_title, 1044 'language' => I18N::languageTag(), 1045 ]); 1046 } 1047} 1048