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