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