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