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