package service import ( "bugulma/backend/internal/domain" "context" "fmt" "math" "gorm.io/gorm" ) // GeospatialService provides advanced geospatial operations for sites type GeospatialService struct { db *gorm.DB } // NewGeospatialService creates a new geospatial service func NewGeospatialService(db *gorm.DB) *GeospatialService { return &GeospatialService{db: db} } // SpatialQuery represents a spatial query with various criteria type SpatialQuery struct { CenterLat float64 CenterLng float64 RadiusKm float64 SiteTypes []domain.SiteType ResourceTypes []domain.ResourceType MaxResults int } // SpatialResult represents a site with spatial metadata type SpatialResult struct { Site *domain.Site DistanceKm float64 Bearing float64 // Direction from center point DrivingTime *int // Estimated driving time in minutes (optional) } // SpatialCluster represents a cluster of spatially close sites type SpatialCluster struct { ID int `json:"id"` CentroidLat float64 `json:"centroid_lat"` CentroidLng float64 `json:"centroid_lng"` SiteCount int `json:"site_count"` Sites []SpatialResult `json:"sites"` RadiusKm float64 `json:"radius_km"` } // FindNearbySites finds sites within radius with advanced filtering func (gs *GeospatialService) FindNearbySites(ctx context.Context, query SpatialQuery) ([]SpatialResult, error) { var results []SpatialResult // Build the base query with PostGIS baseQuery := ` SELECT s.*, ST_Distance(s.location_geometry::geography, ST_GeogFromText('POINT(? ?)')) / 1000 as distance_km, ST_Azimuth(ST_GeogFromText('POINT(? ?)'), s.location_geometry) as bearing FROM sites s WHERE s.location_geometry IS NOT NULL AND ST_DWithin( s.location_geometry::geography, ST_GeogFromText('POINT(? ?)'), ? * 1000 ) ` args := []interface{}{query.CenterLng, query.CenterLat, query.CenterLng, query.CenterLat, query.CenterLng, query.CenterLat, query.RadiusKm} // Add site type filter if len(query.SiteTypes) > 0 { placeholders := "" for i, siteType := range query.SiteTypes { if i > 0 { placeholders += "," } placeholders += "?" args = append(args, siteType) } baseQuery += fmt.Sprintf(" AND s.site_type IN (%s)", placeholders) } // Order by distance and limit results baseQuery += " ORDER BY s.location_geometry <-> ST_GeogFromText('POINT(? ?)')" if query.MaxResults > 0 { baseQuery += " LIMIT ?" args = append(args, query.CenterLng, query.CenterLat, query.MaxResults) } else { args = append(args, query.CenterLng, query.CenterLat) } // Execute query rows, err := gs.db.Raw(baseQuery, args...).Rows() if err != nil { return nil, err } defer rows.Close() for rows.Next() { var site domain.Site var distanceKm, bearing float64 var locationGeometry interface{} // Geometry column - we don't use it in the struct // Scan the row - need to be careful with column order // Note: location_geometry is scanned but not stored in the struct err := rows.Scan( &site.ID, &site.Name, &site.Latitude, &site.Longitude, &locationGeometry, &site.SiteType, &site.FloorAreaM2, &site.Ownership, &site.OwnerOrganizationID, &site.AvailableUtilities, &site.ParkingSpaces, &site.LoadingDocks, &site.CraneCapacityTonnes, &site.EnergyRating, &site.WasteManagement, &site.EnvironmentalImpact, &site.YearBuilt, &site.BuilderOwner, &site.Architect, &site.OriginalPurpose, &site.CurrentUse, &site.Style, &site.Materials, &site.Storeys, &site.HeritageStatus, &site.Notes, &site.Sources, &site.CreatedAt, &site.UpdatedAt, &distanceKm, &bearing, ) if err != nil { return nil, err } results = append(results, SpatialResult{ Site: &site, DistanceKm: distanceKm, Bearing: bearing, }) } return results, nil } // CalculateDistanceMatrix calculates distances between multiple points efficiently func (gs *GeospatialService) CalculateDistanceMatrix(ctx context.Context, points []domain.Site) ([][]float64, error) { if len(points) == 0 { return [][]float64{}, nil } matrix := make([][]float64, len(points)) for i := range matrix { matrix[i] = make([]float64, len(points)) } // For small matrices, calculate directly // For larger matrices, could use PostGIS functions for i := 0; i < len(points); i++ { for j := 0; j < len(points); j++ { if i == j { matrix[i][j] = 0 } else { matrix[i][j] = gs.calculateHaversineDistance( points[i].Latitude, points[i].Longitude, points[j].Latitude, points[j].Longitude, ) } } } return matrix, nil } // calculateHaversineDistance calculates distance using Haversine formula func (gs *GeospatialService) calculateHaversineDistance(lat1, lon1, lat2, lon2 float64) float64 { const R = 6371 // Earth radius in km dLat := (lat2 - lat1) * math.Pi / 180 dLon := (lon2 - lon1) * math.Pi / 180 a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)* math.Sin(dLon/2)*math.Sin(dLon/2) c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) return R * c } // ValidateGeometry validates PostGIS geometry func (gs *GeospatialService) ValidateGeometry(ctx context.Context, siteID string) error { var count int64 err := gs.db.Raw(` SELECT COUNT(*) FROM sites WHERE id = ? AND ST_IsValid(location_geometry) `, siteID).Scan(&count).Error if err != nil { return err } if count == 0 { return fmt.Errorf("invalid geometry for site %s", siteID) } return nil } // GetSpatialStatistics returns spatial statistics for sites func (gs *GeospatialService) GetSpatialStatistics(ctx context.Context) (map[string]interface{}, error) { var stats struct { TotalSites int64 SitesWithGeometry int64 AvgDistance float64 MaxDistance float64 MedianLatitude float64 MedianLongitude float64 } // Get basic counts gs.db.Raw("SELECT COUNT(*) as total_sites FROM sites").Scan(&stats.TotalSites) gs.db.Raw("SELECT COUNT(*) as sites_with_geometry FROM sites WHERE location_geometry IS NOT NULL").Scan(&stats.SitesWithGeometry) // Calculate spatial statistics if we have geometry data if stats.SitesWithGeometry > 1 { row := gs.db.Raw(` SELECT AVG(ST_Distance(a.location_geometry::geography, b.location_geometry::geography)) / 1000 as avg_distance, MAX(ST_Distance(a.location_geometry::geography, b.location_geometry::geography)) / 1000 as max_distance FROM sites a CROSS JOIN sites b WHERE a.id < b.id AND a.location_geometry IS NOT NULL AND b.location_geometry IS NOT NULL `).Row() row.Scan(&stats.AvgDistance, &stats.MaxDistance) // Calculate median coordinates row2 := gs.db.Raw(` SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY latitude) as median_latitude, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY longitude) as median_longitude FROM sites WHERE location_geometry IS NOT NULL `).Row() row2.Scan(&stats.MedianLatitude, &stats.MedianLongitude) } return map[string]interface{}{ "total_sites": stats.TotalSites, "sites_with_geometry": stats.SitesWithGeometry, "avg_distance_km": stats.AvgDistance, "max_distance_km": stats.MaxDistance, "median_latitude": stats.MedianLatitude, "median_longitude": stats.MedianLongitude, }, nil } // FindSpatialClusters finds clusters of spatially close sites using DBSCAN algorithm func (gs *GeospatialService) FindSpatialClusters(ctx context.Context, minPoints int, radiusKm float64) ([]SpatialCluster, error) { var clusters []SpatialCluster // Use PostGIS ST_ClusterDBSCAN to group nearby sites // Convert radius from km to degrees (approximate) radiusDegrees := radiusKm / 111.32 // Rough conversion: 1 degree ≈ 111.32 km query := ` WITH clustered_sites AS ( SELECT s.*, ST_ClusterDBSCAN(s.location_geometry, ?, ?) OVER () as cluster_id, ST_Centroid(ST_Collect(s.location_geometry)) OVER (PARTITION BY ST_ClusterDBSCAN(s.location_geometry, ?, ?) OVER ()) as cluster_centroid FROM sites s WHERE s.location_geometry IS NOT NULL ), cluster_stats AS ( SELECT cluster_id, COUNT(*) as site_count, ST_Y(cluster_centroid) as centroid_lat, ST_X(cluster_centroid) as centroid_lng, MAX(ST_Distance(cluster_centroid::geography, location_geometry::geography)) / 1000 as cluster_radius_km FROM clustered_sites WHERE cluster_id IS NOT NULL GROUP BY cluster_id, cluster_centroid HAVING COUNT(*) >= ? ) SELECT cs.cluster_id, cstats.centroid_lat, cstats.centroid_lng, cstats.site_count, cstats.cluster_radius_km, cs.id as site_id, cs.name, cs.latitude, cs.longitude, cs.location_geometry, cs.site_type, cs.floor_area_m2, cs.ownership, cs.owner_organization_id, cs.available_utilities, cs.parking_spaces, cs.loading_docks, cs.crane_capacity_tonnes, cs.energy_rating, cs.waste_management, cs.environmental_impact, cs.year_built, cs.builder_owner, cs.architect, cs.original_purpose, cs.current_use, cs.style, cs.materials, cs.storeys, cs.heritage_status, cs.notes, cs.sources, cs.created_at, cs.updated_at, ST_Distance(cstats.cluster_centroid::geography, cs.location_geometry::geography) / 1000 as distance_from_centroid FROM clustered_sites cs JOIN cluster_stats cstats ON cs.cluster_id = cstats.cluster_id ORDER BY cs.cluster_id, distance_from_centroid ` rows, err := gs.db.Raw(query, radiusDegrees, minPoints, radiusDegrees, minPoints, minPoints).Rows() if err != nil { return nil, fmt.Errorf("failed to execute clustering query: %w", err) } defer rows.Close() clusterMap := make(map[int]*SpatialCluster) for rows.Next() { var ( clusterID int centroidLat float64 centroidLng float64 siteCount int clusterRadiusKm float64 site domain.Site distanceFromCentroid float64 locationGeometry interface{} // Geometry column - we don't use it in the struct ) err := rows.Scan( &clusterID, ¢roidLat, ¢roidLng, &siteCount, &clusterRadiusKm, &site.ID, &site.Name, &site.Latitude, &site.Longitude, &locationGeometry, &site.SiteType, &site.FloorAreaM2, &site.Ownership, &site.OwnerOrganizationID, &site.AvailableUtilities, &site.ParkingSpaces, &site.LoadingDocks, &site.CraneCapacityTonnes, &site.EnergyRating, &site.WasteManagement, &site.EnvironmentalImpact, &site.YearBuilt, &site.BuilderOwner, &site.Architect, &site.OriginalPurpose, &site.CurrentUse, &site.Style, &site.Materials, &site.Storeys, &site.HeritageStatus, &site.Notes, &site.Sources, &site.CreatedAt, &site.UpdatedAt, &distanceFromCentroid, ) if err != nil { return nil, fmt.Errorf("failed to scan cluster row: %w", err) } // Get or create cluster cluster, exists := clusterMap[clusterID] if !exists { cluster = &SpatialCluster{ ID: clusterID, CentroidLat: centroidLat, CentroidLng: centroidLng, SiteCount: siteCount, Sites: []SpatialResult{}, RadiusKm: clusterRadiusKm, } clusterMap[clusterID] = cluster } // Add site to cluster cluster.Sites = append(cluster.Sites, SpatialResult{ Site: &site, DistanceKm: distanceFromCentroid, Bearing: 0, // Could calculate bearing from centroid if needed }) } // Convert map to slice for _, cluster := range clusterMap { clusters = append(clusters, *cluster) } return clusters, nil }