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