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