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