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