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 function (string $xref) use ($tree): ?Individual { 164 return Registry::individualFactory()->make($xref, $tree); 165 }) 166 ->filter() 167 ->filter(GedcomRecord::accessFilter()); 168 169 // Generate URLs omitting each xref. 170 $remove_urls = []; 171 172 foreach ($individuals as $exclude) { 173 $xrefs_1 = $individuals 174 ->filter(static function (Individual $individual) use ($exclude): bool { 175 return $individual->xref() !== $exclude->xref(); 176 }) 177 ->map(static function (Individual $individual): string { 178 return $individual->xref(); 179 }); 180 181 $remove_urls[$exclude->xref()] = route(static::class, [ 182 'tree' => $tree->name(), 183 'scale' => $scale, 184 'xrefs' => $xrefs_1->all(), 185 ]); 186 } 187 188 $individuals = array_map(static function (string $xref) use ($tree): ?Individual { 189 return Registry::individualFactory()->make($xref, $tree); 190 }, $xrefs); 191 192 $individuals = array_filter($individuals, static function (?Individual $individual): bool { 193 return $individual instanceof Individual && $individual->canShow(); 194 }); 195 196 if ($ajax) { 197 $this->layout = 'layouts/ajax'; 198 199 return $this->chart($tree, $xrefs, $scale); 200 } 201 202 $reset_url = route(static::class, [ 203 'scale' => self::DEFAULT_SCALE, 204 'tree' => $tree->name(), 205 ]); 206 207 $zoom_in_url = route(static::class, [ 208 'scale' => min(self::MAXIMUM_SCALE, $scale + (int) ($scale * 0.4 + 1)), 209 'tree' => $tree->name(), 210 'xrefs' => $xrefs, 211 ]); 212 213 $zoom_out_url = route(static::class, [ 214 'scale' => max(self::MINIMUM_SCALE, $scale - (int) ($scale * 0.4 + 1)), 215 'tree' => $tree->name(), 216 'xrefs' => $xrefs, 217 ]); 218 219 $ajax_url = route(static::class, [ 220 'ajax' => true, 221 'scale' => $scale, 222 'tree' => $tree->name(), 223 'xrefs' => $xrefs, 224 ]); 225 226 return $this->viewResponse('modules/timeline-chart/page', [ 227 'ajax_url' => $ajax_url, 228 'individuals' => $individuals, 229 'module' => $this->name(), 230 'remove_urls' => $remove_urls, 231 'reset_url' => $reset_url, 232 'scale' => $scale, 233 'title' => $this->title(), 234 'tree' => $tree, 235 'zoom_in_url' => $zoom_in_url, 236 'zoom_out_url' => $zoom_out_url, 237 ]); 238 } 239 240 /** 241 * @param Tree $tree 242 * @param array<string> $xrefs 243 * @param int $scale 244 * 245 * @return ResponseInterface 246 */ 247 protected function chart(Tree $tree, array $xrefs, int $scale): ResponseInterface 248 { 249 /** @var Individual[] $individuals */ 250 $individuals = array_map(static function (string $xref) use ($tree): ?Individual { 251 return Registry::individualFactory()->make($xref, $tree); 252 }, $xrefs); 253 254 $individuals = array_filter($individuals, static function (?Individual $individual): bool { 255 return $individual instanceof Individual && $individual->canShow(); 256 }); 257 258 $baseyear = (int) date('Y'); 259 $topyear = 0; 260 $indifacts = new Collection(); 261 $birthyears = []; 262 $birthmonths = []; 263 $birthdays = []; 264 265 foreach ($individuals as $individual) { 266 $bdate = $individual->getBirthDate(); 267 if ($bdate->isOK()) { 268 $date = new GregorianDate($bdate->minimumJulianDay()); 269 270 $birthyears [$individual->xref()] = $date->year; 271 $birthmonths[$individual->xref()] = max(1, $date->month); 272 $birthdays [$individual->xref()] = max(1, $date->day); 273 } 274 // find all the fact information 275 $facts = $individual->facts(); 276 foreach ($individual->spouseFamilies() as $family) { 277 foreach ($family->facts() as $fact) { 278 $facts->push($fact); 279 } 280 } 281 282 foreach ($facts as $event) { 283 if (!in_array($event->tag(), self::NON_FACTS, true)) { 284 // check for a date 285 $date = $event->date(); 286 if ($date->isOK()) { 287 $date = new GregorianDate($date->minimumJulianDay()); 288 $baseyear = min($baseyear, $date->year); 289 $topyear = max($topyear, $date->year); 290 291 if (!$individual->isDead()) { 292 $topyear = max($topyear, (int) date('Y')); 293 } 294 295 $indifacts->push($event); 296 } 297 } 298 } 299 } 300 301 // do not add the same fact twice (prevents marriages from being added multiple times) 302 $indifacts = $indifacts->uniqueStrict(static function (Fact $fact): string { 303 return $fact->id(); 304 }); 305 306 if ($scale === 0) { 307 $scale = (int) (($topyear - $baseyear) / 20 * $indifacts->count() / 4); 308 if ($scale < 6) { 309 $scale = 6; 310 } 311 } 312 if ($scale < 2) { 313 $scale = 2; 314 } 315 $baseyear -= 5; 316 $topyear += 5; 317 318 $indifacts = Fact::sortFacts($indifacts); 319 320 $html = view('modules/timeline-chart/chart', [ 321 'baseyear' => $baseyear, 322 'bheight' => self::BOX_HEIGHT, 323 'birthdays' => $birthdays, 324 'birthmonths' => $birthmonths, 325 'birthyears' => $birthyears, 326 'indifacts' => $indifacts, 327 'individuals' => $individuals, 328 'placements' => [], 329 'scale' => $scale, 330 'topyear' => $topyear, 331 ]); 332 333 return response($html); 334 } 335} 336