765 lines
30 KiB
Swift
765 lines
30 KiB
Swift
//
|
||
// ItineraryTraceVC.swift
|
||
// QuickLocation
|
||
//
|
||
// Created by 八条 on 2026/6/30.
|
||
//
|
||
|
||
import UIKit
|
||
import RxSwift
|
||
import RxCocoa
|
||
import ObjectMapper
|
||
import SwiftyUserDefaults
|
||
import AMapNaviKit
|
||
import AMapSearchKit
|
||
|
||
class ItineraryTraceVC: BaseViewController {
|
||
|
||
fileprivate var rootView: ItineraryTraceView!
|
||
private let model: ScheduleRecordModel
|
||
private let memberModel: GroupMemberModel
|
||
private var routeOverlays: [MAPolyline] = []
|
||
private let routeSearch = AMapSearchAPI()
|
||
private var drivingSegmentIndexes: [ObjectIdentifier: Int] = [:]
|
||
private var playbackSegments: [[CLLocationCoordinate2D]?] = []
|
||
private var playbackPath: [(coordinate: CLLocationCoordinate2D, distance: Double)] = []
|
||
private var playbackTotalDistance: Double = 0
|
||
private var playbackAnnotation: TracePlaybackAnnotation?
|
||
private var displayLink: CADisplayLink?
|
||
private var playbackProgress: Double = 0
|
||
private var playbackLastTick: Date?
|
||
private var playbackLastCenterUpdate: Date?
|
||
private var playbackDuration: TimeInterval = 30
|
||
private let playbackFollowInterval: TimeInterval = 0.2
|
||
private let minPlaybackDuration: TimeInterval = 10
|
||
private let maxPlaybackDuration: TimeInterval = 180
|
||
private let playbackReplaySpeed: CLLocationDistance = 80
|
||
private let playbackPathMinStep: CLLocationDistance = 5
|
||
private let playbackInterpolationSteps = 3
|
||
private var playbackInterpolationFrame = 0
|
||
private var playbackPrevCoordinate: CLLocationCoordinate2D?
|
||
private var playbackTargetCoordinate: CLLocationCoordinate2D?
|
||
|
||
private let minPointDistance: CLLocationDistance = 10
|
||
private let directSegmentDistance: CLLocationDistance = 80
|
||
private let maxDrivingSegmentDistance: CLLocationDistance = 1500
|
||
private let maxDrivingSegmentTimeGap: Int64 = 5 * 60 * 1000
|
||
private let maxDrivingSegmentRequests = 40
|
||
private let eventCalloutTag = 91301
|
||
|
||
override func loadView() {
|
||
rootView = ItineraryTraceView(frame: UIScreen.main.bounds)
|
||
view = rootView
|
||
}
|
||
|
||
init(scheduleJson: [String: Any], memberInfo: [String: Any]) {
|
||
self.model = ScheduleRecordModel(JSON: scheduleJson) ?? ScheduleRecordModel(JSON: ["id": ""])!
|
||
self.memberModel = GroupMemberModel(JSON: memberInfo) ?? GroupMemberModel(JSON: ["id": ""])!
|
||
print(self.model)
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
rootView.eventCollectionView.dataSource = self
|
||
rootView.eventCollectionView.delegate = self
|
||
setupMap()
|
||
populateData()
|
||
addAnnotations()
|
||
drawRoute()
|
||
setupPlaybackControls()
|
||
}
|
||
|
||
override func viewDidDisappear(_ animated: Bool) {
|
||
super.viewDidDisappear(animated)
|
||
if isMovingFromParent || isBeingDismissed {
|
||
stopPlayback(resetProgress: false)
|
||
rootView.cleanupMap()
|
||
}
|
||
}
|
||
|
||
@MainActor deinit {
|
||
displayLink?.invalidate()
|
||
}
|
||
|
||
private func populateData() {
|
||
rootView.titleLab.text = "\(memberModel.nick_name) 的驾驶详细信息"
|
||
rootView.startTimeLab.text = model.start_time.isoStringToCustom(model.start_time, format: "HH:mm")
|
||
rootView.endTimeLab.text = model.end_time.isoStringToCustom(model.end_time, format: "HH:mm")
|
||
rootView.startAddressLab.text = model.start_address?.street
|
||
rootView.endAddressLab.text = model.end_address?.street
|
||
}
|
||
|
||
// MARK: - Map
|
||
private func setupMap() {
|
||
rootView.mapView.delegate = self
|
||
routeSearch?.delegate = self
|
||
if let lat = Defaults[\.currentLatitude], let lon = Defaults[\.currentLongitude] {
|
||
let coord = CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
||
if CLLocationCoordinate2DIsValid(coord) {
|
||
rootView.mapView.setCenter(coord, animated: false)
|
||
rootView.mapView.setZoomLevel(14, animated: false)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func addAnnotations() {
|
||
// 起点
|
||
if let start = model.trajectory_path.first, abs(start.lat) > 0.0001 {
|
||
let ann = MAPointAnnotation()
|
||
ann.coordinate = CLLocationCoordinate2D(latitude: start.lat, longitude: start.lng)
|
||
ann.title = "start"
|
||
rootView.mapView.addAnnotation(ann)
|
||
}
|
||
|
||
// 终点
|
||
if let end = model.trajectory_path.last, abs(end.lat) > 0.0001 {
|
||
let ann = MAPointAnnotation()
|
||
ann.coordinate = CLLocationCoordinate2D(latitude: end.lat, longitude: end.lng)
|
||
ann.title = "end"
|
||
rootView.mapView.addAnnotation(ann)
|
||
}
|
||
|
||
// 事件点(从 driving_events 取坐标)
|
||
for ev in model.driving_events {
|
||
guard abs(ev.latitude) > 0.0001 else { continue }
|
||
let ann = TraceEventAnnotation()
|
||
ann.coordinate = CLLocationCoordinate2D(latitude: ev.latitude, longitude: ev.longitude)
|
||
ann.title = "event"
|
||
ann.eventType = ev.event_type
|
||
ann.eventTime = eventTimeText(ev.timestamp)
|
||
ann.eventAddress = ev.address?.street ?? ""
|
||
rootView.mapView.addAnnotation(ann)
|
||
}
|
||
|
||
// 缩放到所有标注
|
||
if let anns = rootView.mapView.annotations, anns.count > 0 {
|
||
rootView.mapView.showAnnotations(anns, animated: true)
|
||
}
|
||
}
|
||
|
||
private func drawRoute() {
|
||
clearRouteOverlays()
|
||
|
||
let points = cleanedTracePoints(model.trajectory_path)
|
||
guard points.count >= 2 else { print("[trace] insufficient points: \(points.count)"); return }
|
||
|
||
playbackSegments = Array(repeating: nil, count: points.count - 1)
|
||
|
||
var drivingRequestCount = 0
|
||
for i in 0..<(points.count - 1) {
|
||
let from = points[i]
|
||
let to = points[i + 1]
|
||
let fallback = [from.coordinate, to.coordinate]
|
||
let distance = from.coordinate.distance(to: to.coordinate)
|
||
let timeGap = abs(to.timestamp - from.timestamp)
|
||
|
||
if distance < directSegmentDistance {
|
||
addPolyline(fallback)
|
||
playbackSegments[i] = fallback
|
||
} else if distance <= maxDrivingSegmentDistance,
|
||
timeGap <= maxDrivingSegmentTimeGap,
|
||
drivingRequestCount < maxDrivingSegmentRequests {
|
||
requestDrivingSegment(from: from.coordinate, to: to.coordinate, segmentIndex: i)
|
||
drivingRequestCount += 1
|
||
} else {
|
||
// 断档或距离异常的轨迹段不绘制,避免把事件点/异常点直接连到终点。
|
||
continue
|
||
}
|
||
}
|
||
buildPlaybackPath()
|
||
updatePlaybackAnnotation(progress: playbackProgress)
|
||
print("[trace] cleaned=\(points.count) drivingRequests=\(drivingRequestCount)")
|
||
}
|
||
|
||
private func buildPlaybackPath() {
|
||
var path: [(coordinate: CLLocationCoordinate2D, distance: Double)] = []
|
||
var cumulative: Double = 0
|
||
var lastCoord: CLLocationCoordinate2D?
|
||
for segment in playbackSegments.compactMap({ $0 }) where segment.count > 1 {
|
||
for coord in segment {
|
||
if let last = lastCoord {
|
||
let d = last.distance(to: coord)
|
||
if d < playbackPathMinStep { continue }
|
||
cumulative += d
|
||
}
|
||
path.append((coordinate: coord, distance: cumulative))
|
||
lastCoord = coord
|
||
}
|
||
}
|
||
playbackPath = path
|
||
playbackTotalDistance = cumulative
|
||
}
|
||
|
||
private func clearRouteOverlays() {
|
||
for overlay in routeOverlays {
|
||
rootView.mapView.remove(overlay)
|
||
}
|
||
routeOverlays.removeAll()
|
||
drivingSegmentIndexes.removeAll()
|
||
}
|
||
|
||
private func cleanedTracePoints(_ points: [TrackPoint]) -> [(timestamp: Int64, coordinate: CLLocationCoordinate2D)] {
|
||
let sorted = points.sorted { $0.timestamp < $1.timestamp }
|
||
var result: [(timestamp: Int64, coordinate: CLLocationCoordinate2D)] = []
|
||
|
||
for point in sorted {
|
||
let coordinate = CLLocationCoordinate2D(latitude: point.lat, longitude: point.lng)
|
||
guard abs(point.lat) > 0.0001,
|
||
abs(point.lng) > 0.0001,
|
||
CLLocationCoordinate2DIsValid(coordinate) else { continue }
|
||
|
||
if let last = result.last,
|
||
last.coordinate.distance(to: coordinate) < minPointDistance {
|
||
continue
|
||
}
|
||
|
||
result.append((timestamp: point.timestamp, coordinate: coordinate))
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
private func requestDrivingSegment(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D, segmentIndex: Int) {
|
||
let request = AMapDrivingRouteSearchRequest()
|
||
request.origin = AMapGeoPoint.location(withLatitude: CGFloat(from.latitude), longitude: CGFloat(from.longitude))
|
||
request.destination = AMapGeoPoint.location(withLatitude: CGFloat(to.latitude), longitude: CGFloat(to.longitude))
|
||
request.strategy = 0
|
||
drivingSegmentIndexes[ObjectIdentifier(request)] = segmentIndex
|
||
routeSearch?.aMapDrivingRouteSearch(request)
|
||
}
|
||
|
||
private func setupPlaybackControls() {
|
||
rootView.playBtn.addTarget(self, action: #selector(playButtonTapped), for: .touchUpInside)
|
||
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchDown), for: .touchDown)
|
||
rootView.progressSlider.addTarget(self, action: #selector(progressSliderValueChanged), for: .valueChanged)
|
||
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchUpInside)
|
||
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchUpOutside)
|
||
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchCancel)
|
||
}
|
||
|
||
@objc private func playButtonTapped() {
|
||
if !rootView.playBtn.isSelected, playbackProgress >= 1 {
|
||
playbackProgress = 0
|
||
rootView.progressSlider.value = 0
|
||
updatePlaybackAnnotation(progress: playbackProgress)
|
||
}
|
||
rootView.playBtn.isSelected ? pausePlayback() : startPlayback()
|
||
}
|
||
|
||
@objc private func progressSliderTouchDown() {
|
||
pausePlayback()
|
||
}
|
||
|
||
@objc private func progressSliderValueChanged() {
|
||
playbackProgress = Double(rootView.progressSlider.value)
|
||
updatePlaybackAnnotation(progress: playbackProgress)
|
||
}
|
||
|
||
@objc private func progressSliderTouchEnded() {
|
||
playbackProgress = Double(rootView.progressSlider.value)
|
||
updatePlaybackAnnotation(progress: playbackProgress)
|
||
}
|
||
|
||
private func startPlayback() {
|
||
let totalDistance = playbackPathDistance()
|
||
guard totalDistance > 0 else { return }
|
||
guard playbackProgress < 1 else { return }
|
||
playbackDuration = max(minPlaybackDuration, min(maxPlaybackDuration, totalDistance / playbackReplaySpeed))
|
||
rootView.playBtn.isSelected = true
|
||
playbackLastTick = Date()
|
||
playbackLastCenterUpdate = nil
|
||
playbackInterpolationFrame = playbackInterpolationSteps
|
||
if rootView.mapView.zoomLevel < 16 {
|
||
rootView.mapView.setZoomLevel(16, animated: false)
|
||
}
|
||
displayLink?.invalidate()
|
||
displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink))
|
||
displayLink?.add(to: .main, forMode: .common)
|
||
}
|
||
|
||
private func pausePlayback() {
|
||
rootView.playBtn.isSelected = false
|
||
displayLink?.invalidate()
|
||
displayLink = nil
|
||
playbackLastTick = nil
|
||
playbackLastCenterUpdate = nil
|
||
}
|
||
|
||
private func stopPlayback(resetProgress: Bool) {
|
||
pausePlayback()
|
||
if resetProgress {
|
||
playbackProgress = 0
|
||
rootView.progressSlider.value = 0
|
||
updatePlaybackAnnotation(progress: playbackProgress)
|
||
}
|
||
}
|
||
|
||
@objc private func handleDisplayLink() {
|
||
let now = Date()
|
||
let delta = playbackLastTick.map { now.timeIntervalSince($0) } ?? 0
|
||
playbackLastTick = now
|
||
playbackProgress = min(1, playbackProgress + delta / playbackDuration)
|
||
rootView.progressSlider.value = Float(playbackProgress)
|
||
|
||
playbackInterpolationFrame += 1
|
||
if playbackInterpolationFrame >= playbackInterpolationSteps {
|
||
playbackInterpolationFrame = 0
|
||
playbackPrevCoordinate = playbackTargetCoordinate
|
||
playbackTargetCoordinate = playbackCoordinate(at: playbackProgress)
|
||
}
|
||
if let target = playbackTargetCoordinate {
|
||
if let prev = playbackPrevCoordinate {
|
||
let t = Double(playbackInterpolationFrame) / Double(playbackInterpolationSteps)
|
||
let coord = prev.interpolate(to: target, ratio: t)
|
||
setPlaybackAnnotationCoordinate(coord)
|
||
followPlaybackCoordinateIfNeeded(coord)
|
||
} else {
|
||
setPlaybackAnnotationCoordinate(target)
|
||
followPlaybackCoordinateIfNeeded(target)
|
||
}
|
||
}
|
||
|
||
if playbackProgress >= 1 {
|
||
finishPlayback()
|
||
}
|
||
}
|
||
|
||
private func finishPlayback() {
|
||
playbackProgress = 1
|
||
rootView.progressSlider.value = 1
|
||
updatePlaybackAnnotation(progress: playbackProgress)
|
||
pausePlayback()
|
||
}
|
||
|
||
private func updatePlaybackAnnotation(progress: Double, interpolated: Bool = false) {
|
||
guard let coordinate = playbackCoordinate(at: progress) else { return }
|
||
|
||
if !interpolated {
|
||
playbackPrevCoordinate = coordinate
|
||
playbackTargetCoordinate = coordinate
|
||
playbackInterpolationFrame = playbackInterpolationSteps
|
||
}
|
||
setPlaybackAnnotationCoordinate(coordinate)
|
||
followPlaybackCoordinateIfNeeded(coordinate)
|
||
}
|
||
|
||
private func setPlaybackAnnotationCoordinate(_ coordinate: CLLocationCoordinate2D) {
|
||
if let annotation = playbackAnnotation {
|
||
annotation.coordinate = coordinate
|
||
} else {
|
||
let annotation = TracePlaybackAnnotation()
|
||
annotation.coordinate = coordinate
|
||
playbackAnnotation = annotation
|
||
rootView.mapView.addAnnotation(annotation)
|
||
}
|
||
}
|
||
|
||
private func followPlaybackCoordinateIfNeeded(_ coordinate: CLLocationCoordinate2D) {
|
||
guard rootView.playBtn.isSelected else { return }
|
||
|
||
let now = Date()
|
||
if let lastUpdate = playbackLastCenterUpdate,
|
||
now.timeIntervalSince(lastUpdate) < playbackFollowInterval {
|
||
return
|
||
}
|
||
|
||
playbackLastCenterUpdate = now
|
||
rootView.mapView.setCenter(coordinate, animated: true)
|
||
}
|
||
|
||
private func playbackCoordinate(at progress: Double) -> CLLocationCoordinate2D? {
|
||
let clampedProgress = min(max(progress, 0), 1)
|
||
let path = playbackPath
|
||
guard !path.isEmpty, playbackTotalDistance > 0 else { return path.first?.coordinate }
|
||
|
||
let targetDistance = playbackTotalDistance * clampedProgress
|
||
|
||
var lo = 0, hi = path.count - 1
|
||
while lo < hi {
|
||
let mid = (lo + hi) / 2
|
||
if path[mid].distance < targetDistance {
|
||
lo = mid + 1
|
||
} else {
|
||
hi = mid
|
||
}
|
||
}
|
||
let idx = lo
|
||
let prev = idx > 0 ? path[idx - 1] : path[0]
|
||
let curr = path[idx]
|
||
let segDist = curr.distance - prev.distance
|
||
let ratio = segDist > 0 ? max(0, min(1, (targetDistance - prev.distance) / segDist)) : 0
|
||
return prev.coordinate.interpolate(to: curr.coordinate, ratio: ratio)
|
||
}
|
||
|
||
private func playbackPathDistance() -> CLLocationDistance {
|
||
playbackTotalDistance
|
||
}
|
||
|
||
@discardableResult
|
||
private func addPolyline(_ coordinates: [CLLocationCoordinate2D]) -> MAPolyline? {
|
||
guard coordinates.count > 1 else { return nil }
|
||
var mutableCoords = coordinates
|
||
guard let polyline = MAPolyline(coordinates: &mutableCoords, count: UInt(mutableCoords.count)) else { return nil }
|
||
rootView.mapView.add(polyline)
|
||
routeOverlays.append(polyline)
|
||
return polyline
|
||
}
|
||
|
||
private func coordinates(from path: AMapPath) -> [CLLocationCoordinate2D] {
|
||
var coords: [CLLocationCoordinate2D] = []
|
||
for step in path.steps {
|
||
guard let polylineStr = step.polyline else { continue }
|
||
for point in polylineStr.components(separatedBy: ";") {
|
||
let latLon = point.components(separatedBy: ",")
|
||
if latLon.count == 2,
|
||
let lon = Double(latLon[0]),
|
||
let lat = Double(latLon[1]) {
|
||
let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
||
if CLLocationCoordinate2DIsValid(coordinate) {
|
||
coords.append(coordinate)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return coords
|
||
}
|
||
|
||
private func eventTimeText(_ timestamp: Int64) -> String {
|
||
let seconds = timestamp > 10_000_000_000 ? Double(timestamp) / 1000.0 : Double(timestamp)
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "HH:mm"
|
||
return formatter.string(from: Date(timeIntervalSince1970: seconds))
|
||
}
|
||
|
||
private static func playbackAvatarImage(_ image: UIImage) -> UIImage? {
|
||
let size = CGSize(width: 34, height: 34)
|
||
UIGraphicsBeginImageContextWithOptions(size, false, 0)
|
||
defer { UIGraphicsEndImageContext() }
|
||
|
||
let rect = CGRect(origin: .zero, size: size)
|
||
UIColor.white.setFill()
|
||
UIBezierPath(ovalIn: rect).fill()
|
||
|
||
let imageRect = rect.insetBy(dx: 3, dy: 3)
|
||
let clipPath = UIBezierPath(ovalIn: imageRect)
|
||
clipPath.addClip()
|
||
image.draw(in: imageRect)
|
||
|
||
return UIGraphicsGetImageFromCurrentImageContext()
|
||
}
|
||
}
|
||
|
||
private extension CLLocationCoordinate2D {
|
||
func distance(to coordinate: CLLocationCoordinate2D) -> CLLocationDistance {
|
||
let fromLocation = CLLocation(latitude: latitude, longitude: longitude)
|
||
let toLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
|
||
return fromLocation.distance(from: toLocation)
|
||
}
|
||
|
||
func interpolate(to coordinate: CLLocationCoordinate2D, ratio: Double) -> CLLocationCoordinate2D {
|
||
let clampedRatio = min(max(ratio, 0), 1)
|
||
return CLLocationCoordinate2D(latitude: latitude + (coordinate.latitude - latitude) * clampedRatio,
|
||
longitude: longitude + (coordinate.longitude - longitude) * clampedRatio)
|
||
}
|
||
}
|
||
|
||
private extension Array where Element == CLLocationCoordinate2D {
|
||
var totalDistance: CLLocationDistance {
|
||
guard count > 1 else { return 0 }
|
||
var distance: CLLocationDistance = 0
|
||
for i in 0..<(count - 1) {
|
||
distance += self[i].distance(to: self[i + 1])
|
||
}
|
||
return distance
|
||
}
|
||
}
|
||
|
||
private final class TracePlaybackAnnotation: MAPointAnnotation {}
|
||
|
||
// MARK: - MAMapViewDelegate
|
||
extension ItineraryTraceVC: MAMapViewDelegate {
|
||
func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
|
||
guard !(annotation is MAUserLocation) else { return nil }
|
||
guard let pointAnn = annotation as? MAPointAnnotation else { return nil }
|
||
|
||
if annotation is TracePlaybackAnnotation {
|
||
let id = "PlaybackUserPin"
|
||
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
|
||
if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) }
|
||
else { view?.annotation = annotation }
|
||
view?.image = Self.playbackAvatarImage(memberModel.userIcon)
|
||
view?.centerOffset = CGPoint(x: 0, y: -16)
|
||
return view
|
||
}
|
||
|
||
if pointAnn.title == "start" {
|
||
let id = "StartPin"
|
||
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
|
||
if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) }
|
||
else { view?.annotation = annotation }
|
||
view?.image = UIImage(named: "ItineraryTrace/start")
|
||
view?.centerOffset = CGPoint(x: 0, y: -12)
|
||
return view
|
||
}
|
||
|
||
if pointAnn.title == "end" {
|
||
let id = "EndPin"
|
||
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
|
||
if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) }
|
||
else { view?.annotation = annotation }
|
||
view?.image = UIImage(named: "ItineraryTrace/end")
|
||
view?.centerOffset = CGPoint(x: 0, y: -12)
|
||
return view
|
||
}
|
||
|
||
if pointAnn.title == "event" {
|
||
let id = "EventPin"
|
||
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
|
||
if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) }
|
||
else { view?.annotation = annotation }
|
||
view?.viewWithTag(eventCalloutTag)?.removeFromSuperview()
|
||
view?.image = UIImage(named: "ItineraryTrace/event")
|
||
view?.bounds.size = CGSize(width: 20, height: 20)
|
||
view?.clipsToBounds = false
|
||
return view
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func mapView(_ mapView: MAMapView!, didSelect view: MAAnnotationView!) {
|
||
guard let annotation = view.annotation as? TraceEventAnnotation else { return }
|
||
view.viewWithTag(eventCalloutTag)?.removeFromSuperview()
|
||
|
||
let calloutWidth = TraceEventCalloutView.preferredWidth(type: annotation.eventType,
|
||
time: annotation.eventTime,
|
||
address: annotation.eventAddress)
|
||
let callout = TraceEventCalloutView(frame: CGRect(x: 0, y: 0, width: calloutWidth, height: 72))
|
||
callout.tag = eventCalloutTag
|
||
callout.configure(icon: annotation.eventTypeIcon,
|
||
type: annotation.eventTypeText,
|
||
time: annotation.eventTime,
|
||
address: annotation.eventAddress)
|
||
callout.frame.origin = CGPoint(x: (view.bounds.width - callout.bounds.width) / 2, y: -callout.bounds.height - 8)
|
||
view.addSubview(callout)
|
||
}
|
||
|
||
func mapView(_ mapView: MAMapView!, didDeselect view: MAAnnotationView!) {
|
||
view.viewWithTag(eventCalloutTag)?.removeFromSuperview()
|
||
}
|
||
|
||
func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! {
|
||
if let polyline = overlay as? MAPolyline {
|
||
let r = MAPolylineRenderer(polyline: polyline)
|
||
r?.strokeColor = UIColor(hexStr: "#16B3FF")
|
||
r?.lineWidth = 3
|
||
r?.lineJoinType = kMALineJoinRound
|
||
r?.lineCapType = kMALineCapRound
|
||
return r
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// MARK: - AMapSearchDelegate
|
||
extension ItineraryTraceVC: AMapSearchDelegate {
|
||
func onRouteSearchDone(_ request: AMapRouteSearchBaseRequest!, response: AMapRouteSearchResponse!) {
|
||
let requestId = ObjectIdentifier(request)
|
||
let segmentIndex = drivingSegmentIndexes.removeValue(forKey: requestId)
|
||
|
||
guard let path = response.route?.paths?.first as? AMapPath else {
|
||
return
|
||
}
|
||
|
||
let coords = coordinates(from: path)
|
||
if coords.count > 1 {
|
||
addPolyline(coords)
|
||
if let segmentIndex, playbackSegments.indices.contains(segmentIndex) {
|
||
playbackSegments[segmentIndex] = coords
|
||
buildPlaybackPath()
|
||
updatePlaybackAnnotation(progress: playbackProgress)
|
||
}
|
||
}
|
||
}
|
||
|
||
func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) {
|
||
if let request = request as? AMapRouteSearchBaseRequest {
|
||
let requestId = ObjectIdentifier(request)
|
||
drivingSegmentIndexes.removeValue(forKey: requestId)
|
||
}
|
||
print("Trace route search error: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
// MARK: - UICollectionViewDataSource & Delegate (事件列表)
|
||
extension ItineraryTraceVC: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||
return 8
|
||
}
|
||
|
||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||
let cell: ItineraryTraceCell = collectionView.dequeueReusableCell(for: indexPath)
|
||
guard let stats = model.statistics else { return cell }
|
||
let all: [(String, String, Int)] = [
|
||
("急加速", "GroupMemberList/1", stats.hard_acceleration),
|
||
("急转向", "GroupMemberList/3", stats.sharp_turn),
|
||
("急刹", "GroupMemberList/5", stats.hard_braking),
|
||
("手机干扰", "GroupMemberList/7", stats.signal_loss),
|
||
("超速", "GroupMemberList/2", stats.speeding),
|
||
("低速", "GroupMemberList/4", stats.low_speeding),
|
||
("频繁变道", "GroupMemberList/6", stats.frequent_lane_change),
|
||
("长时间驾驶", "GroupMemberList/8", stats.long_driving)
|
||
]
|
||
let item = all[indexPath.row]
|
||
cell.iconView.image = UIImage(named: item.1)
|
||
cell.nameLab.text = item.0
|
||
cell.countLab.text = "\(item.2)"
|
||
return cell
|
||
}
|
||
}
|
||
|
||
// MARK: - 事件点击气泡
|
||
private final class TraceEventAnnotation: MAPointAnnotation {
|
||
var eventType = ""
|
||
var eventTypeText: String {
|
||
get {
|
||
switch eventType {
|
||
case "hard_acceleration":
|
||
return "急加速"
|
||
case "sharp_turn":
|
||
return "急转向"
|
||
case "hard_braking":
|
||
return "急刹"
|
||
case "signal_loss":
|
||
return "手机干扰"
|
||
case "speeding":
|
||
return "超速"
|
||
case "low_speeding":
|
||
return "低速"
|
||
case "frequent_lane_change":
|
||
return "频繁变道"
|
||
case "long_driving":
|
||
return "长时间驾驶"
|
||
default:
|
||
return eventType
|
||
}
|
||
}
|
||
}
|
||
var eventTypeIcon: UIImage? {
|
||
get {
|
||
switch eventType {
|
||
case "hard_acceleration":
|
||
return UIImage(named: "GroupMemberList/1")
|
||
case "sharp_turn":
|
||
return UIImage(named: "GroupMemberList/3")
|
||
case "hard_braking":
|
||
return UIImage(named: "GroupMemberList/5")
|
||
case "signal_loss":
|
||
return UIImage(named: "GroupMemberList/7")
|
||
case "speeding":
|
||
return UIImage(named: "GroupMemberList/2")
|
||
case "low_speeding":
|
||
return UIImage(named: "GroupMemberList/4")
|
||
case "frequent_lane_change":
|
||
return UIImage(named: "GroupMemberList/6")
|
||
case "long_driving":
|
||
return UIImage(named: "GroupMemberList/8")
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
var eventTime = ""
|
||
var eventAddress = ""
|
||
}
|
||
|
||
private final class TraceEventCalloutView: UIView {
|
||
private let bubbleView = UIView()
|
||
private let arrowView = UIView()
|
||
|
||
private let typeIcon: UIImageView = {
|
||
let view = UIImageView()
|
||
view.contentMode = .scaleAspectFill
|
||
return view
|
||
}()
|
||
|
||
private let typeLab: UILabel = {
|
||
let lab = UILabel()
|
||
lab.font = .systemFont(ofSize: 14, weight: .medium)
|
||
lab.textColor = UIColor(hexStr: "#333333")
|
||
lab.numberOfLines = 1
|
||
return lab
|
||
}()
|
||
|
||
private let detailLab: UILabel = {
|
||
let lab = UILabel()
|
||
lab.font = .systemFont(ofSize: 12)
|
||
lab.textColor = UIColor(hexStr: "#666666")
|
||
lab.numberOfLines = 1
|
||
return lab
|
||
}()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
|
||
bubbleView.backgroundColor = .white
|
||
bubbleView.layer.cornerRadius = 8
|
||
bubbleView.layer.shadowColor = UIColor.black.withAlphaComponent(0.16).cgColor
|
||
bubbleView.layer.shadowOpacity = 1
|
||
bubbleView.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||
bubbleView.layer.shadowRadius = 8
|
||
|
||
arrowView.backgroundColor = .white
|
||
arrowView.transform = CGAffineTransform(rotationAngle: .pi / 4)
|
||
|
||
addSubview(bubbleView)
|
||
addSubview(arrowView)
|
||
bubbleView.addSubview(typeIcon)
|
||
bubbleView.addSubview(typeLab)
|
||
bubbleView.addSubview(detailLab)
|
||
|
||
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
let arrowSize: CGFloat = 12
|
||
bubbleView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: bounds.height - arrowSize)
|
||
arrowView.bounds = CGRect(x: 0, y: 0, width: arrowSize, height: arrowSize)
|
||
arrowView.center = CGPoint(x: bounds.midX, y: bubbleView.frame.maxY - 1)
|
||
typeIcon.frame = CGRect(x: 9, y: 12, width: 16, height: 16)
|
||
typeLab.frame = CGRect(x: 28, y: 10, width: bubbleView.bounds.width - 24, height: 20)
|
||
detailLab.frame = CGRect(x: 9, y: 34, width: bubbleView.bounds.width - 24, height: 18)
|
||
}
|
||
|
||
func configure(icon: UIImage?, type: String, time: String, address: String) {
|
||
if let img = icon {
|
||
typeIcon.image = img
|
||
}
|
||
typeLab.text = type
|
||
let detail = address.isEmpty ? time : "\(time) 在 \(address)"
|
||
let attr = NSMutableAttributedString(string: detail)
|
||
attr.addAttributes([
|
||
.foregroundColor: UIColor(hexStr: "#16B3FF"),
|
||
.font: UIFont.systemFont(ofSize: 12, weight: .medium)
|
||
], range: NSRange(location: 0, length: min(time.count, detail.count)))
|
||
detailLab.attributedText = attr
|
||
}
|
||
|
||
static func preferredWidth(type: String, time: String, address: String) -> CGFloat {
|
||
let title = type.isEmpty ? "驾驶事件" : type
|
||
let detail = address.isEmpty ? time : "\(time) 在 \(address)"
|
||
let titleWidth = (title as NSString).size(withAttributes: [.font: UIFont.systemFont(ofSize: 14, weight: .medium)]).width
|
||
let detailWidth = (detail as NSString).size(withAttributes: [.font: UIFont.systemFont(ofSize: 12)]).width + 5
|
||
return min(max(max(titleWidth, detailWidth) + 24, 120), 280)
|
||
}
|
||
}
|