// // 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 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 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 = [] var sampled: [(timestamp: Int64, coordinate: CLLocationCoordinate2D)] = [] for pt in points { if let last = sampled.last { if pt.timestamp - last.timestamp >= 5_000 { sampled.append(pt) } } else { sampled.append(pt) } } let coords = sampled.map { $0.coordinate } addPolyline(coords) playbackSegments = [coords] buildPlaybackPath() updatePlaybackAnnotation(progress: playbackProgress) // let middle = sampled.dropFirst().dropLast().prefix(16) // let waypoints = middle.map { $0.coordinate } // // let request = AMapDrivingRouteSearchRequest() // request.origin = AMapGeoPoint.location(withLatitude: CGFloat(points.first!.coordinate.latitude), longitude: CGFloat(points.first!.coordinate.longitude)) // request.destination = AMapGeoPoint.location(withLatitude: CGFloat(points.last!.coordinate.latitude), longitude: CGFloat(points.last!.coordinate.longitude)) // request.strategy = 0 // if !waypoints.isEmpty { // request.waypoints = waypoints.map { AMapGeoPoint.location(withLatitude: CGFloat($0.latitude), longitude: CGFloat($0.longitude)) } // } // routeSearch?.aMapDrivingRouteSearch(request) print("[trace] cleaned=\(points.count) sampled=\(sampled.count)") } 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() } 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 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!) { guard let path = response.route?.paths?.first as? AMapPath else { return } let coords = coordinates(from: path) guard coords.count > 1 else { return } addPolyline(coords) playbackSegments = [coords] buildPlaybackPath() updatePlaybackAnnotation(progress: playbackProgress) } func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) { 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) } }