1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2021 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 Aura\Router\RouterContainer; 23use Fig\Http\Message\RequestMethodInterface; 24use Fisharebest\Webtrees\Auth; 25use Fisharebest\Webtrees\Date\GregorianDate; 26use Fisharebest\Webtrees\Fact; 27use Fisharebest\Webtrees\Registry; 28use Fisharebest\Webtrees\GedcomRecord; 29use Fisharebest\Webtrees\I18N; 30use Fisharebest\Webtrees\Individual; 31use Fisharebest\Webtrees\Tree; 32use Illuminate\Support\Collection; 33use Psr\Http\Message\ResponseInterface; 34use Psr\Http\Message\ServerRequestInterface; 35use Psr\Http\Server\RequestHandlerInterface; 36 37use function app; 38use function assert; 39use function redirect; 40use function route; 41 42/** 43 * Class TimelineChartModule 44 */ 45class TimelineChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface 46{ 47 use ModuleChartTrait; 48 49 protected const ROUTE_URL = '/tree/{tree}/timeline-{scale}'; 50 51 // Defaults 52 protected const DEFAULT_SCALE = 10; 53 protected const DEFAULT_PARAMETERS = [ 54 'scale' => self::DEFAULT_SCALE, 55 ]; 56 57 // Limits 58 protected const MINIMUM_SCALE = 1; 59 protected const MAXIMUM_SCALE = 200; 60 61 // GEDCOM events that may have DATE data, but should not be displayed 62 protected const NON_FACTS = [ 63 'FAM:CHAN', 64 'INDI:BAPL', 65 'INDI:CHAN', 66 'INDI:ENDL', 67 'INDI:SLGC', 68 'INDI:SLGS', 69 'INDI:_TODO', 70 ]; 71 72 protected const BOX_HEIGHT = 30; 73 74 /** 75 * Initialization. 76 * 77 * @return void 78 */ 79 public function boot(): void 80 { 81 $router_container = app(RouterContainer::class); 82 assert($router_container instanceof RouterContainer); 83 84 $router_container->getMap() 85 ->get(static::class, static::ROUTE_URL, $this) 86 ->allows(RequestMethodInterface::METHOD_POST); 87 } 88 89 /** 90 * How should this module be identified in the control panel, etc.? 91 * 92 * @return string 93 */ 94 public function title(): string 95 { 96 /* I18N: Name of a module/chart */ 97 return I18N::translate('Timeline'); 98 } 99 100 /** 101 * A sentence describing what this module does. 102 * 103 * @return string 104 */ 105 public function description(): string 106 { 107 /* I18N: Description of the “TimelineChart” module */ 108 return I18N::translate('A timeline displaying individual events.'); 109 } 110 111 /** 112 * CSS class for the URL. 113 * 114 * @return string 115 */ 116 public function chartMenuClass(): string 117 { 118 return 'menu-chart-timeline'; 119 } 120 121 /** 122 * The URL for this chart. 123 * 124 * @param Individual $individual 125 * @param array<int|string> $parameters 126 * 127 * @return string 128 */ 129 public function chartUrl(Individual $individual, array $parameters = []): string 130 { 131 return route(static::class, [ 132 'tree' => $individual->tree()->name(), 133 'xrefs' => [$individual->xref()], 134 ] + $parameters + self::DEFAULT_PARAMETERS); 135 } 136 137 /** 138 * @param ServerRequestInterface $request 139 * 140 * @return ResponseInterface 141 */ 142 public function handle(ServerRequestInterface $request): ResponseInterface 143 { 144 $tree = $request->getAttribute('tree'); 145 assert($tree instanceof Tree); 146 147 $user = $request->getAttribute('user'); 148 $scale = (int) $request->getAttribute('scale'); 149 $xrefs = $request->getQueryParams()['xrefs'] ?? []; 150 $ajax = $request->getQueryParams()['ajax'] ?? ''; 151 152 153 $params = (array) $request->getParsedBody(); 154 155 $add = $params['add'] ?? ''; 156 157 Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user); 158 159 $scale = min($scale, self::MAXIMUM_SCALE); 160 $scale = max($scale, self::MINIMUM_SCALE); 161 162 $xrefs[] = $add; 163 $xrefs = array_filter(array_unique($xrefs)); 164 165 // Convert POST requests into GET requests for pretty URLs. 166 if ($request->getMethod() === RequestMethodInterface::METHOD_POST) { 167 return redirect(route(static::class, [ 168 'scale' => $scale, 169 'tree' => $tree->name(), 170 'xrefs' => $xrefs, 171 ])); 172 } 173 174 // Find the requested individuals. 175 $individuals = (new Collection($xrefs)) 176 ->uniqueStrict() 177 ->map(static function (string $xref) use ($tree): ?Individual { 178 return Registry::individualFactory()->make($xref, $tree); 179 }) 180 ->filter() 181 ->filter(GedcomRecord::accessFilter()); 182 183 // Generate URLs omitting each xref. 184 $remove_urls = []; 185 186 foreach ($individuals as $exclude) { 187 $xrefs_1 = $individuals 188 ->filter(static function (Individual $individual) use ($exclude): bool { 189 return $individual->xref() !== $exclude->xref(); 190 }) 191 ->map(static function (Individual $individual): string { 192 return $individual->xref(); 193 }); 194 195 $remove_urls[$exclude->xref()] = route(static::class, [ 196 'tree' => $tree->name(), 197 'scale' => $scale, 198 'xrefs' => $xrefs_1->all(), 199 ]); 200 } 201 202 $individuals = array_map(static function (string $xref) use ($tree): ?Individual { 203 return Registry::individualFactory()->make($xref, $tree); 204 }, $xrefs); 205 206 $individuals = array_filter($individuals, static function (?Individual $individual): bool { 207 return $individual instanceof Individual && $individual->canShow(); 208 }); 209 210 Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user); 211 212 if ($ajax === '1') { 213 $this->layout = 'layouts/ajax'; 214 215 return $this->chart($tree, $xrefs, $scale); 216 } 217 218 $reset_url = route(static::class, [ 219 'scale' => self::DEFAULT_SCALE, 220 'tree' => $tree->name(), 221 ]); 222 223 $zoom_in_url = route(static::class, [ 224 'scale' => min(self::MAXIMUM_SCALE, $scale + (int) ($scale * 0.4 + 1)), 225 'tree' => $tree->name(), 226 'xrefs' => $xrefs, 227 ]); 228 229 $zoom_out_url = route(static::class, [ 230 'scale' => max(self::MINIMUM_SCALE, $scale - (int) ($scale * 0.4 + 1)), 231 'tree' => $tree->name(), 232 'xrefs' => $xrefs, 233 ]); 234 235 $ajax_url = route(static::class, [ 236 'ajax' => true, 237 'scale' => $scale, 238 'tree' => $tree->name(), 239 'xrefs' => $xrefs, 240 ]); 241 242 return $this->viewResponse('modules/timeline-chart/page', [ 243 'ajax_url' => $ajax_url, 244 'individuals' => $individuals, 245 'module' => $this->name(), 246 'remove_urls' => $remove_urls, 247 'reset_url' => $reset_url, 248 'scale' => $scale, 249 'title' => $this->title(), 250 'tree' => $tree, 251 'zoom_in_url' => $zoom_in_url, 252 'zoom_out_url' => $zoom_out_url, 253 ]); 254 } 255 256 /** 257 * @param Tree $tree 258 * @param array $xrefs 259 * @param int $scale 260 * 261 * @return ResponseInterface 262 */ 263 protected function chart(Tree $tree, array $xrefs, int $scale): ResponseInterface 264 { 265 /** @var Individual[] $individuals */ 266 $individuals = array_map(static function (string $xref) use ($tree): ?Individual { 267 return Registry::individualFactory()->make($xref, $tree); 268 }, $xrefs); 269 270 $individuals = array_filter($individuals, static function (?Individual $individual): bool { 271 return $individual instanceof Individual && $individual->canShow(); 272 }); 273 274 $baseyear = (int) date('Y'); 275 $topyear = 0; 276 $indifacts = new Collection(); 277 $birthyears = []; 278 $birthmonths = []; 279 $birthdays = []; 280 281 foreach ($individuals as $individual) { 282 $bdate = $individual->getBirthDate(); 283 if ($bdate->isOK()) { 284 $date = new GregorianDate($bdate->minimumJulianDay()); 285 286 $birthyears [$individual->xref()] = $date->year; 287 $birthmonths[$individual->xref()] = max(1, $date->month); 288 $birthdays [$individual->xref()] = max(1, $date->day); 289 } 290 // find all the fact information 291 $facts = $individual->facts(); 292 foreach ($individual->spouseFamilies() as $family) { 293 foreach ($family->facts() as $fact) { 294 $facts->push($fact); 295 } 296 } 297 298 foreach ($facts as $event) { 299 if (!in_array($event->tag(), self::NON_FACTS, true)) { 300 // check for a date 301 $date = $event->date(); 302 if ($date->isOK()) { 303 $date = new GregorianDate($date->minimumJulianDay()); 304 $baseyear = min($baseyear, $date->year); 305 $topyear = max($topyear, $date->year); 306 307 if (!$individual->isDead()) { 308 $topyear = max($topyear, (int) date('Y')); 309 } 310 311 $indifacts->push($event); 312 } 313 } 314 } 315 } 316 317 // do not add the same fact twice (prevents marriages from being added multiple times) 318 $indifacts = $indifacts->uniqueStrict(static function (Fact $fact): string { 319 return $fact->id(); 320 }); 321 322 if ($scale === 0) { 323 $scale = (int) (($topyear - $baseyear) / 20 * $indifacts->count() / 4); 324 if ($scale < 6) { 325 $scale = 6; 326 } 327 } 328 if ($scale < 2) { 329 $scale = 2; 330 } 331 $baseyear -= 5; 332 $topyear += 5; 333 334 $indifacts = Fact::sortFacts($indifacts); 335 336 $html = view('modules/timeline-chart/chart', [ 337 'baseyear' => $baseyear, 338 'bheight' => self::BOX_HEIGHT, 339 'birthdays' => $birthdays, 340 'birthmonths' => $birthmonths, 341 'birthyears' => $birthyears, 342 'indifacts' => $indifacts, 343 'individuals' => $individuals, 344 'placements' => [], 345 'scale' => $scale, 346 'topyear' => $topyear, 347 ]); 348 349 return response($html); 350 } 351} 352