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