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