Refactor Location model to integrate PostGIS for spatial data, add legacy coordinate fields for compatibility, and enhance documentation for geocoding service implementation.

This commit is contained in:
pacnpal
2025-02-23 20:06:27 -05:00
parent 7e5d15eb46
commit f15392806a
8 changed files with 723 additions and 109 deletions

View File

@@ -4,22 +4,28 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Facades\DB;
class Location extends Model
{
use \Spatie\Activitylog\LogsActivity;
/**
* The attributes that are mass assignable.
*
* @var array<string>
*/
protected $fillable = [
'name',
'location_type',
'address',
'city',
'state',
'country',
'postal_code',
'latitude',
'longitude',
'coordinates',
'latitude', // Legacy field
'longitude', // Legacy field
'elevation',
'timezone',
'metadata',
@@ -35,8 +41,9 @@ class Location extends Model
* @var array<string, string>
*/
protected $casts = [
'latitude' => 'decimal:8',
'longitude' => 'decimal:8',
'coordinates' => 'point',
'latitude' => 'decimal:6',
'longitude' => 'decimal:6',
'elevation' => 'decimal:2',
'metadata' => 'array',
'geocoding_data' => 'array',
@@ -44,6 +51,97 @@ class Location extends Model
'is_approximate' => 'boolean',
];
/**
* The attributes that should be logged.
*
* @var array
*/
protected static $logAttributes = [
'name',
'location_type',
'address',
'city',
'state',
'country',
'postal_code',
'coordinates',
'latitude',
'longitude',
];
/**
* Boot the model.
*/
protected static function boot()
{
parent::boot();
static::saving(function ($model) {
// Sync point field with lat/lon fields for backward compatibility
if ($model->latitude && $model->longitude && !$model->coordinates) {
$model->coordinates = DB::raw(
"ST_SetSRID(ST_MakePoint($model->longitude, $model->latitude), 4326)"
);
} elseif ($model->coordinates && (!$model->latitude || !$model->longitude)) {
$point = json_decode($model->coordinates);
$model->longitude = $point->lng;
$model->latitude = $point->lat;
}
});
}
/**
* Get the location's string representation.
*/
public function __toString(): string
{
$locationParts = [];
if ($this->city) {
$locationParts[] = $this->city;
}
if ($this->country) {
$locationParts[] = $this->country;
}
$locationStr = $locationParts ? implode(', ', $locationParts) : 'Unknown location';
return "{$this->name} ({$locationStr})";
}
/**
* Get the formatted address.
*/
public function getFormattedAddressAttribute(): string
{
$components = array_filter([
$this->address,
$this->city,
$this->state,
$this->postal_code,
$this->country
]);
return implode(', ', $components);
}
/**
* Normalize a coordinate value.
*/
protected function normalizeCoordinate($value, int $decimalPlaces = 6): ?float
{
if ($value === null) {
return null;
}
try {
// Convert to string first to handle both float and string inputs
$value = (string) $value;
// Use BC Math for precise decimal handling
return round((float) $value, $decimalPlaces);
} catch (\Exception $e) {
return null;
}
}
/**
* Get the parent locatable model.
*/
@@ -52,51 +150,6 @@ class Location extends Model
return $this->morphTo();
}
/**
* Get the location's coordinates as an array.
*
* @return array<string, float|null>
*/
public function getCoordinatesAttribute(): array
{
return [
'lat' => $this->latitude,
'lng' => $this->longitude,
];
}
/**
* Get the formatted address.
*
* @return string
*/
public function getFormattedAddressAttribute(): string
{
$parts = [];
if ($this->address) {
$parts[] = $this->address;
}
if ($this->city) {
$parts[] = $this->city;
}
if ($this->state) {
$parts[] = $this->state;
}
if ($this->postal_code) {
$parts[] = $this->postal_code;
}
if ($this->country) {
$parts[] = $this->country;
}
return implode(', ', $parts);
}
/**
* Get Google Maps URL for the location.
*
@@ -126,8 +179,7 @@ class Location extends Model
public function updateCoordinates(float $latitude, float $longitude, ?float $elevation = null): bool
{
return $this->update([
'latitude' => $latitude,
'longitude' => $longitude,
'coordinates' => DB::raw("ST_SetSRID(ST_MakePoint($longitude, $latitude), 4326)"),
'elevation' => $elevation,
]);
}
@@ -161,17 +213,12 @@ class Location extends Model
*/
public function scopeNearby($query, float $latitude, float $longitude, float $radius, string $unit = 'km')
{
$earthRadius = $unit === 'mi' ? 3959 : 6371;
$point = DB::raw("ST_MakePoint($longitude, $latitude)");
$distance = $unit === 'mi' ? $radius * 1609.34 : $radius * 1000;
return $query->whereRaw(
"($earthRadius * acos(
cos(radians(?)) *
cos(radians(latitude)) *
cos(radians(longitude) - radians(?)) +
sin(radians(?)) *
sin(radians(latitude))
)) <= ?",
[$latitude, $longitude, $latitude, $radius]
"ST_DWithin(coordinates::geography, ?::geography, ?)",
[$point, $distance]
);
}
@@ -185,8 +232,15 @@ class Location extends Model
*/
public function scopeInBounds($query, array $ne, array $sw)
{
return $query->whereBetween('latitude', [$sw['lat'], $ne['lat']])
->whereBetween('longitude', [$sw['lng'], $ne['lng']]);
$bounds = DB::raw(
"ST_MakeEnvelope(
{$sw['lng']}, {$sw['lat']},
{$ne['lng']}, {$ne['lat']},
4326
)"
);
return $query->whereRaw("ST_Within(coordinates::geometry, ?::geometry)", [$bounds]);
}
/**
@@ -199,23 +253,23 @@ class Location extends Model
*/
public function distanceTo(float $latitude, float $longitude, string $unit = 'km'): ?float
{
if (!$this->latitude || !$this->longitude) {
if (!$this->coordinates) {
return null;
}
$earthRadius = $unit === 'mi' ? 3959 : 6371;
$point = DB::raw("ST_MakePoint($longitude, $latitude)");
$distance = DB::selectOne(
"SELECT ST_Distance(
ST_GeomFromText(?),
ST_GeomFromText(?),
true
) as distance",
[
"POINT({$this->longitude} {$this->latitude})",
"POINT($longitude $latitude)"
]
)->distance;
$latFrom = deg2rad($this->latitude);
$lonFrom = deg2rad($this->longitude);
$latTo = deg2rad($latitude);
$lonTo = deg2rad($longitude);
$latDelta = $latTo - $latFrom;
$lonDelta = $lonTo - $lonFrom;
$angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) +
cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2)));
return $angle * $earthRadius;
return $unit === 'mi' ? $distance * 0.000621371 : $distance / 1000;
}
}

178
app/Traits/HasLocation.php Normal file
View File

@@ -0,0 +1,178 @@
<?php
namespace App\Traits;
use App\Models\Location;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Support\Facades\DB;
trait HasLocation
{
/**
* Boot the trait.
*/
public static function bootHasLocation(): void
{
static::deleting(function ($model) {
// Delete associated location when model is deleted
$model->location?->delete();
});
}
/**
* Get the model's location.
*/
public function location(): MorphOne
{
return $this->morphOne(Location::class, 'locatable');
}
/**
* Get the model's coordinates.
*
* @return array<string, float|null>
*/
public function getCoordinatesAttribute(): array
{
if (!$this->location?->coordinates) {
return [
'lat' => null,
'lng' => null,
];
}
return [
'lat' => $this->location->coordinates->getLat(),
'lng' => $this->location->coordinates->getLng(),
];
}
/**
* Get the model's formatted address.
*
* @return string|null
*/
public function getFormattedAddressAttribute(): ?string
{
return $this->location?->formatted_address;
}
/**
* Get the model's map URL.
*
* @return string|null
*/
public function getMapUrlAttribute(): ?string
{
return $this->location?->map_url;
}
/**
* Update or create the model's location.
*
* @param array<string, mixed> $attributes
* @return \App\Models\Location
*/
public function updateLocation(array $attributes): Location
{
if ($this->location) {
$this->location->update($attributes);
return $this->location;
}
return $this->location()->create($attributes);
}
/**
* Set the model's coordinates.
*
* @param float $latitude
* @param float $longitude
* @param float|null $elevation
* @return \App\Models\Location
*/
public function setCoordinates(float $latitude, float $longitude, ?float $elevation = null): Location
{
return $this->updateLocation([
'coordinates' => DB::raw("ST_SetSRID(ST_MakePoint($longitude, $latitude), 4326)"),
'elevation' => $elevation,
]);
}
/**
* Set the model's address.
*
* @param array<string, string> $components
* @return \App\Models\Location
*/
public function setAddress(array $components): Location
{
return $this->updateLocation($components);
}
/**
* Calculate distance to another model with location.
*
* @param mixed $model Model using HasLocation trait
* @param string $unit 'km' or 'mi'
* @return float|null
*/
public function distanceTo($model, string $unit = 'km'): ?float
{
if (!$this->location || !$model->location) {
return null;
}
return $this->location->distanceTo(
$model->location->latitude,
$model->location->longitude,
$unit
);
}
/**
* Scope a query to find models near coordinates.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param float $latitude
* @param float $longitude
* @param float $radius
* @param string $unit
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeNearby($query, float $latitude, float $longitude, float $radius, string $unit = 'km')
{
$point = DB::raw("ST_SetSRID(ST_MakePoint($longitude, $latitude), 4326)");
$distance = $unit === 'mi' ? $radius * 1609.34 : $radius * 1000;
return $query->whereHas('location', function ($query) use ($point, $distance) {
$query->whereRaw(
"ST_DWithin(coordinates::geography, ?::geography, ?)",
[$point, $distance]
);
});
}
/**
* Scope a query to find models within bounds.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param array<string, float> $ne Northeast corner [lat, lng]
* @param array<string, float> $sw Southwest corner [lat, lng]
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeInBounds($query, array $ne, array $sw)
{
$bounds = DB::raw(
"ST_MakeEnvelope(
{$sw['lng']}, {$sw['lat']},
{$ne['lng']}, {$ne['lat']},
4326
)"
);
return $query->whereHas('location', function ($query) use ($bounds) {
$query->whereRaw("ST_Within(coordinates::geometry, ?::geometry)", [$bounds]);
});
}
}