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|null 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 public function latitude(): float|null 174 { 175 return $this->details()->latitude; 176 } 177 178 /** 179 * Longitude of the location. 180 */ 181 public function longitude(): float|null 182 { 183 return $this->details()->longitude; 184 } 185 186 /** 187 * @return string 188 */ 189 public function locationName(): string 190 { 191 return (string) $this->parts->first(); 192 } 193 194 /** 195 * Find a rectangle that (approximately) encloses this place. 196 * 197 * @return array<array<float>> 198 */ 199 public function boundingRectangle(): array 200 { 201 if ($this->id() === null) { 202 return [[-180.0, -90.0], [180.0, 90.0]]; 203 } 204 205 // Find our own co-ordinates and those of any child places 206 $latitudes = DB::table('place_location') 207 ->whereNotNull('latitude') 208 ->where(function (Builder $query): void { 209 $query 210 ->where('parent_id', '=', $this->id()) 211 ->orWhere('id', '=', $this->id()); 212 }) 213 ->groupBy(['latitude']) 214 ->pluck('latitude') 215 ->map(static fn(string $x): float => (float) $x); 216 217 $longitudes = DB::table('place_location') 218 ->whereNotNull('longitude') 219 ->where(function (Builder $query): void { 220 $query 221 ->where('parent_id', '=', $this->id()) 222 ->orWhere('id', '=', $this->id()); 223 }) 224 ->groupBy(['longitude']) 225 ->pluck('longitude') 226 ->map(static fn(string $x): float => (float) $x); 227 228 // No co-ordinates? Use the parent place instead. 229 if ($latitudes->isEmpty() || $longitudes->isEmpty()) { 230 return $this->parent()->boundingRectangle(); 231 } 232 233 // Many co-ordinates? Generate a bounding rectangle that includes them. 234 if ($latitudes->count() > 1 || $longitudes->count() > 1) { 235 return [[$latitudes->min(), $longitudes->min()], [$latitudes->max(), $longitudes->max()]]; 236 } 237 238 // Just one co-ordinate? Draw a box around it. 239 switch ($this->parts->count()) { 240 case 1: 241 // Countries 242 $delta = 5.0; 243 break; 244 case 2: 245 // Regions 246 $delta = 1.0; 247 break; 248 default: 249 // Cities and districts 250 $delta = 0.2; 251 break; 252 } 253 254 return [[ 255 max($latitudes->min() - $delta, -90.0), 256 max($longitudes->min() - $delta, -180.0), 257 ], [ 258 min($latitudes->max() + $delta, 90.0), 259 min($longitudes->max() + $delta, 180.0), 260 ]]; 261 } 262} 263