150 lines
4.8 KiB
Swift
150 lines
4.8 KiB
Swift
//
|
|
// ScenicSelectionModels.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// 景区列表展示标签,对齐 Android `openStatus`。
|
|
enum ScenicSelectionOpenStatus: Equatable {
|
|
case nearest
|
|
case closed
|
|
case none
|
|
}
|
|
|
|
/// 景区选择列表项,对齐 Android `Sceniclistv2Entity` 展示字段。
|
|
struct ScenicSpotDisplayItem: Equatable, Hashable {
|
|
let id: Int
|
|
let name: String
|
|
let coverImg: String
|
|
let address: String
|
|
let distanceText: String
|
|
let openStatus: ScenicSelectionOpenStatus
|
|
let isSelected: Bool
|
|
|
|
var statusLabel: String? {
|
|
switch openStatus {
|
|
case .nearest: "距离最近"
|
|
case .closed: "暂未营业"
|
|
case .none: nil
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 景区距离计算,对齐 Android Haversine 逻辑。
|
|
enum ScenicDistanceCalculator {
|
|
private static let earthRadiusMeters = 6_371_000.0
|
|
|
|
static func distanceKilometers(
|
|
fromLat: Double,
|
|
fromLng: Double,
|
|
toLat: Double,
|
|
toLng: Double
|
|
) -> Double {
|
|
let dLat = (toLat - fromLat) * .pi / 180
|
|
let dLng = (toLng - fromLng) * .pi / 180
|
|
let lat1 = fromLat * .pi / 180
|
|
let lat2 = toLat * .pi / 180
|
|
let a = sin(dLat / 2) * sin(dLat / 2)
|
|
+ cos(lat1) * cos(lat2) * sin(dLng / 2) * sin(dLng / 2)
|
|
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
|
return (earthRadiusMeters * c) / 1000
|
|
}
|
|
|
|
static func formatDistanceKilometers(_ kilometers: Double) -> String {
|
|
if kilometers <= 0 || !kilometers.isFinite {
|
|
return "0"
|
|
}
|
|
return String(format: "%.2f", kilometers)
|
|
}
|
|
}
|
|
|
|
/// 将 role-permission 景区映射为选择页展示项。
|
|
enum ScenicSpotDisplayMapper {
|
|
static func buildItems(
|
|
scenicList: [ScenicInfo],
|
|
searchQuery: String,
|
|
currentLat: Double?,
|
|
currentLng: Double?,
|
|
selectedScenicId: Int
|
|
) -> [ScenicSpotDisplayItem] {
|
|
let hasLocation = (currentLat ?? 0) > 0 && (currentLng ?? 0) > 0
|
|
let searchLower = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
|
struct ScenicWithDistance {
|
|
let item: ScenicSpotDisplayItem
|
|
let distance: Double
|
|
}
|
|
|
|
let mapped = scenicList.map { scenic -> ScenicWithDistance in
|
|
let loc = scenic.location
|
|
let distance: Double
|
|
if let loc, hasLocation, let currentLat, let currentLng {
|
|
distance = ScenicDistanceCalculator.distanceKilometers(
|
|
fromLat: currentLat,
|
|
fromLng: currentLng,
|
|
toLat: loc.lat,
|
|
toLng: loc.lng
|
|
)
|
|
} else if loc == nil, hasLocation {
|
|
distance = .greatestFiniteMagnitude
|
|
} else {
|
|
distance = 0
|
|
}
|
|
|
|
let address = loc?.address.isEmpty == false ? (loc?.address ?? "--") : "--"
|
|
let distanceText: String
|
|
if loc == nil {
|
|
distanceText = "--"
|
|
} else if distance > 0, distance < .greatestFiniteMagnitude {
|
|
distanceText = ScenicDistanceCalculator.formatDistanceKilometers(distance)
|
|
} else {
|
|
distanceText = "0"
|
|
}
|
|
|
|
let openStatus: ScenicSelectionOpenStatus = scenic.status == 2 ? .closed : .none
|
|
let item = ScenicSpotDisplayItem(
|
|
id: scenic.id,
|
|
name: scenic.name,
|
|
coverImg: scenic.coverImg ?? "",
|
|
address: address,
|
|
distanceText: distanceText,
|
|
openStatus: openStatus,
|
|
isSelected: selectedScenicId > 0 && scenic.id == selectedScenicId
|
|
)
|
|
return ScenicWithDistance(item: item, distance: distance)
|
|
}
|
|
|
|
let filtered = mapped.filter { entry in
|
|
guard !searchLower.isEmpty else { return true }
|
|
return entry.item.name.lowercased().contains(searchLower)
|
|
|| entry.item.address.lowercased().contains(searchLower)
|
|
}
|
|
|
|
let sorted: [ScenicWithDistance]
|
|
if hasLocation {
|
|
sorted = filtered.sorted { $0.distance < $1.distance }
|
|
} else {
|
|
sorted = filtered
|
|
}
|
|
|
|
let baseItems = sorted.map(\.item)
|
|
guard hasLocation, !baseItems.isEmpty else { return baseItems }
|
|
|
|
return baseItems.enumerated().map { index, item in
|
|
if index == 0, item.openStatus != .closed {
|
|
return ScenicSpotDisplayItem(
|
|
id: item.id,
|
|
name: item.name,
|
|
coverImg: item.coverImg,
|
|
address: item.address,
|
|
distanceText: item.distanceText,
|
|
openStatus: .nearest,
|
|
isSelected: item.isSelected
|
|
)
|
|
}
|
|
return item
|
|
}
|
|
}
|
|
}
|