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\Database; 23use Fisharebest\Webtrees\Fact; 24use Fisharebest\Webtrees\FactLocation; 25use Fisharebest\Webtrees\Functions\Functions; 26use Fisharebest\Webtrees\I18N; 27use Fisharebest\Webtrees\Individual; 28use Symfony\Component\HttpFoundation\JsonResponse; 29use Symfony\Component\HttpFoundation\Request; 30 31/** 32 * Class PlacesMapModule 33 */ 34class PlacesModule extends AbstractModule implements ModuleTabInterface 35{ 36 const OSM_MIN_ZOOM = 2; 37 const LINE_COLORS = [ 38 '#FF0000', 39 // Red 40 '#00FF00', 41 // Green 42 '#0000FF', 43 // Blue 44 '#FFB300', 45 // Gold 46 '#00FFFF', 47 // Cyan 48 '#FF00FF', 49 // Purple 50 '#7777FF', 51 // Light blue 52 '#80FF80' 53 // Light green 54 ]; 55 56 private static $map_providers = null; 57 private static $map_selections = null; 58 59 /** {@inheritdoc} */ 60 public function getTitle() 61 { 62 return /* I18N: Name of a module */ 63 I18N::translate('Places'); 64 } 65 66 /** {@inheritdoc} */ 67 public function getDescription() 68 { 69 return /* I18N: Description of the “OSM” module */ 70 I18N::translate('Show the location of events on a map.'); 71 } 72 73 /** {@inheritdoc} */ 74 public function defaultAccessLevel() 75 { 76 return Auth::PRIV_PRIVATE; 77 } 78 79 /** {@inheritdoc} */ 80 public function defaultTabOrder() 81 { 82 return 4; 83 } 84 85 /** {@inheritdoc} */ 86 public function hasTabContent(Individual $individual) 87 { 88 return true; 89 } 90 91 /** {@inheritdoc} */ 92 public function isGrayedOut(Individual $individual) 93 { 94 return false; 95 } 96 97 /** {@inheritdoc} */ 98 public function canLoadAjax() 99 { 100 return true; 101 } 102 103 /** {@inheritdoc} */ 104 public function getTabContent(Individual $individual) 105 { 106 return view('modules/places/tab', [ 107 'module' => $this->getName(), 108 'ref' => $individual->getXref(), 109 'type' => 'individual', 110 ]); 111 } 112 113 /** 114 * @param Request $request 115 * 116 * @return JsonResponse 117 */ 118 public function getBaseDataAction(Request $request): JsonResponse 119 { 120 $provider = $this->getMapProviderData($request); 121 $style = $provider['selectedStyleName'] = '' ? '' : '.' . $provider['selectedStyleName']; 122 123 switch ($provider['selectedProvIndex']) { 124 case 'mapbox': 125 $providerOptions = [ 126 'id' => $this->getPreference('mapbox_id'), 127 'accessToken' => $this->getPreference('mapbox_token'), 128 ]; 129 break; 130 case 'here': 131 $providerOptions = [ 132 'app_id' => $this->getPreference('here_appid'), 133 'app_code' => $this->getPreference('here_appcode'), 134 ]; 135 break; 136 default: 137 $providerOptions = []; 138 }; 139 140 $options = [ 141 'minZoom' => self::OSM_MIN_ZOOM, 142 'providerName' => $provider['selectedProvName'] . $style, 143 'providerOptions' => $providerOptions, 144 'animate' => $this->getPreference('map_animate', 0), 145 'I18N' => [ 146 'zoomInTitle' => I18N::translate('Zoom in'), 147 'zoomOutTitle' => I18N::translate('Zoom out'), 148 'reset' => I18N::translate('Reset to initial map state'), 149 'noData' => I18N::translate('No mappable items'), 150 'error' => I18N::translate('An unknown error occurred'), 151 ], 152 ]; 153 154 return new JsonResponse($options); 155 } 156 157 /** 158 * @param Request $request 159 * 160 * @return JsonResponse 161 * @throws Exception 162 */ 163 public function getMapDataAction(Request $request): JsonResponse 164 { 165 $xref = $request->get('reference'); 166 $tree = $request->attributes->get('tree'); 167 $indi = Individual::getInstance($xref, $tree); 168 $facts = $this->getPersonalFacts($request); 169 170 $geojson = [ 171 'type' => 'FeatureCollection', 172 'features' => [], 173 ]; 174 if (empty($facts)) { 175 $code = 204; 176 } else { 177 $code = 200; 178 foreach ($facts as $id => $fact) { 179 $event = new FactLocation($fact, $indi); 180 $icon = $event->getIconDetails(); 181 if ($event->knownLatLon()) { 182 $polyline = null; 183 $geojson['features'][] = [ 184 'type' => 'Feature', 185 'id' => $id, 186 'valid' => true, 187 'geometry' => [ 188 'type' => 'Point', 189 'coordinates' => $event->getGeoJsonCoords(), 190 ], 191 'properties' => [ 192 'polyline' => $polyline, 193 'icon' => $icon, 194 'tooltip' => $event->toolTip(), 195 'summary' => view( 196 'modules/places/event-sidebar', 197 $event->shortSummary('individual', $id) 198 ), 199 'zoom' => (int) $event->getZoom(), 200 ], 201 ]; 202 } 203 } 204 } 205 206 return new JsonResponse($geojson, $code); 207 } 208 209 /** 210 * @param Request $request 211 * 212 * @return array 213 * @throws Exception 214 */ 215 private function getPersonalFacts(Request $request) 216 { 217 $xref = $request->get('reference'); 218 $tree = $request->attributes->get('tree'); 219 $individual = Individual::getInstance($xref, $tree); 220 $facts = $individual->getFacts(); 221 foreach ($individual->getSpouseFamilies() as $family) { 222 $facts = array_merge($facts, $family->getFacts()); 223 // Add birth of children from this family to the facts array 224 foreach ($family->getChildren() as $child) { 225 $childsBirth = $child->getFirstFact('BIRT'); 226 if ($childsBirth && !$childsBirth->getPlace()->isEmpty()) { 227 $facts[] = $childsBirth; 228 } 229 } 230 } 231 232 Functions::sortFacts($facts); 233 234 $useable_facts = array_filter( 235 $facts, 236 function (Fact $item) { 237 return !$item->getPlace()->isEmpty(); 238 } 239 ); 240 241 return array_values($useable_facts); 242 } 243 244 /** 245 * @param Request $request 246 * 247 * @return JsonResponse 248 */ 249 public function getProviderStylesAction(Request $request): JsonResponse 250 { 251 $styles = $this->getMapProviderData($request); 252 253 return new JsonResponse($styles); 254 } 255 256 /** 257 * @param Request $request 258 * 259 * @return array|null 260 */ 261 private function getMapProviderData(Request $request) 262 { 263 if (self::$map_providers === null) { 264 $providersFile = WT_ROOT . WT_MODULES_DIR . 'openstreetmap/providers/providers.xml'; 265 self::$map_selections = [ 266 'provider' => $this->getPreference('provider', 'openstreetmap'), 267 'style' => $this->getPreference('provider_style', 'mapnik'), 268 ]; 269 270 try { 271 $xml = simplexml_load_file($providersFile); 272 // need to convert xml structure into arrays & strings 273 foreach ($xml as $provider) { 274 $style_keys = array_map( 275 function ($item) { 276 return preg_replace('/[^a-z\d]/i', '', strtolower($item)); 277 }, 278 (array) $provider->styles 279 ); 280 281 $key = preg_replace('/[^a-z\d]/i', '', strtolower((string) $provider->name)); 282 283 self::$map_providers[$key] = [ 284 'name' => (string) $provider->name, 285 'styles' => array_combine($style_keys, (array) $provider->styles), 286 ]; 287 } 288 } catch (Exception $ex) { 289 // Default provider is OpenStreetMap 290 self::$map_selections = [ 291 'provider' => 'openstreetmap', 292 'style' => 'mapnik', 293 ]; 294 self::$map_providers = [ 295 'openstreetmap' => [ 296 'name' => 'OpenStreetMap', 297 'styles' => ['mapnik' => 'Mapnik'], 298 ], 299 ]; 300 }; 301 } 302 303 //Ugly!!! 304 switch ($request->get('action')) { 305 case 'BaseData': 306 $varName = (self::$map_selections['style'] === '') ? '' : self::$map_providers[self::$map_selections['provider']]['styles'][self::$map_selections['style']]; 307 $payload = [ 308 'selectedProvIndex' => self::$map_selections['provider'], 309 'selectedProvName' => self::$map_providers[self::$map_selections['provider']]['name'], 310 'selectedStyleName' => $varName, 311 ]; 312 break; 313 case 'ProviderStyles': 314 $provider = $request->get('provider', 'openstreetmap'); 315 $payload = self::$map_providers[$provider]['styles']; 316 break; 317 case 'AdminConfig': 318 $providers = []; 319 foreach (self::$map_providers as $key => $provider) { 320 $providers[$key] = $provider['name']; 321 } 322 $payload = [ 323 'providers' => $providers, 324 'selectedProv' => self::$map_selections['provider'], 325 'styles' => self::$map_providers[self::$map_selections['provider']]['styles'], 326 'selectedStyle' => self::$map_selections['style'], 327 ]; 328 break; 329 default: 330 $payload = null; 331 } 332 333 return $payload; 334 } 335 336 /** 337 * @param $parent_id 338 * @param $placename 339 * @param $places 340 * 341 * @throws Exception 342 */ 343 private function buildLevel($parent_id, $placename, &$places) 344 { 345 $level = array_search('', $placename); 346 $rows = (array) Database::prepare( 347 "SELECT pl_level, pl_id, pl_place, pl_long, pl_lati, pl_zoom, pl_icon FROM `##placelocation` WHERE pl_parent_id=? ORDER BY pl_place" 348 ) 349 ->execute([$parent_id]) 350 ->fetchAll(\PDO::FETCH_ASSOC); 351 352 if (!empty($rows)) { 353 foreach ($rows as $row) { 354 $index = $row['pl_id']; 355 $placename[$level] = $row['pl_place']; 356 $places[] = array_merge([$row['pl_level']], $placename, array_splice($row, 3)); 357 $this->buildLevel($index, $placename, $places); 358 } 359 } 360 } 361} 362