diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteMapView.swift b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteMapView.swift index cd30ae1..8842575 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteMapView.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteMapView.swift @@ -10,11 +10,32 @@ import NMapsMap final class AppointmentRouteMapView: NMFNaverMapView { + /// 지도에 표시된 참여자 마커 — 클러스터링 시 식별자로 개별 제어 + private struct ParticipantMarker { + let id: String + let coordinate: Coordinate + let marker: NMFMarker + /// 클러스터 마커에 표시할 정보 + let member: ClusterMarkerView.Member + } + + /// 마커가 겹칠 때의 우선순위 — 클러스터 > 개별 참여자 > 목적지 + private enum MarkerZIndex { + static let place = 0 + static let participant = 1 + static let cluster = 2 + } + + /// 겹침 판정에 쓰는 참여자 마커 크기 + private static let participantMarkerSize = MapMarker.size(for: .participant(profileImage: nil, tintColor: .clear)) + + private lazy var clusterController = ClusterMarkerController(mapView: mapView, zIndex: MarkerZIndex.cluster) private var placeMarker: NMFMarker? - private var participantMarkers: [NMFMarker] = [] + private var participantMarkers: [ParticipantMarker] = [] private var participantPolylines: [NMFPolylineOverlay] = [] private var placeMarkerLoadTask: Task? private var markerLoadTask: Task? + private var placeCoordinate: Coordinate? private var hasMovedToInitialPosition = false override init(frame: CGRect) { @@ -27,16 +48,15 @@ final class AppointmentRouteMapView: NMFNaverMapView { setUp() } - // MARK: - Public + // MARK: - Lifecycle - func moveCamera(to coordinate: Coordinate, zoomLevel: Double = 15) { - let cameraPosition = NMFCameraPosition( - NMGLatLng(lat: coordinate.latitude, lng: coordinate.longitude), - zoom: zoomLevel - ) - mapView.moveCamera(NMFCameraUpdate(position: cameraPosition)) + override func didMoveToWindow() { + super.didMoveToWindow() + clusterController.setActive(window != nil) } + // MARK: - Public + func setPlaceMarker(coordinate: Coordinate, name: String) { placeMarkerLoadTask?.cancel() placeMarker?.mapView = nil @@ -44,13 +64,10 @@ final class AppointmentRouteMapView: NMFNaverMapView { placeMarkerLoadTask = Task { [weak self] in let pinImage = await AppAssetImageLoader.shared.load(.pin) guard !Task.isCancelled else { return } - await self?.addPlaceMarker(coordinate: coordinate, name: name, pinImage: pinImage) + self?.addPlaceMarker(coordinate: coordinate, name: name, pinImage: pinImage) } - if !hasMovedToInitialPosition { - hasMovedToInitialPosition = true - moveCamera(to: coordinate) - } + placeCoordinate = coordinate } func setParticipants(_ participants: [AppointmentRouteParticipant]) { @@ -67,9 +84,12 @@ final class AppointmentRouteMapView: NMFNaverMapView { guard !Task.isCancelled, !images.isEmpty else { return } for (index, participant) in participants.enumerated() { - await self?.addMarker(for: participant, color: colors[index], profileImage: images[index]) + self?.addMarker(for: participant, color: colors[index], profileImage: images[index]) } + self?.updateClusters() } + + moveToInitialPositionIfNeeded(participants: participants) } } @@ -84,6 +104,45 @@ private extension AppointmentRouteMapView { showLocationButton = true showScaleBar = false showZoomControls = true + mapView.addCameraDelegate(delegate: self) + } + + // MARK: - Clustering + + func updateClusters() { + let items = participantMarkers.map { participantMarker in + let coordinate = participantMarker.coordinate + return ParticipantClusterer.Item( + id: participantMarker.id, + coordinate: coordinate, + screenPoint: mapView.projection.point( + from: NMGLatLng(lat: coordinate.latitude, lng: coordinate.longitude) + ) + ) + } + let clusters = ParticipantClusterer(markerSize: Self.participantMarkerSize).cluster(items) + applyClusters(clusters) + } + + /// 묶인 참여자는 개별 마커를 숨기고 클러스터 마커로 대체 + func applyClusters(_ clusters: [ParticipantClusterer.Cluster]) { + let groupedClusters = clusters.filter { $0.memberIDs.count > 1 } + let groupedIDs = Set(groupedClusters.flatMap(\.memberIDs)) + + participantMarkers.forEach { $0.marker.hidden = groupedIDs.contains($0.id) } + clusterController.update(clusters: groupedClusters) { id in + participantMarkers.first { $0.id == id }?.member + } + } + + // MARK: - Camera + + func moveToInitialPositionIfNeeded(participants: [AppointmentRouteParticipant]) { + guard !hasMovedToInitialPosition, let placeCoordinate else { return } + hasMovedToInitialPosition = true + + let participantPath = participants.flatMap(\.path) + fitCamera(to: [placeCoordinate] + participantPath, maxZoom: MapZoom.overview) } // MARK: - Overlay Building @@ -130,6 +189,7 @@ private extension AppointmentRouteMapView { marker.width = MapMarker.size(for: kind).width marker.height = MapMarker.size(for: kind).height marker.anchor = MapMarker.anchor(for: kind) + marker.zIndex = MarkerZIndex.place marker.mapView = mapView placeMarker = marker @@ -147,16 +207,39 @@ private extension AppointmentRouteMapView { marker.width = MapMarker.size(for: kind).width marker.height = MapMarker.size(for: kind).height marker.anchor = MapMarker.anchor(for: kind) + marker.zIndex = MarkerZIndex.participant marker.mapView = mapView - participantMarkers.append(marker) + participantMarkers.append( + ParticipantMarker( + id: participant.id, + coordinate: position, + marker: marker, + member: ClusterMarkerView.Member( + nickname: participant.nickname, + profileImage: profileImage, + tintColor: color + ) + ) + ) } func clearParticipantOverlays() { - participantMarkers.forEach { $0.mapView = nil } + participantMarkers.forEach { $0.marker.mapView = nil } participantMarkers.removeAll() + clusterController.removeAll() participantPolylines.forEach { $0.mapView = nil } participantPolylines.removeAll() } } + +// MARK: - NMFMapViewCameraDelegate + +extension AppointmentRouteMapView: NMFMapViewCameraDelegate { + + func mapViewCameraIdle(_ mapView: NMFMapView) { + updateClusters() + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ClusterMarkerController.swift b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ClusterMarkerController.swift new file mode 100644 index 0000000..4170224 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ClusterMarkerController.swift @@ -0,0 +1,183 @@ +// +// ClusterMarkerController.swift +// WhereAreYou +// +// Created by 김성훈 on 10/6/26. +// + +import UIKit +import NMapsMap + +/// 클러스터 마커의 표시, 멤버 순환, 슬라이드 전환 관리 +final class ClusterMarkerController: NSObject { + + /// 지도에 표시된 클러스터 마커 — 멤버 구성이 같으면 재사용 + private struct ClusterMarker { + let memberIDs: [String] + let marker: NMFMarker + /// 멤버별 정지 이미지 — 전환 프레임의 재료 + let images: [UIImage] + let icons: [NMFOverlayImage] + var currentIndex = 0 + } + + /// 클러스터 대표 멤버가 바뀌는 주기 + private static let rotationInterval: Duration = .seconds(2) + /// 대표 멤버가 바뀌는 슬라이드 전환 시간 + private static let transitionDuration: CFTimeInterval = 0.35 + + private weak var mapView: NMFMapView? + private let zIndex: Int + private var clusterMarkers: [ClusterMarker] = [] + private var isActive = false + private var rotationTask: Task? + private var transitionLink: CADisplayLink? + private var transitionStart: CFTimeInterval = 0 + + init(mapView: NMFMapView, zIndex: Int) { + self.mapView = mapView + self.zIndex = zIndex + super.init() + } + + // MARK: - Public + + /// 클러스터 목록에 맞춰 마커를 갱신 — 멤버 구성이 같은 클러스터는 기존 마커 재사용 + func update(clusters: [ParticipantClusterer.Cluster], member: (String) -> ClusterMarkerView.Member?) { + var staleMarkers = clusterMarkers + clusterMarkers = clusters.map { cluster in + if let index = staleMarkers.firstIndex(where: { $0.memberIDs == cluster.memberIDs }) { + return staleMarkers.remove(at: index) + } + return makeClusterMarker(for: cluster, members: cluster.memberIDs.compactMap(member)) + } + staleMarkers.forEach { $0.marker.mapView = nil } + updateRotation() + } + + /// 화면 표시 여부 — 표시 중일 때만 순환 동작 + func setActive(_ isActive: Bool) { + self.isActive = isActive + updateRotation() + } + + func removeAll() { + clusterMarkers.forEach { $0.marker.mapView = nil } + clusterMarkers.removeAll() + updateRotation() + } + +} + +// MARK: - Private + +private extension ClusterMarkerController { + + private func makeClusterMarker(for cluster: ParticipantClusterer.Cluster, members: [ClusterMarkerView.Member]) -> ClusterMarker { + let images = members.indices.map { index in + ClusterMarkerView.renderImage(members: members, memberIndex: index) + } + let icons = images.map { NMFOverlayImage(image: $0) } + + let coordinate = cluster.coordinate + let marker = NMFMarker(position: NMGLatLng(lat: coordinate.latitude, lng: coordinate.longitude)) + if let firstIcon = icons.first { + marker.iconImage = firstIcon + } + marker.width = ClusterMarkerView.size.width + marker.height = ClusterMarkerView.size.height + marker.anchor = ClusterMarkerView.anchor + marker.zIndex = zIndex + marker.mapView = mapView + + return ClusterMarker(memberIDs: cluster.memberIDs, marker: marker, images: images, icons: icons) + } + + // MARK: - Rotation + + /// 클러스터가 있고 화면에 떠 있을 때만 순환을 돌리고, 아니면 정지 + func updateRotation() { + guard !clusterMarkers.isEmpty, isActive else { + cancelTransition() + rotationTask?.cancel() + rotationTask = nil + return + } + guard rotationTask == nil else { return } + + rotationTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: Self.rotationInterval) + guard !Task.isCancelled, let self else { return } + self.rotateMembers() + } + } + } + + /// 동작 줄이기가 켜져 있으면 바로 교체하고, 아니면 슬라이드 전환 + func rotateMembers() { + if UIAccessibility.isReduceMotionEnabled { + showNextMembers() + } else { + startTransition() + } + } + + // MARK: - Transition + + func startTransition() { + guard transitionLink == nil else { return } + + let link = CADisplayLink(target: self, selector: #selector(updateTransition)) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 20, maximum: 30, preferred: 30) + link.add(to: .main, forMode: .common) + transitionLink = link + transitionStart = CACurrentMediaTime() + } + + @objc func updateTransition() { + let elapsed = (CACurrentMediaTime() - transitionStart) / Self.transitionDuration + guard elapsed < 1 else { + finishTransition() + return + } + + // 가속 후 감속 + let progress = elapsed * elapsed * (3 - 2 * elapsed) + for clusterMarker in clusterMarkers where clusterMarker.images.count > 1 { + let nextIndex = (clusterMarker.currentIndex + 1) % clusterMarker.images.count + let frame = ClusterMarkerView.renderTransitionFrame( + from: clusterMarker.images[clusterMarker.currentIndex], + to: clusterMarker.images[nextIndex], + progress: progress + ) + clusterMarker.marker.iconImage = NMFOverlayImage(image: frame) + } + } + + /// 전환을 끝내고 다음 멤버로 확정 + func finishTransition() { + transitionLink?.invalidate() + transitionLink = nil + showNextMembers() + } + + /// 전환을 중단하고 현재 멤버의 정지 아이콘으로 복원 + func cancelTransition() { + guard let link = transitionLink else { return } + link.invalidate() + transitionLink = nil + for clusterMarker in clusterMarkers where clusterMarker.icons.indices.contains(clusterMarker.currentIndex) { + clusterMarker.marker.iconImage = clusterMarker.icons[clusterMarker.currentIndex] + } + } + + func showNextMembers() { + for index in clusterMarkers.indices where clusterMarkers[index].icons.count > 1 { + let nextIndex = (clusterMarkers[index].currentIndex + 1) % clusterMarkers[index].icons.count + clusterMarkers[index].currentIndex = nextIndex + clusterMarkers[index].marker.iconImage = clusterMarkers[index].icons[nextIndex] + } + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ClusterMarkerView.swift b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ClusterMarkerView.swift new file mode 100644 index 0000000..abe3ccd --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ClusterMarkerView.swift @@ -0,0 +1,198 @@ +// +// ClusterMarkerView.swift +// WhereAreYou +// +// Created by 김성훈 on 10/6/26. +// + +import UIKit + +/// 겹친 참여자 마커를 대신하는 클러스터 마커 이미지의 렌더링 소스 뷰 +final class ClusterMarkerView: UIView { + + /// 클러스터에 속한 참여자의 표시 정보 + struct Member { + let nickname: String + let profileImage: UIImage + let tintColor: UIColor + } + + /// 마커 전체 크기 + static let size = CGSize(width: contentWidth, height: iconSize + nameBackgroundHeight + shadowMargin) + /// 지도 좌표가 놓이는 비율 — 프로필 원 하단 중앙 + static let anchor = CGPoint(x: 0.5, y: iconSize / size.height) + + /// 지도 마커 아이콘으로 쓸 이미지 렌더링 + static func renderImage(members: [Member], memberIndex: Int) -> UIImage { + let view = ClusterMarkerView(members: members, memberIndex: memberIndex) + view.layoutIfNeeded() + return UIGraphicsImageRenderer(size: size).image { context in + view.layer.render(in: context.cgContext) + } + } + + /// 대표 멤버가 바뀌는 전환의 중간 프레임 렌더링 — progress 0은 from, 1은 to와 같은 이미지 + static func renderTransitionFrame(from: UIImage, to: UIImage, progress: CGFloat) -> UIImage { + if progress <= 0 { return from } + if progress >= 1 { return to } + + let imageRect = CGRect(origin: .zero, size: size) + let avatarRect = CGRect(x: (size.width - iconSize) / 2, y: 0, width: iconSize, height: iconSize) + let nameRect = CGRect(x: 0, y: iconSize, width: size.width, height: size.height - iconSize) + let nameFade = min(max((progress - nameFadeRange.lowerBound) / (nameFadeRange.upperBound - nameFadeRange.lowerBound), 0), 1) + + return UIGraphicsImageRenderer(size: size).image { context in + let cgContext = context.cgContext + + // 프로필 원 — 원형 창 안에서 이전 원은 왼쪽 위로 밀려나고 새 원은 오른쪽 아래에서 들어옴 + cgContext.saveGState() + cgContext.addEllipse(in: avatarRect) + cgContext.clip() + for (image, step) in [(from, progress), (to, progress - 1)] { + guard let avatar = croppedAvatar(of: image, in: avatarRect) else { continue } + let movedRect = avatarRect.offsetBy(dx: slideOffset.width * step, dy: slideOffset.height * step) + cgContext.saveGState() + cgContext.addEllipse(in: movedRect.insetBy(dx: -0.5, dy: -0.5)) + cgContext.clip() + avatar.draw(in: movedRect) + cgContext.restoreGState() + } + cgContext.restoreGState() + + // 이름 영역 — 전환 중간에서만 교차 페이드, 더하기 합성이라 두 이름 배경이 겹쳐도 투명해지지 않음 + cgContext.saveGState() + cgContext.clip(to: nameRect) + from.draw(in: imageRect, blendMode: .normal, alpha: 1 - nameFade) + to.draw(in: imageRect, blendMode: .plusLighter, alpha: nameFade) + cgContext.restoreGState() + } + } + + /// 이동시켜도 이름 영역과 그 그림자가 딸려 들어오지 않도록 프로필 원 부분만 잘라낸 이미지 + private static func croppedAvatar(of image: UIImage, in rect: CGRect) -> UIImage? { + let scale = image.scale + let pixelRect = CGRect(x: rect.minX * scale, y: rect.minY * scale, width: rect.width * scale, height: rect.height * scale) + return image.cgImage?.cropping(to: pixelRect).map { UIImage(cgImage: $0, scale: scale, orientation: .up) } + } + + // MARK: - Constants + + private static let contentWidth: CGFloat = 100 + private static let iconSize: CGFloat = 40 + private static let nameBackgroundHeight: CGFloat = 22 + private static let shadowMargin: CGFloat = 8 + /// 이전 원이 창 밖으로 밀려나는 이동량 — 왼쪽 위 방향으로 원 지름 + 간격 + private static let slideOffset = CGSize(width: -(iconSize + 8) * 0.86, height: -(iconSize + 8) * 0.5) + /// 이름 영역이 교차 페이드되는 진행 구간 — 짧게만 겹쳐 글자 번짐을 줄임 + private static let nameFadeRange: ClosedRange = 0.35...0.65 + + // MARK: - Subviews + + private let avatarCircleView: UIView = { + let view = UIView() + view.backgroundColor = .pointBackground + view.clipsToBounds = true + view.layer.cornerRadius = ClusterMarkerView.iconSize / 2 + view.layer.borderWidth = 2.5 + return view + }() + + private let avatarImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let nameBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = .systemBackground + view.layer.cornerRadius = 4 + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = 0.3 + view.layer.shadowOffset = CGSize(width: 0, height: 3) + return view + }() + + private let nameLabel: UILabel = { + let label = UILabel() + label.font = .boldPreferredFont(forTextStyle: .caption2) + label.textColor = .label + label.lineBreakMode = .byTruncatingTail + return label + }() + + private let countLabel: UILabel = { + let label = UILabel() + label.font = .boldPreferredFont(forTextStyle: .caption2) + label.textColor = .label + label.setContentCompressionResistancePriority(.required, for: .horizontal) + return label + }() + + // MARK: - Init + + private init(members: [Member], memberIndex: Int) { + super.init(frame: CGRect(origin: .zero, size: Self.size)) + setUp() + configure(with: members, memberIndex: memberIndex) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Private + +private extension ClusterMarkerView { + + func setUp() { + let nameStack = UIStackView(arrangedSubviews: [nameLabel, countLabel]) + nameStack.axis = .horizontal + nameStack.spacing = 3 + + avatarCircleView.addSubview(avatarImageView) + nameBackgroundView.addSubview(nameStack) + [avatarCircleView, nameBackgroundView].forEach { addSubview($0) } + [avatarCircleView, avatarImageView, nameBackgroundView, nameStack].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + } + + let imageInset = Self.iconSize * 0.15 + NSLayoutConstraint.activate([ + avatarCircleView.topAnchor.constraint(equalTo: topAnchor), + avatarCircleView.centerXAnchor.constraint(equalTo: centerXAnchor), + avatarCircleView.widthAnchor.constraint(equalToConstant: Self.iconSize), + avatarCircleView.heightAnchor.constraint(equalToConstant: Self.iconSize), + + avatarImageView.topAnchor.constraint(equalTo: avatarCircleView.topAnchor, constant: imageInset), + avatarImageView.leadingAnchor.constraint(equalTo: avatarCircleView.leadingAnchor, constant: imageInset), + avatarImageView.trailingAnchor.constraint(equalTo: avatarCircleView.trailingAnchor, constant: -imageInset), + avatarImageView.bottomAnchor.constraint(equalTo: avatarCircleView.bottomAnchor, constant: -imageInset), + + nameBackgroundView.topAnchor.constraint(equalTo: avatarCircleView.bottomAnchor), + nameBackgroundView.centerXAnchor.constraint(equalTo: centerXAnchor), + nameBackgroundView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor), + nameBackgroundView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + nameBackgroundView.heightAnchor.constraint(equalToConstant: Self.nameBackgroundHeight), + + nameStack.leadingAnchor.constraint(equalTo: nameBackgroundView.leadingAnchor, constant: 6), + nameStack.trailingAnchor.constraint(equalTo: nameBackgroundView.trailingAnchor, constant: -6), + nameStack.centerYAnchor.constraint(equalTo: nameBackgroundView.centerYAnchor), + ]) + } + + /// memberIndex 멤버 기준으로 표시 + func configure(with members: [Member], memberIndex: Int) { + guard members.indices.contains(memberIndex) else { return } + let member = members[memberIndex] + + avatarImageView.image = member.profileImage + avatarCircleView.layer.borderColor = member.tintColor.cgColor + nameLabel.text = member.nickname + countLabel.text = "외 \(members.count - 1)명" + countLabel.isHidden = members.count < 2 + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ParticipantClusterer.swift b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ParticipantClusterer.swift new file mode 100644 index 0000000..a9f3cc5 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/ParticipantClusterer.swift @@ -0,0 +1,88 @@ +// +// ParticipantClusterer.swift +// WhereAreYou +// +// Created by 김성훈 on 10/6/26. +// + +import CoreGraphics + +/// 화면 거리 기준 참여자 마커 클러스터링 계산 +struct ParticipantClusterer { + + /// 클러스터링 입력 단위 + struct Item { + let id: String + let coordinate: Coordinate + /// 현재 카메라 기준 화면 좌표 + let screenPoint: CGPoint + } + + /// 클러스터링 결과 단위 + struct Cluster { + /// 입력 순서를 유지한 멤버 식별자 + let memberIDs: [String] + /// 멤버 좌표 평균 + let coordinate: Coordinate + } + + /// 마커 한 개의 화면 크기 — 모든 마커가 같은 크기라는 전제로 이 크기만큼 겹치면 같은 클러스터로 판정 + let markerSize: CGSize + + func cluster(_ items: [Item]) -> [Cluster] { + var groups: [ClusterGroup] = [] + + for item in items { + if let index = nearestOverlappingGroupIndex(for: item, in: groups) { + groups[index].members.append(item) + } else { + groups.append(ClusterGroup(leader: item, members: [item])) + } + } + + return groups.map { makeCluster(from: $0.members) } + } + +} + +// MARK: - Private + +private extension ParticipantClusterer { + + /// 첫 멤버(기준 마커)를 기준으로 비교하는 그룹 + struct ClusterGroup { + let leader: Item + var members: [Item] + } + + /// 기준 마커와 겹치는 그룹 중 가장 가까운 그룹의 인덱스 + func nearestOverlappingGroupIndex(for item: Item, in groups: [ClusterGroup]) -> Int? { + groups.indices + .filter { isOverlapping(item.screenPoint, groups[$0].leader.screenPoint) } + .min { + squaredDistance(item.screenPoint, groups[$0].leader.screenPoint) + < squaredDistance(item.screenPoint, groups[$1].leader.screenPoint) + } + } + + func isOverlapping(_ lhs: CGPoint, _ rhs: CGPoint) -> Bool { + abs(lhs.x - rhs.x) < markerSize.width && abs(lhs.y - rhs.y) < markerSize.height + } + + func squaredDistance(_ lhs: CGPoint, _ rhs: CGPoint) -> CGFloat { + let deltaX = lhs.x - rhs.x + let deltaY = lhs.y - rhs.y + return deltaX * deltaX + deltaY * deltaY + } + + func makeCluster(from members: [Item]) -> Cluster { + let count = Double(members.count) + let latitude = members.reduce(0) { $0 + $1.coordinate.latitude } / count + let longitude = members.reduce(0) { $0 + $1.coordinate.longitude } / count + return Cluster( + memberIDs: members.map(\.id), + coordinate: Coordinate(latitude: latitude, longitude: longitude) + ) + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/LocationPreview/LocationPreviewMapView.swift b/WhereAreYou/WhereAreYou/Presentation/Map/LocationPreview/LocationPreviewMapView.swift index 42207dd..df26530 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/LocationPreview/LocationPreviewMapView.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/LocationPreview/LocationPreviewMapView.swift @@ -26,14 +26,6 @@ final class LocationPreviewMapView: NMFNaverMapView { // MARK: - Public - func moveCamera(to coordinate: Coordinate, zoomLevel: Double = 16) { - let cameraPosition = NMFCameraPosition( - NMGLatLng(lat: coordinate.latitude, lng: coordinate.longitude), - zoom: zoomLevel - ) - mapView.moveCamera(NMFCameraUpdate(position: cameraPosition)) - } - func setMarker(coordinate: Coordinate, name: String, accentColor: UIColor) { markerLoadTask?.cancel() marker?.mapView = nil @@ -41,7 +33,7 @@ final class LocationPreviewMapView: NMFNaverMapView { markerLoadTask = Task { [weak self] in let pinImage = await AppAssetImageLoader.shared.load(.pin) guard !Task.isCancelled else { return } - await self?.addMarker(coordinate: coordinate, name: name, accentColor: accentColor, pinImage: pinImage) + self?.addMarker(coordinate: coordinate, name: name, accentColor: accentColor, pinImage: pinImage) } if !hasMovedToInitialPosition { diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/NMFNaverMapView+Camera.swift b/WhereAreYou/WhereAreYou/Presentation/Map/NMFNaverMapView+Camera.swift new file mode 100644 index 0000000..4bea14c --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Map/NMFNaverMapView+Camera.swift @@ -0,0 +1,49 @@ +// +// NMFNaverMapView+Camera.swift +// WhereAreYou +// +// Created by 김성훈 on 10/6/26. +// + +import NMapsMap + +/// 화면 성격별 지도 기본 줌 레벨 +enum MapZoom { + + /// 여러 지점을 함께 보는 화면용 줌 + static let overview: Double = 15 + /// 단일 장소를 자세히 보는 화면용 줌 + static let detail: Double = 16 + +} + +extension NMFNaverMapView { + + /// 지정 좌표로 카메라 이동 + func moveCamera(to coordinate: Coordinate, zoomLevel: Double = MapZoom.detail) { + let cameraPosition = NMFCameraPosition( + NMGLatLng(lat: coordinate.latitude, lng: coordinate.longitude), + zoom: zoomLevel + ) + mapView.moveCamera(NMFCameraUpdate(position: cameraPosition)) + } + + /// 모든 좌표가 보이도록 카메라 이동 + func fitCamera(to coordinates: [Coordinate], padding: CGFloat = 60, maxZoom: Double = MapZoom.detail) { + guard let firstCoordinate = coordinates.first else { return } + + let latLngs = coordinates.map { NMGLatLng(lat: $0.latitude, lng: $0.longitude) } + let bounds = NMGLatLngBounds(latLngs: latLngs) + guard bounds.latSpan > 0 || bounds.lngSpan > 0 else { + moveCamera(to: firstCoordinate, zoomLevel: maxZoom) + return + } + + let map = mapView + map.moveCamera(NMFCameraUpdate(fit: bounds, padding: padding)) { [weak map] isCancelled in + guard !isCancelled, let map, map.cameraPosition.zoom > maxZoom else { return } + map.moveCamera(NMFCameraUpdate(zoomTo: maxZoom)) + } + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionMapView.swift b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionMapView.swift index 61e5062..27d0c01 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionMapView.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionMapView.swift @@ -49,14 +49,6 @@ final class PlaceSelectionMapView: NMFNaverMapView { ]) } - func moveCamera(to coordinate: Coordinate, zoomLevel: Double = 16) { - let cameraPosition = NMFCameraPosition( - NMGLatLng(lat: coordinate.latitude, lng: coordinate.longitude), - zoom: zoomLevel - ) - mapView.moveCamera(NMFCameraUpdate(position: cameraPosition)) - } - } // MARK: - NMFMapViewCameraDelegate