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