1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2020 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; 21 22use Fisharebest\Webtrees\Services\GedcomService; 23use Illuminate\Database\Capsule\Manager as DB; 24use Illuminate\Support\Collection; 25use stdClass; 26 27use function preg_split; 28 29/** 30 * Class PlaceLocation 31 */ 32class PlaceLocation 33{ 34 /** @var string e.g. "Westminster, London, England" */ 35 private $location_name; 36 37 /** @var Collection<string> The parts of a location name, e.g. ["Westminster", "London", "England"] */ 38 private $parts; 39 40 /** 41 * Create a place-location. 42 * 43 * @param string $location_name 44 */ 45 public function __construct(string $location_name) 46 { 47 // Ignore any empty parts in location names such as "Village, , , Country". 48 $this->parts = (new Collection(preg_split(Gedcom::PLACE_SEPARATOR_REGEX, $location_name))) 49 ->filter(); 50 51 // Rebuild the location name in the correct format. 52 $this->location_name = $this->parts->implode(Gedcom::PLACE_SEPARATOR); 53 } 54 55 /** 56 * Get the higher level location. 57 * 58 * @return PlaceLocation 59 */ 60 public function parent(): PlaceLocation 61 { 62 return new self($this->parts->slice(1)->implode(Gedcom::PLACE_SEPARATOR)); 63 } 64 65 /** 66 * The database row that contains this location. 67 * Note that due to database collation, both "Quebec" and "Québec" will share the same row. 68 * 69 * @return int 70 */ 71 public function id(): int 72 { 73 return Factory::cache()->array()->remember('location-' . $this->location_name, function () { 74 // The "top-level" location won't exist in the database. 75 if ($this->parts->isEmpty()) { 76 return 0; 77 } 78 79 $parent_location_id = $this->parent()->id(); 80 81 $location_id = (int) DB::table('placelocation') 82 ->where('pl_place', '=', mb_substr($this->parts->first(), 0, 120)) 83 ->where('pl_parent_id', '=', $parent_location_id) 84 ->value('pl_id'); 85 86 if ($location_id === 0) { 87 $location = $this->parts->first(); 88 89 $location_id = 1 + (int) DB::table('placelocation')->max('pl_id'); 90 91 DB::table('placelocation')->insert([ 92 'pl_id' => $location_id, 93 'pl_place' => mb_substr($location, 0, 120), 94 'pl_parent_id' => $parent_location_id, 95 'pl_level' => $this->parts->count() - 1, 96 'pl_lati' => '', 97 'pl_long' => '', 98 'pl_icon' => '', 99 'pl_zoom' => 2, 100 ]); 101 } 102 103 return $location_id; 104 }); 105 } 106 107 /** 108 * Does this location exist in the database? Note that calls to PlaceLocation::id() will 109 * create the row, so this function is only meaningful when called before a call to PlaceLocation::id(). 110 * 111 * @return bool 112 */ 113 public function exists(): bool 114 { 115 $location_id = 0; 116 117 $this->parts->reverse()->each(static function (string $place) use (&$location_id) { 118 if ($location_id !== null) { 119 $location_id = DB::table('placelocation') 120 ->where('pl_parent_id', '=', $location_id) 121 ->where('pl_place', '=', mb_substr($place, 0, 120)) 122 ->value('pl_id'); 123 } 124 }); 125 126 return $location_id !== null; 127 } 128 129 /** 130 * @return stdClass 131 */ 132 private function details(): stdClass 133 { 134 return Factory::cache()->array()->remember('location-details-' . $this->id(), function () { 135 // The "top-level" location won't exist in the database. 136 if ($this->parts->isEmpty()) { 137 return (object) [ 138 'pl_id' => '0', 139 'pl_parent_id' => '0', 140 'pl_level' => null, 141 'pl_place' => '', 142 'pl_lati' => null, 143 'pl_long' => null, 144 'pl_zoom' => null, 145 'pl_icon' => null, 146 'pl_media' => null, 147 ]; 148 } 149 150 return DB::table('placelocation') 151 ->where('pl_id', '=', $this->id()) 152 ->first(); 153 }); 154 } 155 156 /** 157 * Latitude of the location. 158 * 159 * @return float 160 */ 161 public function latitude(): float 162 { 163 $gedcom_service = new GedcomService(); 164 $pl_lati = (string) $this->details()->pl_lati; 165 166 return $gedcom_service->readLatitude($pl_lati); 167 } 168 169 /** 170 * Longitude of the location. 171 * 172 * @return float 173 */ 174 public function longitude(): float 175 { 176 $gedcom_service = new GedcomService(); 177 $pl_long = (string) $this->details()->pl_long; 178 179 return $gedcom_service->readLongitude($pl_long); 180 } 181 182 /** 183 * The icon for the location. 184 * 185 * @return string 186 */ 187 public function icon(): string 188 { 189 return (string) $this->details()->pl_icon; 190 } 191 192 /** 193 * Zoom level for the location. 194 * 195 * @return int 196 */ 197 public function zoom(): int 198 { 199 return (int) $this->details()->pl_zoom ?: 2; 200 } 201 202 /** 203 * @return string 204 */ 205 public function locationName(): string 206 { 207 return (string) $this->parts->first(); 208 } 209 210 /** 211 * Find a rectangle that (approximately) encloses this place. 212 * 213 * @return array<array<float>> 214 */ 215 public function boundingRectangle(): array 216 { 217 if ($this->id() === 0) { 218 return [[-180.0, -90.0], [180.0, 90.0]]; 219 } 220 221 // Find our own co-ordinates and those of any child places 222 $latitudes = DB::table('placelocation') 223 ->where('pl_parent_id', '=', $this->id()) 224 ->orWhere('pl_id', '=', $this->id()) 225 ->groupBy(['pl_lati']) 226 ->pluck('pl_lati') 227 ->filter() 228 ->map(static function (string $x): float { 229 return (new GedcomService())->readLatitude($x); 230 }); 231 232 $longitudes = DB::table('placelocation') 233 ->where('pl_parent_id', '=', $this->id()) 234 ->orWhere('pl_id', '=', $this->id()) 235 ->groupBy(['pl_long']) 236 ->pluck('pl_long') 237 ->filter() 238 ->map(static function (string $x): float { 239 return (new GedcomService())->readLongitude($x); 240 }); 241 242 // No co-ordinates? Use the parent place instead. 243 if ($latitudes->isEmpty() && $longitudes->isEmpty()) { 244 return $this->parent()->boundingRectangle(); 245 } 246 247 // Many co-ordinates? Generate a bounding rectangle that includes them. 248 if ($latitudes->count() > 1 || $longitudes->count() > 1) { 249 return [[$latitudes->min(), $longitudes->min()], [$latitudes->max(), $longitudes->max()]]; 250 } 251 252 // Just one co-ordinate? Draw a box around it. 253 switch ($this->parts->count()) { 254 case 1: 255 // Countries 256 $delta = 5.0; 257 break; 258 case 2: 259 // Regions 260 $delta = 1.0; 261 break; 262 default: 263 // Cities and districts 264 $delta = 0.2; 265 break; 266 } 267 268 return [[ 269 max($latitudes->min() - $delta, -90.0), 270 max($longitudes->min() - $delta, -180.0), 271 ], [ 272 min($latitudes->max() + $delta, 90.0), 273 min($longitudes->max() + $delta, 180.0), 274 ]]; 275 } 276} 277