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 Fig\Http\Message\StatusCodeInterface; 25use Fisharebest\Webtrees\Auth; 26use Fisharebest\Webtrees\Fact; 27use Fisharebest\Webtrees\Family; 28use Fisharebest\Webtrees\Functions\Functions; 29use Fisharebest\Webtrees\GedcomTag; 30use Fisharebest\Webtrees\I18N; 31use Fisharebest\Webtrees\Individual; 32use Fisharebest\Webtrees\Location; 33use Fisharebest\Webtrees\Menu; 34use Fisharebest\Webtrees\Services\ChartService; 35use Fisharebest\Webtrees\Tree; 36use Psr\Http\Message\ResponseInterface; 37use Psr\Http\Message\ServerRequestInterface; 38use Psr\Http\Server\RequestHandlerInterface; 39 40use function app; 41use function assert; 42use function intdiv; 43use function is_string; 44use function redirect; 45use function route; 46use function view; 47 48/** 49 * Class PedigreeMapModule 50 */ 51class PedigreeMapModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface 52{ 53 use ModuleChartTrait; 54 55 private const ROUTE_NAME = 'pedigree-map'; 56 private const ROUTE_URL = '/tree/{tree}/pedigree-map-{generations}/{xref}'; 57 58 // Defaults 59 public const DEFAULT_GENERATIONS = '4'; 60 public const DEFAULT_PARAMETERS = [ 61 'generations' => self::DEFAULT_GENERATIONS, 62 ]; 63 64 // Limits 65 public const MAXIMUM_GENERATIONS = 10; 66 67 private const LINE_COLORS = [ 68 '#FF0000', 69 // Red 70 '#00FF00', 71 // Green 72 '#0000FF', 73 // Blue 74 '#FFB300', 75 // Gold 76 '#00FFFF', 77 // Cyan 78 '#FF00FF', 79 // Purple 80 '#7777FF', 81 // Light blue 82 '#80FF80' 83 // Light green 84 ]; 85 86 /** @var ChartService */ 87 private $chart_service; 88 89 /** 90 * PedigreeMapModule constructor. 91 * 92 * @param ChartService $chart_service 93 */ 94 public function __construct(ChartService $chart_service) 95 { 96 $this->chart_service = $chart_service; 97 } 98 99 /** 100 * Initialization. 101 * 102 * @return void 103 */ 104 public function boot(): void 105 { 106 $router_container = app(RouterContainer::class); 107 assert($router_container instanceof RouterContainer); 108 109 $router_container->getMap() 110 ->get(self::ROUTE_NAME, self::ROUTE_URL, $this) 111 ->allows(RequestMethodInterface::METHOD_POST) 112 ->tokens([ 113 'generations' => '\d+', 114 ]); 115 } 116 117 /** 118 * How should this module be identified in the control panel, etc.? 119 * 120 * @return string 121 */ 122 public function title(): string 123 { 124 /* I18N: Name of a module */ 125 return I18N::translate('Pedigree map'); 126 } 127 128 /** 129 * A sentence describing what this module does. 130 * 131 * @return string 132 */ 133 public function description(): string 134 { 135 /* I18N: Description of the “Pedigree map” module */ 136 return I18N::translate('Show the birthplace of ancestors on a map.'); 137 } 138 139 /** 140 * CSS class for the URL. 141 * 142 * @return string 143 */ 144 public function chartMenuClass(): string 145 { 146 return 'menu-chart-pedigreemap'; 147 } 148 149 /** 150 * Return a menu item for this chart - for use in individual boxes. 151 * 152 * @param Individual $individual 153 * 154 * @return Menu|null 155 */ 156 public function chartBoxMenu(Individual $individual): ?Menu 157 { 158 return $this->chartMenu($individual); 159 } 160 161 /** 162 * The title for a specific instance of this chart. 163 * 164 * @param Individual $individual 165 * 166 * @return string 167 */ 168 public function chartTitle(Individual $individual): string 169 { 170 /* I18N: %s is an individual’s name */ 171 return I18N::translate('Pedigree map of %s', $individual->fullName()); 172 } 173 174 /** 175 * The URL for a page showing chart options. 176 * 177 * @param Individual $individual 178 * @param mixed[] $parameters 179 * 180 * @return string 181 */ 182 public function chartUrl(Individual $individual, array $parameters = []): string 183 { 184 return route(self::ROUTE_NAME, [ 185 'tree' => $individual->tree()->name(), 186 'xref' => $individual->xref(), 187 ] + $parameters + self::DEFAULT_PARAMETERS); 188 } 189 190 /** 191 * @param ServerRequestInterface $request 192 * 193 * @return ResponseInterface 194 */ 195 public function getMapDataAction(ServerRequestInterface $request): ResponseInterface 196 { 197 $tree = $request->getAttribute('tree'); 198 assert($tree instanceof Tree); 199 200 $xref = $request->getQueryParams()['xref']; 201 $individual = Individual::getInstance($xref, $tree); 202 $color_count = count(self::LINE_COLORS); 203 204 $facts = $this->getPedigreeMapFacts($request, $this->chart_service); 205 206 $geojson = [ 207 'type' => 'FeatureCollection', 208 'features' => [], 209 ]; 210 211 $sosa_points = []; 212 213 foreach ($facts as $id => $fact) { 214 $location = new Location($fact->place()->gedcomName()); 215 216 // Use the co-ordinates from the fact (if they exist). 217 $latitude = $fact->latitude(); 218 $longitude = $fact->longitude(); 219 220 // Use the co-ordinates from the location otherwise. 221 if ($latitude === 0.0 && $longitude === 0.0) { 222 $latitude = $location->latitude(); 223 $longitude = $location->longitude(); 224 } 225 226 $icon = ['color' => 'Gold', 'name' => 'bullseye ']; 227 if ($latitude !== 0.0 || $longitude !== 0.0) { 228 $polyline = null; 229 $color = self::LINE_COLORS[log($id, 2) % $color_count]; 230 $icon['color'] = $color; //make icon color the same as the line 231 $sosa_points[$id] = [$latitude, $longitude]; 232 $sosa_parent = intdiv($id, 2); 233 if (array_key_exists($sosa_parent, $sosa_points)) { 234 // Would like to use a GeometryCollection to hold LineStrings 235 // rather than generate polylines but the MarkerCluster library 236 // doesn't seem to like them 237 $polyline = [ 238 'points' => [ 239 $sosa_points[$sosa_parent], 240 [$latitude, $longitude], 241 ], 242 'options' => [ 243 'color' => $color, 244 ], 245 ]; 246 } 247 $geojson['features'][] = [ 248 'type' => 'Feature', 249 'id' => $id, 250 'valid' => true, 251 'geometry' => [ 252 'type' => 'Point', 253 'coordinates' => [$longitude, $latitude], 254 ], 255 'properties' => [ 256 'polyline' => $polyline, 257 'icon' => $icon, 258 'tooltip' => strip_tags($fact->place()->fullName()), 259 'summary' => view('modules/pedigree-map/events', $this->summaryData($individual, $fact, $id)), 260 'zoom' => $location->zoom() ?: 2, 261 ], 262 ]; 263 } 264 } 265 266 $code = $facts === [] ? StatusCodeInterface::STATUS_NO_CONTENT : StatusCodeInterface::STATUS_OK; 267 268 return response($geojson, $code); 269 } 270 271 /** 272 * @param ServerRequestInterface $request 273 * 274 * @return ResponseInterface 275 */ 276 public function handle(ServerRequestInterface $request): ResponseInterface 277 { 278 $tree = $request->getAttribute('tree'); 279 assert($tree instanceof Tree); 280 281 $xref = $request->getAttribute('xref'); 282 assert(is_string($xref)); 283 284 $individual = Individual::getInstance($xref, $tree); 285 $individual = Auth::checkIndividualAccess($individual); 286 287 $user = $request->getAttribute('user'); 288 $generations = (int) $request->getAttribute('generations'); 289 Auth::checkComponentAccess($this, 'chart', $tree, $user); 290 291 // Convert POST requests into GET requests for pretty URLs. 292 if ($request->getMethod() === RequestMethodInterface::METHOD_POST) { 293 return redirect(route(self::ROUTE_NAME, [ 294 'tree' => $tree->name(), 295 'xref' => $request->getParsedBody()['xref'], 296 'generations' => $request->getParsedBody()['generations'], 297 ])); 298 } 299 300 $map = view('modules/pedigree-map/chart', [ 301 'individual' => $individual, 302 'generations' => $generations, 303 ]); 304 305 return $this->viewResponse('modules/pedigree-map/page', [ 306 'module' => $this->name(), 307 /* I18N: %s is an individual’s name */ 308 'title' => I18N::translate('Pedigree map of %s', $individual->fullName()), 309 'tree' => $tree, 310 'individual' => $individual, 311 'generations' => $generations, 312 'maxgenerations' => self::MAXIMUM_GENERATIONS, 313 'map' => $map, 314 ]); 315 } 316 317 /** 318 * @param ServerRequestInterface $request 319 * @param ChartService $chart_service 320 * 321 * @return array 322 */ 323 private function getPedigreeMapFacts(ServerRequestInterface $request, ChartService $chart_service): array 324 { 325 $tree = $request->getAttribute('tree'); 326 assert($tree instanceof Tree); 327 328 $generations = (int) $request->getQueryParams()['generations']; 329 $xref = $request->getQueryParams()['xref']; 330 $individual = Individual::getInstance($xref, $tree); 331 $ancestors = $chart_service->sosaStradonitzAncestors($individual, $generations); 332 $facts = []; 333 foreach ($ancestors as $sosa => $person) { 334 if ($person->canShow()) { 335 $birth = $person->facts(['BIRT'])->first(); 336 if ($birth instanceof Fact && $birth->place()->gedcomName() !== '') { 337 $facts[$sosa] = $birth; 338 } 339 } 340 } 341 342 return $facts; 343 } 344 345 /** 346 * @param Individual $individual 347 * @param Fact $fact 348 * @param int $sosa 349 * 350 * @return array 351 */ 352 private function summaryData(Individual $individual, Fact $fact, int $sosa): array 353 { 354 $record = $fact->record(); 355 $name = ''; 356 $url = ''; 357 $tag = $fact->label(); 358 $addbirthtag = false; 359 360 if ($record instanceof Family) { 361 // Marriage 362 $spouse = $record->spouse($individual); 363 if ($spouse) { 364 $url = $spouse->url(); 365 $name = $spouse->fullName(); 366 } 367 } elseif ($record !== $individual) { 368 // Birth of a child 369 $url = $record->url(); 370 $name = $record->fullName(); 371 $tag = GedcomTag::getLabel('_BIRT_CHIL', $record); 372 } 373 374 if ($sosa > 1) { 375 $addbirthtag = true; 376 $tag = ucfirst($this->getSosaName($sosa)); 377 } 378 379 return [ 380 'tag' => $tag, 381 'url' => $url, 382 'name' => $name, 383 'value' => $fact->value(), 384 'date' => $fact->date()->display(true), 385 'place' => $fact->place(), 386 'addtag' => $addbirthtag, 387 ]; 388 } 389 390 /** 391 * builds and returns sosa relationship name in the active language 392 * 393 * @param int $sosa Sosa number 394 * 395 * @return string 396 */ 397 private function getSosaName(int $sosa): string 398 { 399 $path = ''; 400 401 while ($sosa > 1) { 402 if ($sosa % 2 === 1) { 403 $path = 'mot' . $path; 404 } else { 405 $path = 'fat' . $path; 406 } 407 $sosa = intdiv($sosa, 2); 408 } 409 410 return Functions::getRelationshipNameFromPath($path); 411 } 412} 413