*/ protected $fillable = [ 'name', 'location_type', 'address', 'city', 'state', 'country', 'postal_code', 'coordinates', 'latitude', // Legacy field 'longitude', // Legacy field 'elevation', 'timezone', 'metadata', 'is_approximate', 'source', 'geocoding_data', 'geocoded_at', ]; /** * The attributes that should be cast. * * @var array */ protected $casts = [ 'coordinates' => 'point', 'latitude' => 'decimal:6', 'longitude' => 'decimal:6', 'elevation' => 'decimal:2', 'metadata' => 'array', 'geocoding_data' => 'array', 'geocoded_at' => 'datetime', '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. */ public function locatable(): MorphTo { return $this->morphTo(); } /** * Get Google Maps URL for the location. * * @return string|null */ public function getMapUrlAttribute(): ?string { if (!$this->latitude || !$this->longitude) { return null; } return sprintf( 'https://www.google.com/maps?q=%f,%f', $this->latitude, $this->longitude ); } /** * Update the location's coordinates. * * @param float $latitude * @param float $longitude * @param float|null $elevation * @return bool */ public function updateCoordinates(float $latitude, float $longitude, ?float $elevation = null): bool { return $this->update([ 'coordinates' => DB::raw("ST_SetSRID(ST_MakePoint($longitude, $latitude), 4326)"), 'elevation' => $elevation, ]); } /** * Set the location's address components. * * @param array $components * @return bool */ public function setAddress(array $components): bool { return $this->update([ 'address' => $components['address'] ?? $this->address, 'city' => $components['city'] ?? $this->city, 'state' => $components['state'] ?? $this->state, 'country' => $components['country'] ?? $this->country, 'postal_code' => $components['postal_code'] ?? $this->postal_code, ]); } /** * Scope a query to find locations within a radius. * * @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_MakePoint($longitude, $latitude)"); $distance = $unit === 'mi' ? $radius * 1609.34 : $radius * 1000; return $query->whereRaw( "ST_DWithin(coordinates::geography, ?::geography, ?)", [$point, $distance] ); } /** * Scope a query to find locations within bounds. * * @param \Illuminate\Database\Eloquent\Builder $query * @param array $ne Northeast corner [lat, lng] * @param array $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->whereRaw("ST_Within(coordinates::geometry, ?::geometry)", [$bounds]); } /** * Calculate the distance to a point. * * @param float $latitude * @param float $longitude * @param string $unit * @return float|null */ public function distanceTo(float $latitude, float $longitude, string $unit = 'km'): ?float { if (!$this->coordinates) { return null; } $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; return $unit === 'mi' ? $distance * 0.000621371 : $distance / 1000; } }