1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2018 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\Auth; 22use Fisharebest\Webtrees\Exceptions\IndividualAccessDeniedException; 23use Fisharebest\Webtrees\Exceptions\IndividualNotFoundException; 24use Fisharebest\Webtrees\Fact; 25use Fisharebest\Webtrees\FactLocation; 26use Fisharebest\Webtrees\I18N; 27use Fisharebest\Webtrees\Individual; 28use Fisharebest\Webtrees\Menu; 29use Fisharebest\Webtrees\Tree; 30use Symfony\Component\HttpFoundation\JsonResponse; 31use Symfony\Component\HttpFoundation\Request; 32use Symfony\Component\HttpFoundation\Response; 33 34/** 35 * Class PedigreeMapModule 36 */ 37class PedigreeMapModule extends AbstractModule implements ModuleChartInterface 38{ 39 const LINE_COLORS = [ 40 '#FF0000', 41 // Red 42 '#00FF00', 43 // Green 44 '#0000FF', 45 // Blue 46 '#FFB300', 47 // Gold 48 '#00FFFF', 49 // Cyan 50 '#FF00FF', 51 // Purple 52 '#7777FF', 53 // Light blue 54 '#80FF80' 55 // Light green 56 ]; 57 58 private static $map_providers = null; 59 private static $map_selections = null; 60 61 /** {@inheritdoc} */ 62 public function getTitle(): string 63 { 64 /* I18N: Name of a module */ 65 return I18N::translate('Pedigree map'); 66 } 67 68 /** {@inheritdoc} */ 69 public function getDescription(): string 70 { 71 /* I18N: Description of the “OSM” module */ 72 return I18N::translate('Show the birthplace of ancestors on a map.'); 73 } 74 75 /** {@inheritdoc} */ 76 public function defaultAccessLevel(): int 77 { 78 return Auth::PRIV_PRIVATE; 79 } 80 81 /** 82 * Return a menu item for this chart. 83 * 84 * @param Individual $individual 85 * 86 * @return Menu 87 */ 88 public function getChartMenu(Individual $individual): Menu 89 { 90 return new Menu( 91 I18N::translate('Pedigree map'), 92 route('module', [ 93 'module' => $this->getName(), 94 'action' => 'PedigreeMap', 95 'xref' => $individual->getXref(), 96 'ged' => $individual->getTree()->getName(), 97 ]), 98 'menu-chart-pedigreemap', 99 ['rel' => 'nofollow'] 100 ); 101 } 102 103 /** 104 * Return a menu item for this chart - for use in individual boxes. 105 * 106 * @param Individual $individual 107 * 108 * @return Menu 109 */ 110 public function getBoxChartMenu(Individual $individual): Menu 111 { 112 return $this->getChartMenu($individual); 113 } 114 115 /** 116 * @param Request $request 117 * @param Tree $tree 118 * 119 * @return JsonResponse 120 */ 121 public function getMapDataAction(Request $request, Tree $tree): JsonResponse 122 { 123 $xref = $request->get('reference'); 124 $indi = Individual::getInstance($xref, $tree); 125 $color_count = count(self::LINE_COLORS); 126 127 $facts = $this->getPedigreeMapFacts($request, $tree); 128 129 $geojson = [ 130 'type' => 'FeatureCollection', 131 'features' => [], 132 ]; 133 134 $sosa_points = []; 135 136 foreach ($facts as $id => $fact) { 137 $event = new FactLocation($fact, $indi); 138 $icon = $event->getIconDetails(); 139 if ($event->knownLatLon()) { 140 $polyline = null; 141 $color = self::LINE_COLORS[log($id, 2) % $color_count]; 142 $icon['color'] = $color; //make icon color the same as the line 143 $sosa_points[$id] = $event->getLatLonJSArray(); 144 $sosa_parent = intdiv($id, 2); 145 if (array_key_exists($sosa_parent, $sosa_points)) { 146 // Would like to use a GeometryCollection to hold LineStrings 147 // rather than generate polylines but the MarkerCluster library 148 // doesn't seem to like them 149 $polyline = [ 150 'points' => [ 151 $sosa_points[$sosa_parent], 152 $event->getLatLonJSArray(), 153 ], 154 'options' => [ 155 'color' => $color, 156 ], 157 ]; 158 } 159 $geojson['features'][] = [ 160 'type' => 'Feature', 161 'id' => $id, 162 'valid' => true, 163 'geometry' => [ 164 'type' => 'Point', 165 'coordinates' => $event->getGeoJsonCoords(), 166 ], 167 'properties' => [ 168 'polyline' => $polyline, 169 'icon' => $icon, 170 'tooltip' => $event->toolTip(), 171 'summary' => view('modules/pedigree-map/event-sidebar', $event->shortSummary('pedigree', $id)), 172 'zoom' => (int) $event->getZoom(), 173 ], 174 ]; 175 } 176 } 177 178 $code = empty($facts) ? Response::HTTP_NO_CONTENT : Response::HTTP_OK; 179 180 return new JsonResponse($geojson, $code); 181 } 182 183 /** 184 * @param Request $request 185 * @param Tree $tree 186 * 187 * @return array 188 */ 189 private function getPedigreeMapFacts(Request $request, Tree $tree): array 190 { 191 $xref = $request->get('reference'); 192 $individual = Individual::getInstance($xref, $tree); 193 $generations = (int) $request->get( 194 'generations', 195 $tree->getPreference('DEFAULT_PEDIGREE_GENERATIONS') 196 ); 197 $ancestors = $this->sosaStradonitzAncestors($individual, $generations); 198 $facts = []; 199 foreach ($ancestors as $sosa => $person) { 200 if ($person !== null && $person->canShow()) { 201 $birth = $person->getFirstFact('BIRT'); 202 if ($birth instanceof Fact && !$birth->getPlace()->isEmpty()) { 203 $facts[$sosa] = $birth; 204 } 205 } 206 } 207 208 return $facts; 209 } 210 211 /** 212 * @param Request $request 213 * 214 * @return JsonResponse 215 */ 216 public function getProviderStylesAction(Request $request): JsonResponse 217 { 218 $styles = $this->getMapProviderData($request); 219 220 return new JsonResponse($styles); 221 } 222 223 /** 224 * @param Request $request 225 * 226 * @return array|null 227 */ 228 private function getMapProviderData(Request $request) 229 { 230 if (self::$map_providers === null) { 231 $providersFile = WT_ROOT . WT_MODULES_DIR . 'openstreetmap/providers/providers.xml'; 232 self::$map_selections = [ 233 'provider' => $this->getPreference('provider', 'openstreetmap'), 234 'style' => $this->getPreference('provider_style', 'mapnik'), 235 ]; 236 237 try { 238 $xml = simplexml_load_file($providersFile); 239 // need to convert xml structure into arrays & strings 240 foreach ($xml as $provider) { 241 $style_keys = array_map( 242 function (string $item): string { 243 return preg_replace('/[^a-z\d]/i', '', strtolower($item)); 244 }, 245 (array) $provider->styles 246 ); 247 248 $key = preg_replace('/[^a-z\d]/i', '', strtolower((string) $provider->name)); 249 250 self::$map_providers[$key] = [ 251 'name' => (string) $provider->name, 252 'styles' => array_combine($style_keys, (array) $provider->styles), 253 ]; 254 } 255 } catch (Exception $ex) { 256 // Default provider is OpenStreetMap 257 self::$map_selections = [ 258 'provider' => 'openstreetmap', 259 'style' => 'mapnik', 260 ]; 261 self::$map_providers = [ 262 'openstreetmap' => [ 263 'name' => 'OpenStreetMap', 264 'styles' => ['mapnik' => 'Mapnik'], 265 ], 266 ]; 267 }; 268 } 269 270 //Ugly!!! 271 switch ($request->get('action')) { 272 case 'BaseData': 273 $varName = (self::$map_selections['style'] === '') ? '' : self::$map_providers[self::$map_selections['provider']]['styles'][self::$map_selections['style']]; 274 $payload = [ 275 'selectedProvIndex' => self::$map_selections['provider'], 276 'selectedProvName' => self::$map_providers[self::$map_selections['provider']]['name'], 277 'selectedStyleName' => $varName, 278 ]; 279 break; 280 case 'ProviderStyles': 281 $provider = $request->get('provider', 'openstreetmap'); 282 $payload = self::$map_providers[$provider]['styles']; 283 break; 284 case 'AdminConfig': 285 $providers = []; 286 foreach (self::$map_providers as $key => $provider) { 287 $providers[$key] = $provider['name']; 288 } 289 $payload = [ 290 'providers' => $providers, 291 'selectedProv' => self::$map_selections['provider'], 292 'styles' => self::$map_providers[self::$map_selections['provider']]['styles'], 293 'selectedStyle' => self::$map_selections['style'], 294 ]; 295 break; 296 default: 297 $payload = null; 298 } 299 300 return $payload; 301 } 302 303 /** 304 * @param Request $request 305 * @param Tree $tree 306 * 307 * @return object 308 */ 309 public function getPedigreeMapAction(Request $request, Tree $tree) 310 { 311 $xref = $request->get('xref'); 312 $individual = Individual::getInstance($xref, $tree); 313 $maxgenerations = $tree->getPreference('MAX_PEDIGREE_GENERATIONS'); 314 $generations = $request->get('generations', $tree->getPreference('DEFAULT_PEDIGREE_GENERATIONS')); 315 316 if ($individual === null) { 317 throw new IndividualNotFoundException(); 318 } 319 320 if (!$individual->canShow()) { 321 throw new IndividualAccessDeniedException(); 322 } 323 324 return (object) [ 325 'name' => 'modules/pedigree-map/pedigree-map-page', 326 'data' => [ 327 'module' => $this->getName(), 328 /* I18N: %s is an individual’s name */ 329 'title' => I18N::translate('Pedigree map of %s', $individual->getFullName()), 330 'tree' => $tree, 331 'individual' => $individual, 332 'generations' => $generations, 333 'maxgenerations' => $maxgenerations, 334 'map' => view( 335 'modules/pedigree-map/pedigree-map', 336 [ 337 'module' => $this->getName(), 338 'ref' => $individual->getXref(), 339 'type' => 'pedigree', 340 'generations' => $generations, 341 ] 342 ), 343 ], 344 ]; 345 } 346 347 // @TODO shift the following function to somewhere more appropriate during restructure 348 349 /** 350 * Copied from AbstractChartController.php 351 * 352 * Find the ancestors of an individual, and generate an array indexed by 353 * Sosa-Stradonitz number. 354 * 355 * @param Individual $individual Start with this individual 356 * @param int $generations Fetch this number of generations 357 * 358 * @return Individual[] 359 */ 360 private function sosaStradonitzAncestors(Individual $individual, int $generations): array 361 { 362 /** @var Individual[] $ancestors */ 363 $ancestors = [ 364 1 => $individual, 365 ]; 366 367 for ($i = 1, $max = 2 ** ($generations - 1); $i < $max; $i++) { 368 $ancestors[$i * 2] = null; 369 $ancestors[$i * 2 + 1] = null; 370 371 $individual = $ancestors[$i]; 372 373 if ($individual !== null) { 374 $family = $individual->getPrimaryChildFamily(); 375 if ($family !== null) { 376 if ($family->getHusband() !== null) { 377 $ancestors[$i * 2] = $family->getHusband(); 378 } 379 if ($family->getWife() !== null) { 380 $ancestors[$i * 2 + 1] = $family->getWife(); 381 } 382 } 383 } 384 } 385 386 return $ancestors; 387 } 388} 389