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 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 array<bool|int|string|array<string>|null> $parameters 117 * 118 * @return string 119 */ 120 public function chartUrl(Individual $individual, array $parameters = []): string 121 { 122 return route(static::class, [ 123 'tree' => $individual->tree()->name(), 124 'xrefs' => [$individual->xref()], 125 ] + $parameters + self::DEFAULT_PARAMETERS); 126 } 127 128 /** 129 * @param ServerRequestInterface $request 130 * 131 * @return ResponseInterface 132 */ 133 public function handle(ServerRequestInterface $request): ResponseInterface 134 { 135 $tree = Validator::attributes($request)->tree(); 136 $user = Validator::attributes($request)->user(); 137 $scale = Validator::attributes($request)->isBetween(self::MINIMUM_SCALE, self::MAXIMUM_SCALE)->integer('scale'); 138 $xrefs = Validator::queryParams($request)->array('xrefs'); 139 $ajax = Validator::queryParams($request)->boolean('ajax', false); 140 $xrefs = array_filter(array_unique($xrefs)); 141 142 // Convert POST requests into GET requests for pretty URLs. 143 if ($request->getMethod() === RequestMethodInterface::METHOD_POST) { 144 $xrefs[] = Validator::parsedBody($request)->isXref()->string('add', ''); 145 146 return redirect(route(static::class, [ 147 'tree' => $tree->name(), 148 'scale' => $scale, 149 'xrefs' => $xrefs, 150 ])); 151 } 152 153 Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user); 154 155 // Find the requested individuals. 156 $individuals = (new Collection($xrefs)) 157 ->uniqueStrict() 158 ->map(static fn (string $xref): Individual|null => Registry::individualFactory()->make($xref, $tree)) 159 ->filter() 160 ->filter(GedcomRecord::accessFilter()); 161 162 // Generate URLs omitting each xref. 163 $remove_urls = []; 164 165 foreach ($individuals as $exclude) { 166 $xrefs_1 = $individuals 167 ->filter(static fn (Individual $individual): bool => $individual->xref() !== $exclude->xref()) 168 ->map(static fn (Individual $individual): string => $individual->xref()); 169 170 $remove_urls[$exclude->xref()] = route(static::class, [ 171 'tree' => $tree->name(), 172 'scale' => $scale, 173 'xrefs' => $xrefs_1->all(), 174 ]); 175 } 176 177 $individuals = array_map(static fn (string $xref): Individual|null => Registry::individualFactory()->make($xref, $tree), $xrefs); 178 179 $individuals = array_filter($individuals, static fn (Individual|null $individual): bool => $individual instanceof Individual && $individual->canShow()); 180 181 if ($ajax) { 182 $this->layout = 'layouts/ajax'; 183 184 return $this->chart($tree, $xrefs, $scale); 185 } 186 187 $reset_url = route(static::class, [ 188 'scale' => self::DEFAULT_SCALE, 189 'tree' => $tree->name(), 190 ]); 191 192 $zoom_in_url = route(static::class, [ 193 'scale' => min(self::MAXIMUM_SCALE, $scale + (int) ($scale * 0.4 + 1)), 194 'tree' => $tree->name(), 195 'xrefs' => $xrefs, 196 ]); 197 198 $zoom_out_url = route(static::class, [ 199 'scale' => max(self::MINIMUM_SCALE, $scale - (int) ($scale * 0.4 + 1)), 200 'tree' => $tree->name(), 201 'xrefs' => $xrefs, 202 ]); 203 204 $ajax_url = route(static::class, [ 205 'ajax' => true, 206 'scale' => $scale, 207 'tree' => $tree->name(), 208 'xrefs' => $xrefs, 209 ]); 210 211 return $this->viewResponse('modules/timeline-chart/page', [ 212 'ajax_url' => $ajax_url, 213 'individuals' => $individuals, 214 'module' => $this->name(), 215 'remove_urls' => $remove_urls, 216 'reset_url' => $reset_url, 217 'scale' => $scale, 218 'title' => $this->title(), 219 'tree' => $tree, 220 'zoom_in_url' => $zoom_in_url, 221 'zoom_out_url' => $zoom_out_url, 222 ]); 223 } 224 225 /** 226 * @param Tree $tree 227 * @param array<string> $xrefs 228 * @param int $scale 229 * 230 * @return ResponseInterface 231 */ 232 protected function chart(Tree $tree, array $xrefs, int $scale): ResponseInterface 233 { 234 /** @var Individual[] $individuals */ 235 $individuals = array_map(static fn (string $xref): Individual|null => Registry::individualFactory()->make($xref, $tree), $xrefs); 236 237 $individuals = array_filter($individuals, static fn (Individual|null $individual): bool => $individual instanceof Individual && $individual->canShow()); 238 239 $baseyear = (int) date('Y'); 240 $topyear = 0; 241 $indifacts = new Collection(); 242 $birthyears = []; 243 $birthmonths = []; 244 $birthdays = []; 245 246 foreach ($individuals as $individual) { 247 $bdate = $individual->getBirthDate(); 248 if ($bdate->isOK()) { 249 $date = new GregorianDate($bdate->minimumJulianDay()); 250 251 $birthyears [$individual->xref()] = $date->year; 252 $birthmonths[$individual->xref()] = max(1, $date->month); 253 $birthdays [$individual->xref()] = max(1, $date->day); 254 } 255 // find all the fact information 256 $facts = $individual->facts(); 257 foreach ($individual->spouseFamilies() as $family) { 258 foreach ($family->facts() as $fact) { 259 $facts->push($fact); 260 } 261 } 262 263 foreach ($facts as $event) { 264 if (!in_array($event->tag(), self::NON_FACTS, true)) { 265 // check for a date 266 $date = $event->date(); 267 if ($date->isOK()) { 268 $date = new GregorianDate($date->minimumJulianDay()); 269 $baseyear = min($baseyear, $date->year); 270 $topyear = max($topyear, $date->year); 271 272 if (!$individual->isDead()) { 273 $topyear = max($topyear, (int) date('Y')); 274 } 275 276 $indifacts->push($event); 277 } 278 } 279 } 280 } 281 282 // do not add the same fact twice (prevents marriages from being added multiple times) 283 $indifacts = $indifacts->uniqueStrict(static fn (Fact $fact): string => $fact->id()); 284 285 if ($scale === 0) { 286 $scale = (int) (($topyear - $baseyear) / 20 * $indifacts->count() / 4); 287 if ($scale < 6) { 288 $scale = 6; 289 } 290 } 291 if ($scale < 2) { 292 $scale = 2; 293 } 294 $baseyear -= 5; 295 $topyear += 5; 296 297 $indifacts = Fact::sortFacts($indifacts); 298 299 $html = view('modules/timeline-chart/chart', [ 300 'baseyear' => $baseyear, 301 'bheight' => self::BOX_HEIGHT, 302 'birthdays' => $birthdays, 303 'birthmonths' => $birthmonths, 304 'birthyears' => $birthyears, 305 'indifacts' => $indifacts, 306 'individuals' => $individuals, 307 'placements' => [], 308 'scale' => $scale, 309 'topyear' => $topyear, 310 ]); 311 312 return response($html); 313 } 314} 315