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