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