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