1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Module; 21 22use Aura\Router\RouterContainer; 23use Fig\Http\Message\RequestMethodInterface; 24use Fisharebest\ExtCalendar\GregorianCalendar; 25use Fisharebest\Webtrees\Auth; 26use Fisharebest\Webtrees\ColorGenerator; 27use Fisharebest\Webtrees\Date; 28use Fisharebest\Webtrees\I18N; 29use Fisharebest\Webtrees\Individual; 30use Fisharebest\Webtrees\Place; 31use Fisharebest\Webtrees\Tree; 32use Illuminate\Database\Capsule\Manager as DB; 33use Illuminate\Database\Query\JoinClause; 34use Psr\Http\Message\ResponseInterface; 35use Psr\Http\Message\ServerRequestInterface; 36use Psr\Http\Server\RequestHandlerInterface; 37use stdClass; 38 39use function app; 40use function assert; 41 42/** 43 * Class LifespansChartModule 44 */ 45class LifespansChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface 46{ 47 use ModuleChartTrait; 48 49 protected const ROUTE_URL = '/tree/{tree}/lifespans'; 50 51 // Defaults 52 protected const DEFAULT_PARAMETERS = []; 53 54 // Parameters for generating colors 55 protected const RANGE = 120; // degrees 56 protected const SATURATION = 100; // percent 57 protected const LIGHTNESS = 30; // percent 58 protected const ALPHA = 0.25; 59 60 /** 61 * Initialization. 62 * 63 * @return void 64 */ 65 public function boot(): void 66 { 67 $router_container = app(RouterContainer::class); 68 assert($router_container instanceof RouterContainer); 69 70 $router_container->getMap() 71 ->get(static::class, static::ROUTE_URL, $this) 72 ->allows(RequestMethodInterface::METHOD_POST); 73 } 74 75 /** 76 * How should this module be identified in the control panel, etc.? 77 * 78 * @return string 79 */ 80 public function title(): string 81 { 82 /* I18N: Name of a module/chart */ 83 return I18N::translate('Lifespans'); 84 } 85 86 /** 87 * A sentence describing what this module does. 88 * 89 * @return string 90 */ 91 public function description(): string 92 { 93 /* I18N: Description of the “LifespansChart” module */ 94 return I18N::translate('A chart of individuals’ lifespans.'); 95 } 96 97 /** 98 * CSS class for the URL. 99 * 100 * @return string 101 */ 102 public function chartMenuClass(): string 103 { 104 return 'menu-chart-lifespan'; 105 } 106 107 /** 108 * The URL for this chart. 109 * 110 * @param Individual $individual 111 * @param mixed[] $parameters 112 * 113 * @return string 114 */ 115 public function chartUrl(Individual $individual, array $parameters = []): string 116 { 117 return route(static::class, [ 118 'tree' => $individual->tree()->name(), 119 'xrefs' => [$individual->xref()], 120 ] + $parameters + self::DEFAULT_PARAMETERS); 121 } 122 123 /** 124 * @param ServerRequestInterface $request 125 * 126 * @return ResponseInterface 127 */ 128 public function handle(ServerRequestInterface $request): ResponseInterface 129 { 130 $tree = $request->getAttribute('tree'); 131 assert($tree instanceof Tree); 132 133 $user = $request->getAttribute('user'); 134 $xrefs = $request->getQueryParams()['xrefs'] ?? []; 135 $ajax = $request->getQueryParams()['ajax'] ?? ''; 136 137 $params = (array) $request->getParsedBody(); 138 139 $addxref = $params['addxref'] ?? ''; 140 $addfam = (bool) ($params['addfam'] ?? false); 141 $place_id = (int) ($params['place_id'] ?? 0); 142 $start = $params['start'] ?? ''; 143 $end = $params['end'] ?? ''; 144 145 $place = Place::find($place_id, $tree); 146 $start_date = new Date($start); 147 $end_date = new Date($end); 148 149 $xrefs = array_unique($xrefs); 150 151 // Add an individual, and family members 152 $individual = Individual::getInstance($addxref, $tree); 153 if ($individual !== null) { 154 $xrefs[] = $addxref; 155 if ($addfam) { 156 $xrefs = array_merge($xrefs, $this->closeFamily($individual)); 157 } 158 } 159 160 // Select by date and/or place. 161 if ($place_id !== 0 && $start_date->isOK() && $end_date->isOK()) { 162 $date_xrefs = $this->findIndividualsByDate($start_date, $end_date, $tree); 163 $place_xrefs = $this->findIndividualsByPlace($place, $tree); 164 $xrefs = array_intersect($date_xrefs, $place_xrefs); 165 } elseif ($start_date->isOK() && $end_date->isOK()) { 166 $xrefs = $this->findIndividualsByDate($start_date, $end_date, $tree); 167 } elseif ($place_id !== 0) { 168 $xrefs = $this->findIndividualsByPlace($place, $tree); 169 } 170 171 // Filter duplicates and private individuals. 172 $xrefs = array_unique($xrefs); 173 $xrefs = array_filter($xrefs, static function (string $xref) use ($tree): bool { 174 $individual = Individual::getInstance($xref, $tree); 175 176 return $individual !== null && $individual->canShow(); 177 }); 178 179 // Convert POST requests into GET requests for pretty URLs. 180 if ($request->getMethod() === RequestMethodInterface::METHOD_POST) { 181 return redirect(route(static::class, [ 182 'tree' => $tree->name(), 183 'xrefs' => $xrefs, 184 ])); 185 } 186 187 Auth::checkComponentAccess($this, 'chart', $tree, $user); 188 189 if ($ajax === '1') { 190 $this->layout = 'layouts/ajax'; 191 192 return $this->chart($tree, $xrefs); 193 } 194 195 $reset_url = route(static::class, ['tree' => $tree->name()]); 196 197 $ajax_url = route(static::class, [ 198 'ajax' => true, 199 'tree' => $tree->name(), 200 'xrefs' => $xrefs, 201 ]); 202 203 return $this->viewResponse('modules/lifespans-chart/page', [ 204 'ajax_url' => $ajax_url, 205 'module' => $this->name(), 206 'reset_url' => $reset_url, 207 'title' => $this->title(), 208 'tree' => $tree, 209 'xrefs' => $xrefs, 210 ]); 211 } 212 213 /** 214 * @param Tree $tree 215 * @param array $xrefs 216 * 217 * @return ResponseInterface 218 */ 219 protected function chart(Tree $tree, array $xrefs): ResponseInterface 220 { 221 /** @var Individual[] $individuals */ 222 $individuals = array_map(static function (string $xref) use ($tree): ?Individual { 223 return Individual::getInstance($xref, $tree); 224 }, $xrefs); 225 226 $individuals = array_filter($individuals, static function (?Individual $individual): bool { 227 return $individual instanceof Individual && $individual->canShow(); 228 }); 229 230 // Sort the array in order of birth year 231 usort($individuals, Individual::birthDateComparator()); 232 233 // Round to whole decades 234 $start_year = (int) floor($this->minYear($individuals) / 10) * 10; 235 $end_year = (int) ceil($this->maxYear($individuals) / 10) * 10; 236 237 $lifespans = $this->layoutIndividuals($individuals); 238 239 $max_rows = array_reduce($lifespans, static function ($carry, stdClass $item) { 240 return max($carry, $item->row); 241 }, 0); 242 243 $count = count($xrefs); 244 $subtitle = I18N::plural('%s individual', '%s individuals', $count, I18N::number($count)); 245 246 $html = view('modules/lifespans-chart/chart', [ 247 'dir' => I18N::direction(), 248 'end_year' => $end_year, 249 'lifespans' => $lifespans, 250 'max_rows' => $max_rows, 251 'start_year' => $start_year, 252 'subtitle' => $subtitle, 253 ]); 254 255 return response($html); 256 } 257 258 /** 259 * Find the latest event year for individuals 260 * 261 * @param array $individuals 262 * 263 * @return int 264 */ 265 protected function maxYear(array $individuals): int 266 { 267 $jd = array_reduce($individuals, static function ($carry, Individual $item) { 268 return max($carry, $item->getEstimatedDeathDate()->maximumJulianDay()); 269 }, 0); 270 271 $year = $this->jdToYear($jd); 272 273 // Don't show future dates 274 return min($year, (int) date('Y')); 275 } 276 277 /** 278 * Find the earliest event year for individuals 279 * 280 * @param array $individuals 281 * 282 * @return int 283 */ 284 protected function minYear(array $individuals): int 285 { 286 $jd = array_reduce($individuals, static function ($carry, Individual $item) { 287 return min($carry, $item->getEstimatedBirthDate()->minimumJulianDay()); 288 }, PHP_INT_MAX); 289 290 return $this->jdToYear($jd); 291 } 292 293 /** 294 * Convert a julian day to a gregorian year 295 * 296 * @param int $jd 297 * 298 * @return int 299 */ 300 protected function jdToYear(int $jd): int 301 { 302 if ($jd === 0) { 303 return 0; 304 } 305 306 $gregorian = new GregorianCalendar(); 307 [$y] = $gregorian->jdToYmd($jd); 308 309 return $y; 310 } 311 312 /** 313 * @param Date $start 314 * @param Date $end 315 * @param Tree $tree 316 * 317 * @return string[] 318 */ 319 protected function findIndividualsByDate(Date $start, Date $end, Tree $tree): array 320 { 321 return DB::table('individuals') 322 ->join('dates', static function (JoinClause $join): void { 323 $join 324 ->on('d_file', '=', 'i_file') 325 ->on('d_gid', '=', 'i_id'); 326 }) 327 ->where('i_file', '=', $tree->id()) 328 ->where('d_julianday1', '<=', $end->maximumJulianDay()) 329 ->where('d_julianday2', '>=', $start->minimumJulianDay()) 330 ->whereNotIn('d_fact', ['BAPL', 'ENDL', 'SLGC', 'SLGS', '_TODO', 'CHAN']) 331 ->pluck('i_id') 332 ->all(); 333 } 334 335 /** 336 * @param Place $place 337 * @param Tree $tree 338 * 339 * @return string[] 340 */ 341 protected function findIndividualsByPlace(Place $place, Tree $tree): array 342 { 343 return DB::table('individuals') 344 ->join('placelinks', static function (JoinClause $join): void { 345 $join 346 ->on('pl_file', '=', 'i_file') 347 ->on('pl_gid', '=', 'i_id'); 348 }) 349 ->where('i_file', '=', $tree->id()) 350 ->where('pl_p_id', '=', $place->id()) 351 ->pluck('i_id') 352 ->all(); 353 } 354 355 /** 356 * Find the close family members of an individual. 357 * 358 * @param Individual $individual 359 * 360 * @return string[] 361 */ 362 protected function closeFamily(Individual $individual): array 363 { 364 $xrefs = []; 365 366 foreach ($individual->spouseFamilies() as $family) { 367 foreach ($family->children() as $child) { 368 $xrefs[] = $child->xref(); 369 } 370 371 foreach ($family->spouses() as $spouse) { 372 $xrefs[] = $spouse->xref(); 373 } 374 } 375 376 foreach ($individual->childFamilies() as $family) { 377 foreach ($family->children() as $child) { 378 $xrefs[] = $child->xref(); 379 } 380 381 foreach ($family->spouses() as $spouse) { 382 $xrefs[] = $spouse->xref(); 383 } 384 } 385 386 return $xrefs; 387 } 388 389 /** 390 * @param Individual[] $individuals 391 * 392 * @return stdClass[] 393 */ 394 private function layoutIndividuals(array $individuals): array 395 { 396 $colors = [ 397 'M' => new ColorGenerator(240, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE * -1), 398 'F' => new ColorGenerator(000, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE), 399 'U' => new ColorGenerator(120, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE), 400 ]; 401 402 $current_year = (int) date('Y'); 403 404 // Latest year used in each row 405 $rows = []; 406 407 $lifespans = []; 408 409 foreach ($individuals as $individual) { 410 $birth_jd = $individual->getEstimatedBirthDate()->minimumJulianDay(); 411 $birth_year = $this->jdToYear($birth_jd); 412 $death_jd = $individual->getEstimatedDeathDate()->maximumJulianDay(); 413 $death_year = $this->jdToYear($death_jd); 414 415 // Died before they were born? Swapping the dates allows them to be shown. 416 if ($death_year < $birth_year) { 417 $death_year = $birth_year; 418 } 419 420 // Don't show death dates in the future. 421 $death_year = min($death_year, $current_year); 422 423 // Add this individual to the next row in the chart... 424 $next_row = count($rows); 425 // ...unless we can find an existing row where it fits. 426 foreach ($rows as $row => $year) { 427 if ($year < $birth_year) { 428 $next_row = $row; 429 break; 430 } 431 } 432 433 // Fill the row up to the year (leaving a small gap) 434 $rows[$next_row] = $death_year; 435 436 $lifespans[] = (object) [ 437 'background' => $colors[$individual->sex()]->getNextColor(), 438 'birth_year' => $birth_year, 439 'death_year' => $death_year, 440 'id' => 'individual-' . md5($individual->xref()), 441 'individual' => $individual, 442 'row' => $next_row, 443 ]; 444 } 445 446 return $lifespans; 447 } 448} 449