1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2019 webtrees development team 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * You should have received a copy of the GNU General Public License 14 * along with this program. If not, see <http://www.gnu.org/licenses/>. 15 */ 16declare(strict_types=1); 17 18namespace Fisharebest\Webtrees\Module; 19 20use Exception; 21use Fisharebest\Webtrees\Exceptions\IndividualAccessDeniedException; 22use Fisharebest\Webtrees\Exceptions\IndividualNotFoundException; 23use Fisharebest\Webtrees\Fact; 24use Fisharebest\Webtrees\Family; 25use Fisharebest\Webtrees\Functions\FunctionsCharts; 26use Fisharebest\Webtrees\GedcomTag; 27use Fisharebest\Webtrees\I18N; 28use Fisharebest\Webtrees\Individual; 29use Fisharebest\Webtrees\Location; 30use Fisharebest\Webtrees\Menu; 31use Fisharebest\Webtrees\Services\ChartService; 32use Fisharebest\Webtrees\Tree; 33use Fisharebest\Webtrees\Webtrees; 34use Symfony\Component\HttpFoundation\JsonResponse; 35use Symfony\Component\HttpFoundation\Request; 36use Symfony\Component\HttpFoundation\Response; 37 38/** 39 * Class PedigreeMapModule 40 */ 41class PedigreeMapModule extends AbstractModule implements ModuleChartInterface 42{ 43 use ModuleChartTrait; 44 45 private const LINE_COLORS = [ 46 '#FF0000', 47 // Red 48 '#00FF00', 49 // Green 50 '#0000FF', 51 // Blue 52 '#FFB300', 53 // Gold 54 '#00FFFF', 55 // Cyan 56 '#FF00FF', 57 // Purple 58 '#7777FF', 59 // Light blue 60 '#80FF80' 61 // Light green 62 ]; 63 64 private static $map_providers = null; 65 private static $map_selections = null; 66 67 /** 68 * How should this module be labelled on tabs, menus, etc.? 69 * 70 * @return string 71 */ 72 public function title(): string 73 { 74 /* I18N: Name of a module */ 75 return I18N::translate('Pedigree map'); 76 } 77 78 /** 79 * A sentence describing what this module does. 80 * 81 * @return string 82 */ 83 public function description(): string 84 { 85 /* I18N: Description of the “OSM” module */ 86 return I18N::translate('Show the birthplace of ancestors on a map.'); 87 } 88 89 /** 90 * CSS class for the URL. 91 * 92 * @return string 93 */ 94 public function chartMenuClass(): string 95 { 96 return 'menu-chart-pedigreemap'; 97 } 98 99 /** 100 * Return a menu item for this chart - for use in individual boxes. 101 * 102 * @param Individual $individual 103 * 104 * @return Menu|null 105 */ 106 public function chartBoxMenu(Individual $individual): ?Menu 107 { 108 return $this->chartMenu($individual); 109 } 110 111 /** 112 * The title for a specific instance of this chart. 113 * 114 * @param Individual $individual 115 * 116 * @return string 117 */ 118 public function chartTitle(Individual $individual): string 119 { 120 /* I18N: %s is an individual’s name */ 121 return I18N::translate('Pedigree map of %s', $individual->getFullName()); 122 } 123 124 /** 125 * The URL for this chart. 126 * 127 * @param Individual $individual 128 * @param string[] $parameters 129 * 130 * @return string 131 */ 132 public function chartUrl(Individual $individual, array $parameters = []): string 133 { 134 return route('module', [ 135 'module' => $this->name(), 136 'action' => 'PedigreeMap', 137 'xref' => $individual->xref(), 138 'ged' => $individual->tree()->name(), 139 ] + $parameters); 140 } 141 142 /** 143 * @param Request $request 144 * @param Tree $tree 145 * @param ChartService $chart_service 146 * 147 * @return JsonResponse 148 */ 149 public function getMapDataAction(Request $request, Tree $tree, ChartService $chart_service): JsonResponse 150 { 151 $xref = $request->get('reference'); 152 $indi = Individual::getInstance($xref, $tree); 153 $color_count = count(self::LINE_COLORS); 154 155 $facts = $this->getPedigreeMapFacts($request, $tree, $chart_service); 156 157 $geojson = [ 158 'type' => 'FeatureCollection', 159 'features' => [], 160 ]; 161 162 $sosa_points = []; 163 164 foreach ($facts as $id => $fact) { 165 $location = new Location($fact->place()->gedcomName()); 166 167 // Use the co-ordinates from the fact (if they exist). 168 $latitude = $fact->latitude(); 169 $longitude = $fact->longitude(); 170 171 // Use the co-ordinates from the location otherwise. 172 if ($latitude === 0.0 && $longitude === 0.0) { 173 $latitude = $location->latitude(); 174 $longitude = $location->longitude(); 175 } 176 177 $icon = ['color' => 'Gold', 'name' => 'bullseye ']; 178 if ($latitude !== 0.0 || $longitude !== 0.0) { 179 $polyline = null; 180 $color = self::LINE_COLORS[log($id, 2) % $color_count]; 181 $icon['color'] = $color; //make icon color the same as the line 182 $sosa_points[$id] = [$latitude, $longitude]; 183 $sosa_parent = intdiv($id, 2); 184 if (array_key_exists($sosa_parent, $sosa_points)) { 185 // Would like to use a GeometryCollection to hold LineStrings 186 // rather than generate polylines but the MarkerCluster library 187 // doesn't seem to like them 188 $polyline = [ 189 'points' => [ 190 $sosa_points[$sosa_parent], 191 [$latitude, $longitude], 192 ], 193 'options' => [ 194 'color' => $color, 195 ], 196 ]; 197 } 198 $geojson['features'][] = [ 199 'type' => 'Feature', 200 'id' => $id, 201 'valid' => true, 202 'geometry' => [ 203 'type' => 'Point', 204 'coordinates' => [$longitude, $latitude], 205 ], 206 'properties' => [ 207 'polyline' => $polyline, 208 'icon' => $icon, 209 'tooltip' => strip_tags($fact->place()->fullName()), 210 'summary' => view('modules/pedigree-map/events', $this->summaryData($indi, $fact, $id)), 211 'zoom' => $location->zoom() ?: 2, 212 ], 213 ]; 214 } 215 } 216 217 $code = empty($facts) ? Response::HTTP_NO_CONTENT : Response::HTTP_OK; 218 219 return new JsonResponse($geojson, $code); 220 } 221 222 /** 223 * @param Individual $individual 224 * @param Fact $fact 225 * @param int $sosa 226 * 227 * @return array 228 */ 229 private function summaryData(Individual $individual, Fact $fact, int $sosa): array 230 { 231 $record = $fact->record(); 232 $name = ''; 233 $url = ''; 234 $tag = $fact->label(); 235 $addbirthtag = false; 236 237 if ($record instanceof Family) { 238 // Marriage 239 $spouse = $record->getSpouse($individual); 240 if ($spouse) { 241 $url = $spouse->url(); 242 $name = $spouse->getFullName(); 243 } 244 } elseif ($record !== $individual) { 245 // Birth of a child 246 $url = $record->url(); 247 $name = $record->getFullName(); 248 $tag = GedcomTag::getLabel('_BIRT_CHIL', $record); 249 } 250 251 if ($sosa > 1) { 252 $addbirthtag = true; 253 $tag = ucfirst(FunctionsCharts::getSosaName($sosa)); 254 } 255 256 return [ 257 'tag' => $tag, 258 'url' => $url, 259 'name' => $name, 260 'value' => $fact->value(), 261 'date' => $fact->date()->display(true), 262 'place' => $fact->place(), 263 'addtag' => $addbirthtag, 264 ]; 265 } 266 267 /** 268 * @param Request $request 269 * @param Tree $tree 270 * @param ChartService $chart_service 271 * 272 * @return array 273 */ 274 private function getPedigreeMapFacts(Request $request, Tree $tree, ChartService $chart_service): array 275 { 276 $xref = $request->get('reference'); 277 $individual = Individual::getInstance($xref, $tree); 278 $generations = (int) $request->get( 279 'generations', 280 $tree->getPreference('DEFAULT_PEDIGREE_GENERATIONS') 281 ); 282 $ancestors = $chart_service->sosaStradonitzAncestors($individual, $generations); 283 $facts = []; 284 foreach ($ancestors as $sosa => $person) { 285 if ($person->canShow()) { 286 $birth = $person->getFirstFact('BIRT'); 287 if ($birth instanceof Fact && $birth->place()->gedcomName() !== '') { 288 $facts[$sosa] = $birth; 289 } 290 } 291 } 292 293 return $facts; 294 } 295 296 /** 297 * @param Request $request 298 * 299 * @return JsonResponse 300 */ 301 public function getProviderStylesAction(Request $request): JsonResponse 302 { 303 $styles = $this->getMapProviderData($request); 304 305 return new JsonResponse($styles); 306 } 307 308 /** 309 * @param Request $request 310 * 311 * @return array|null 312 */ 313 private function getMapProviderData(Request $request) 314 { 315 if (self::$map_providers === null) { 316 $providersFile = WT_ROOT . Webtrees::MODULES_PATH . 'openstreetmap/providers/providers.xml'; 317 self::$map_selections = [ 318 'provider' => $this->getPreference('provider', 'openstreetmap'), 319 'style' => $this->getPreference('provider_style', 'mapnik'), 320 ]; 321 322 try { 323 $xml = simplexml_load_file($providersFile); 324 // need to convert xml structure into arrays & strings 325 foreach ($xml as $provider) { 326 $style_keys = array_map( 327 function (string $item): string { 328 return preg_replace('/[^a-z\d]/i', '', strtolower($item)); 329 }, 330 (array) $provider->styles 331 ); 332 333 $key = preg_replace('/[^a-z\d]/i', '', strtolower((string) $provider->name)); 334 335 self::$map_providers[$key] = [ 336 'name' => (string) $provider->name, 337 'styles' => array_combine($style_keys, (array) $provider->styles), 338 ]; 339 } 340 } catch (Exception $ex) { 341 // Default provider is OpenStreetMap 342 self::$map_selections = [ 343 'provider' => 'openstreetmap', 344 'style' => 'mapnik', 345 ]; 346 self::$map_providers = [ 347 'openstreetmap' => [ 348 'name' => 'OpenStreetMap', 349 'styles' => ['mapnik' => 'Mapnik'], 350 ], 351 ]; 352 }; 353 } 354 355 //Ugly!!! 356 switch ($request->get('action')) { 357 case 'BaseData': 358 $varName = (self::$map_selections['style'] === '') ? '' : self::$map_providers[self::$map_selections['provider']]['styles'][self::$map_selections['style']]; 359 $payload = [ 360 'selectedProvIndex' => self::$map_selections['provider'], 361 'selectedProvName' => self::$map_providers[self::$map_selections['provider']]['name'], 362 'selectedStyleName' => $varName, 363 ]; 364 break; 365 case 'ProviderStyles': 366 $provider = $request->get('provider', 'openstreetmap'); 367 $payload = self::$map_providers[$provider]['styles']; 368 break; 369 case 'AdminConfig': 370 $providers = []; 371 foreach (self::$map_providers as $key => $provider) { 372 $providers[$key] = $provider['name']; 373 } 374 $payload = [ 375 'providers' => $providers, 376 'selectedProv' => self::$map_selections['provider'], 377 'styles' => self::$map_providers[self::$map_selections['provider']]['styles'], 378 'selectedStyle' => self::$map_selections['style'], 379 ]; 380 break; 381 default: 382 $payload = null; 383 } 384 385 return $payload; 386 } 387 388 /** 389 * @param Request $request 390 * @param Tree $tree 391 * 392 * @return object 393 */ 394 public function getPedigreeMapAction(Request $request, Tree $tree) 395 { 396 $xref = $request->get('xref', ''); 397 $individual = Individual::getInstance($xref, $tree); 398 $maxgenerations = $tree->getPreference('MAX_PEDIGREE_GENERATIONS'); 399 $generations = $request->get('generations', $tree->getPreference('DEFAULT_PEDIGREE_GENERATIONS')); 400 401 if ($individual === null) { 402 throw new IndividualNotFoundException(); 403 } 404 405 if (!$individual->canShow()) { 406 throw new IndividualAccessDeniedException(); 407 } 408 409 return $this->viewResponse('modules/pedigree-map/page', [ 410 'module_name' => $this->name(), 411 /* I18N: %s is an individual’s name */ 412 'title' => I18N::translate('Pedigree map of %s', $individual->getFullName()), 413 'tree' => $tree, 414 'individual' => $individual, 415 'generations' => $generations, 416 'maxgenerations' => $maxgenerations, 417 'map' => view( 418 'modules/pedigree-map/chart', 419 [ 420 'module' => $this->name(), 421 'ref' => $individual->xref(), 422 'type' => 'pedigree', 423 'generations' => $generations, 424 ] 425 ), 426 ]); 427 } 428} 429