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