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