Draw inverted circle and calculate zoom level based on radius in Google Maps

Trong bài viết này, chúng ta sẽ tìm hiểu về cách dùng các class có sẵn trong Google Maps SDK để làm những thứ không có sẵn. Cụ thể:

  • Vẽ một hình tròn với bán kính cho trước có phần bên trong tranperancy và phần map bên ngoài hình tròn được fill bởi màu khác.
  • Tính zoom level phù hợp dựa trên bán kính của một đường tròn cho trước.

A. Draw inverted circle in Google Maps.

Trong Google Maps để vẽ một hình tròn fill màu bên trong rất đơn giản. Chúng ta chỉ cần class có sẵn GMSCircle và vài dòng code. Ví dụ để vẽ một hình tròn có bán kính 500m tại vị trí hiện tại của camera ta có:

    fileprivate func drawCircle(centerOn coordinate: CLLocationCoordinate2D) {
        let circle = GMSCircle(position: coordinate, radius: 500)
        circle.fillColor = UIColor.red.withAlphaComponent(0.2)
        circle.strokeColor = .red
        circle.strokeWidth = 2.0
        circle.map = mapView
    }

Kết quả:

Tuy nhiên để vẽ một hình tròn với phần bên trong trong suốt, phần map bên ngoài được fill màu thì không đơn giản như vậy. Để làm điều này, ý tưởng là fill màu lên toàn bộ bề mặt trái đất rồi khoét một hình tròn màu trong suốt lên đó. Đọc kĩ trang document của Google Maps SDK phần Shapes, ta thấy có một class tên là GMSPolygon có vẻ khả thi. Vì:

  • Class GMSPolygon giúp vẽ một đa giác trên map bằng việc nối các điểm tọa độ với nhau.
  • Class GMSPolygon có một property là holes thuộc kiểu GMSMutablePath giúp vẽ một đường khép kín bên trong.

Vậy thì chúng ta cần:

Bước 1. Thể hiện một hình tròn có bán kính cho trước bằng class GMSMutablePath

    fileprivate func createHoles(in coordinate: CLLocationCoordinate2D, radius: CLLocationDistance)
            -> GMSMutablePath {
        let circlePath = GMSMutablePath()
        // 1.
        let centerPoint = mapView.projection.point(for: coordinate)
        // 2.
        let radius = mapView.projection.points(forMeters: radius, at: coordinate)
        var circlePoint = CGPoint()
        for angle in 0..<360 {
            // 3.
            circlePoint.x = centerPoint.x + radius * CGFloat(cos(Double(angle) * .pi / 180.0))
            circlePoint.y = centerPoint.y + radius * CGFloat(sin(Double(angle) * .pi / 180.0))
            // 4.
            circlePath.add(mapView.projection.coordinate(for: circlePoint))
        }
        return circlePath
    }
  1. Sử dụng class GMSProjection chuyển toạ độ tâm đường tròn trên thực tế (latitude, longitude) sang toạ độ CGPoint trên view.

  2. Tính toán độ dài bán kính đường tròn theo đơn vị CGFloat.

  3. Sử dụng các hàm lượng giác cơ bản để tính toán 360 điểm nằm trên đường tròn tương ứng với góc hợp thành từ 0° -> 360°.

  4. Thêm các điểm nằm tính được để cấu tạo nên đường tròn bằng GMSMutablePath.

Bước 2. Vẽ một đa giác lên toàn bộ bề mặt trái đất.

Vẽ một đa giác fill màu bao phủ lên toàn bộ bề mặt trái đất thì ta có thể vẽ qua các điểm cực với (latitude, longitude) tương ứng: A (-85.05115, -180.0) -> B (85.05115, -180.0) -> C (85.05115, 0.0) -> D (85.05115, 180.0) E (-85.0515, 180.0) -> F (-85.0515, 0.0) -> A (-85.05115, -180.0)

Tuy nhiên do kinh độ -180° và 180° trùng nhau lên khi vẽ qua các điểm như trên ta sẽ thu được kết quả không được như ý muốn:

Vậy thì thay vì vẽ một đa giác thì thử vẽ 2 đa giác: 1 cái phủ lên bán cầu tây, 1 cái bán cầu đông xem sao.

Khai báo 2 property GMSPolygon tương ứng.

    let westernHemisphere = GMSPolygon()
    let easternHemisphere = GMSPolygon()

Hàm vẽ đa giác.

    fileprivate func drawPolygons() {
        // Draw Western Hemisphere
        let westernPath = GMSMutablePath()
        westernPath.addLatitude(-85.05115, longitude: -180.0)
        westernPath.addLatitude(85.05115, longitude: -180.0)
        westernPath.addLatitude(85.05115, longitude: 0.0)
        westernPath.addLatitude(-85.05115, longitude: 0.0)
        westernHemisphere.fillColor = UIColor.blue.withAlphaComponent(0.2)
        westernHemisphere.path = westernPath
        westernHemisphere.map = mapView
        // Draw Eastern Hemisphere
        let easternPath = GMSMutablePath()
        easternPath.addLatitude(-85.05115, longitude: 0.0)
        easternPath.addLatitude(85.05115, longitude: 0.0)
        easternPath.addLatitude(85.05115, longitude: 180.0)
        easternPath.addLatitude(-85.05115, longitude: 180.0)
        easternHemisphere.fillColor = UIColor.blue.withAlphaComponent(0.2)
        easternHemisphere.path = easternPath
        easternHemisphere.map = mapView
    }

Hàm vẽ hình tròn cuối cùng.

    fileprivate func drawInvertedCircle(centerOn coordinate: CLLocationCoordinate2D) {
        drawPolygons()
        let hole = createHoles(in: coordinate, radius: 500)
        // Nếu tâm hình tròn nằm ở bán cầu tây thì set holes cho bán cầu tây và ngược lại.
        if coordinate.longitude < 0.0 {
            westernHemisphere.holes = [hole]
        } else {
            easternHemisphere.holes = [hole]
        }
    }

Sửa lại fillColor trong hàm vẽ hình tròn lúc đầu thành .clearColor, gọi hàm drawInvertedCircle(😃 trong viewDidLoad và đây là kết quả:

Khi zoom không còn hiện tượng bị mất 1 nửa bán cầu như trước nữa.

B. Calculate zoom level based on a cirlce radius.

Để dễ hình dung hơn, ta có thể đặt ra yêu cầu sau: Cho một bức ảnh hình chữ nhật có fill màu blue alpha 0.2 và một hình tròn ở chính giữa có viền, bên trong trong suốt. Yêu cầu là tính zoom level của map khi hiển thị sao cho khoảng cách từ tâm đường tròn đúng bằng một khoảng cách cho trước (500m). Giả sử như sau:

Muốn mapView hiển thị với zoom level thỏa mãn yêu cầu trên, ta có thể sử dụng GMSCameraUpdate.fit(bounds: GMSCoordinateBounds, withPadding: CGFloat). GMSCoordinateBounds được hình thành từ 2 toạ độ CLLocationCoordinate2D top left (A) và bottom right (B). Vì vậy ta chỉ cần tính toạ độ CGPoint của top left, bottom right của frame map cần fit và cả khoảng cách từ tâm đến 2 điểm này (c). Ta có:

  • x(A) = w * cosα, y(A) = h * sinα ← α = arctan(h / w) ← tanα = h / w ← w = mapView.bounds.width / 2, h = mapView.bounds.height / 2.
  • Tính tương tự với điểm bottom right B.
  • Độ dài đường chéo c = sqrt (w * w + h * h)

Từ lập luận trên, ta hiện thực hoá bằng code.

extension GMSMapView {

    // circleRatio for example: 300.0/360.0
    func animateToFitCircleOn(center: CLLocationCoordinate2D, radius: CLLocationDistance, circleRatio: Double) {
        let centerPoint = projection.point(for: center)
        // Tính w
        let halfWidth = radius / circleRatio
        // Tính h
        let halfHeight = halfWidth / Double(self.bounds.width / self.bounds.height)
        // Tính c
        let halfCross = sqrt(halfWidth * halfWidth + halfHeight * halfHeight)
        let radiusInView = projection.points(forMeters: halfCross, at: center)
        // Tính góc alpha, beta
        let beta = atan(halfHeight / halfWidth)
        let alpha = beta - .pi
        var topLeftPoint = CGPoint()
        var bottomRightPoint = CGPoint()
        // Tính toạ độ top left, bottom right: CGPoint
        topLeftPoint.x = centerPoint.x + radiusInView * CGFloat(cos(alpha))
        topLeftPoint.y = centerPoint.y + radiusInView * CGFloat(sin(alpha))
        bottomRightPoint.x = centerPoint.x + radiusInView * CGFloat(cos(beta))
        bottomRightPoint.y = centerPoint.y + radiusInView * CGFloat(sin(beta))
        // Convert sang toạ độ latitude, longitude
        let topLeft = projection.coordinate(for: topLeftPoint)
        let bottomRight = projection.coordinate(for: bottomRightPoint)
        let bounds = GMSCoordinateBounds(coordinate: topLeft, coordinate: bottomRight)
        let cameraUpdate = GMSCameraUpdate.fit(bounds, withPadding: 0.0)
        animate(with: cameraUpdate)
    }

}

Kết quả:

Link GitHub demo


All Rights Reserved