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