Skip to content

MapParticipationUtil

조익성 edited this page Nov 14, 2025 · 3 revisions

MapParticipantUtil

코드 위치: https://github.com/YangJJune/U-Compass/blob/dev/app/src/main/java/com/ikseong/ucompass/ui/util/MapParticipantUtil.kt

위치 기반 방향 계산을 위한 유틸리티 클래스입니다. 지도상에서 참가자들의 상대적 위치와 방향을 계산합니다.

관련 영상

MapParticipant1.mp4
MapParticipant2.mp4

전체 흐름도

내 위치 + 목표 위치
        ↓
calculateBearing() ← 두 좌표 사이의 진북 기준 방위각
        ↓
calcRelativeAngle() ← 내가 보는 방향과의 차이 계산
        ↓
getRelativeBearing() ← "내 기준" 상대 방위각 (-180~180°)
        ↓
getCardinalDirectionFromRelative() ← 사분면 판별 (N/E/S/W)
        ↓
rectangleSideAndDistance() ← 화면 가장자리 핀 배치 위치

함수별 상세 설명

1. 📍 calculateBearing() - 방위각 계산

private fun calculateBearing(
    lat1: Double, lon1: Double,
    lat2: Double, lon2: Double
): Double

🎯 목적: "A 지점에서 B 지점으로 가려면 어느 방향으로 가야 하는가?"

사용 시나리오:

  • 서울에서 부산으로 가는 방향은?
  • 지도에서 두 마커 사이의 방향은?
  • GPS 네비게이션의 "목적지까지의 방향" 계산

핵심 로직: 구면 삼각법 (Spherical Trigonometry)

지구는 평면이 아닌 구(球)이기 때문에 단순한 각도 계산이 아닌 구면 위의 방위각을 계산해야 합니다.

// 1. 위경도를 라디안으로 변환
val lat1Rad = Math.toRadians(lat1)
val lat2Rad = Math.toRadians(lat2)
val deltaLon = Math.toRadians(lon2 - lon1)

// 2. 방위각 벡터 계산
val y = sin(deltaLon) * cos(lat2Rad)
val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(deltaLon)

// 3. atan2로 각도 계산 후 0~360도로 정규화
val bearing = (Math.toDegrees(atan2(y, x)) + 360.0) % 360.0

왜 이렇게 복잡한가?

  • 평면에서는 단순히 atan2(Δy, Δx)로 계산 가능
  • 하지만 지구는 구형이므로 경도선이 극지방으로 갈수록 좁아짐
  • 따라서 위도(latitude)를 고려한 보정이 필요

반환값: 0~360도 (0°=북, 90°=동, 180°=남, 270°=서)

예시:

// 서울 → 부산
calculateBearing(37.5665, 126.9780, 35.1796, 129.0756)
// 반환값: 약 140° (남동쪽)

2. 🔄 calcRelativeAngle() - 상대 각도 계산

private fun calcRelativeAngle(
    bearingFrom: Double,
    bearingTo: Double
): Double

🎯 목적: "내가 A 방향을 보고 있을 때, B 방향으로 가려면 얼마나 돌아야 하는가?"

사용 시나리오:

  • 나는 동쪽(90°)을 보고 있는데, 목표는 남동쪽(135°)에 있다 → 오른쪽으로 45° 회전
  • 나는 북쪽(0°)을 보고 있는데, 목표는 서쪽(270°)에 있다 → 왼쪽으로 -90° 회전

핵심 로직: 각도 차이의 정규화

단순히 bearingTo - bearingFrom을 하면 안 되는 이유:

예: 350° → 10° 로 회전하는 경우
단순 차이: 10° - 350° = -340° (❌ 잘못된 값)
실제 필요: 오른쪽으로 20° 회전

올바른 계산:

var diff = (bearingTo - bearingFrom + 360.0) % 360.0  // 0~360도 범위로 정규화
if (diff > 180.0) {
    diff -= 360.0  // 180도 넘으면 음수로 (반대 방향이 더 짧음)
}
return diff  // -180~+180도 범위

반환값: -180~+180도

  • 양수(+): 시계 방향 회전 (오른쪽으로)
  • 음수(-): 반시계 방향 회전 (왼쪽으로)

예시:

calcRelativeAngle(90.0, 135.0)   // +45° (오른쪽으로 45° 회전)
calcRelativeAngle(0.0, 270.0)    // -90° (왼쪽으로 90° 회전)
calcRelativeAngle(350.0, 10.0)   // +20° (오른쪽으로 20° 회전)

3. 🧭 getRelativeBearing() - 상대 방위각

fun getRelativeBearing(
    myLat: Double,
    myLon: Double,
    myHeading: Double,
    targetLat: Double,
    targetLon: Double
): Double

🎯 목적: "내가 현재 보고 있는 방향을 기준으로, 목표물이 내 몇 시 방향에 있는가?"

사용 시나리오:

  • AR 나침반: "친구가 내 오른쪽 30도 방향에 있습니다"
  • 게임: "적이 10시 방향에서 접근 중"
  • 네비게이션: "목적지가 정면에서 약간 왼쪽"

핵심 로직: 두 함수의 조합

// 1단계: 내 위치에서 목표까지의 절대 방위각 계산
val targetBearing = calculateBearing(myLat, myLon, targetLat, targetLon)

// 2단계: 내가 보는 방향과의 상대 각도 계산
return calcRelativeAngle(myHeading, targetBearing)

시각적 이해:

        0° (정면)
           ↑
           |
-90° ← --- 나 --- → +90°
     (좌)      (우)
           |
           ↓
        ±180° (뒤)

매개변수:

  • myLat, myLon: 내 현재 위치 (GPS 좌표)
  • myHeading: 내가 바라보는 방향 (DeviceOrientationUtil에서 얻음)
  • targetLat, targetLon: 목표의 위치

반환값: -180~+180도

  • 0도: 정면 (12시 방향)
  • +45도: 오른쪽 앞 (1시 방향)
  • +90도: 오른쪽 (3시 방향)
  • +135도: 오른쪽 뒤 (5시 방향)
  • -90도: 왼쪽 (9시 방향)
  • ±180도: 뒤 (6시 방향)

실제 사용 예시:

// 내 위치: 서울 시청 (37.5665, 126.9780)
// 내가 보는 방향: 정북쪽 (0도)
// 목표: 남산타워 (37.5512, 126.9882)

val relative = getRelativeBearing(
    37.5665, 126.9780,  // 내 위치
    0.0,                 // 북쪽을 보고 있음
    37.5512, 126.9882   // 남산타워
)
// 결과: 약 +150도 (오른쪽 뒤쪽, 남동쪽 방향)

4. 🧩 getCardinalDirectionFromRelative() - 사분면 판별

fun getCardinalDirectionFromRelative(relativeAngle: Double): Direction

🎯 목적: "복잡한 각도 값을 사람이 이해하기 쉬운 방향(앞/뒤/좌/우)으로 변환"

사용 시나리오:

  • UI에 "친구가 왼쪽에 있습니다" 표시
  • 음성 안내: "목표물이 오른쪽 방향입니다"
  • 화살표 아이콘 선택 (↑ ↗ → ↘ ↓ ↙ ← ↖)

핵심 로직: 각도 범위별 분류

return when {
    relativeAngle >= -45.0 && relativeAngle <= 45.0   -> Direction.N  //
    relativeAngle > 45.0 && relativeAngle <= 135.0    -> Direction.E  // 오른쪽
    relativeAngle < -45.0 && relativeAngle >= -135.0  -> Direction.W  // 왼쪽
    else                                               -> Direction.S  //
}

시각적 분류:

          N (앞)
       -45° ~ 45°
           |
W ---------|--------- E
(왼쪽)     |      (오른쪽)
-135°~-45° | 45°~135°
           |
          S (뒤)
    나머지 범위

반환값:

각도 범위 방향 설명 시계 방향
-45° ~ 45° N 앞 (정면) 12시
45° ~ 135° E 오른쪽 3시
-135° ~ -45° W 왼쪽 9시
나머지 S 6시

예시:

getCardinalDirectionFromRelative(30.0)    // Direction.N (앞)
getCardinalDirectionFromRelative(80.0)    // Direction.E (오른쪽)
getCardinalDirectionFromRelative(-100.0)  // Direction.W (왼쪽)
getCardinalDirectionFromRelative(170.0)   // Direction.S (뒤)

5. 📱 rectangleSideAndDistance() - 직사각형 경계 계산

fun rectangleSideAndDistance(
    W: Double,
    H: Double,
    bearingDeg: Double
): Triple<Direction, Direction, Double>

🎯 목적: "화면 밖에 있는 참가자를 화면 가장자리에 핀으로 표시할 위치 계산"

사용 시나리오:

  • 지도 앱: 화면 밖 친구의 위치를 가장자리 화살표로 표시
  • 게임: 시야 밖 적의 방향을 UI 경계에 표시
  • AR: 카메라 화면 밖 관심 지점을 가장자리에 표시

문제 정의:

┌─────────────────┐
│                 │  참가자가 북동쪽(45°)에 있다면?
│        나       │  → 화면의 어느 가장자리에 핀을 표시?
│                 │  → 가장자리 중 어느 위치에 정확히?
└─────────────────┘

핵심 로직: 광선 투사법 (Ray Casting)

  1. 방위각을 방향 벡터로 변환
val θ = Math.toRadians(bearingDeg)
val dx = sin(θ)   // 동쪽(+x) 성분
val dy = cos(θ)   // 북쪽(+y) 성분
  1. 네 면과의 교차점 매개변수(t) 계산
// 중심(0,0)에서 (dx, dy) 방향으로 뻗은 직선이
// 각 면과 만나는 지점까지의 거리(t)

val tTop    = if (dy > 0) halfH / dy else// 북쪽 면
val tBottom = if (dy < 0) -halfH / dy else// 남쪽 면
val tRight  = if (dx > 0) halfW / dx else// 동쪽 면
val tLeft   = if (dx < 0) -halfW / dx else// 서쪽 면
  1. 가장 가까운 면 선택
// t가 가장 작은 면이 실제로 만나는 면
val (tMin, side) = minOf(tTop, tBottom, tRight, tLeft)
  1. 교차점 좌표 계산
val ix = tMin * dx  // 교차점의 x 좌표
val iy = tMin * dy  // 교차점의 y 좌표
  1. 세부 방향 판별 (8방위)
// 북쪽 면에 닿았지만, 오른쪽에 가까우면 NE
when (side) {
    Direction.N -> when {
        ix > halfW / 2  -> Direction.NE  // 북동
        ix < -halfW / 2 -> Direction.NW  // 북서
        else            -> Direction.N   // 정북
    }
    // ... 다른 방향들도 동일
}

매개변수:

  • W: 화면/지도의 가로 길이 (픽셀 또는 미터)
  • H: 화면/지도의 세로 길이
  • bearingDeg: 참가자가 있는 방향 (진북 기준)

반환값: Triple(핀 방향, 기본 방향, 거리)

구성 요소 타입 설명 용도
첫 번째 Direction 8방향 (N/NE/E/SE/S/SW/W/NW) 핀 아이콘 배치 위치
두 번째 Direction 4방향 (N/E/S/W) 어느 면에 닿았는지
세 번째 Double 경계면 중점까지의 수직 거리 가장자리 내 정확한 위치

시각적 예시:

화면 크기: 1080×1920
참가자 방향: 45° (북동쪽)

┌─────────────────┐
│        45°      │ ← 이 방향으로 직선을 그으면
│       ╱         │    오른쪽 면과 만남
│      ╱          │
│     ●           │ ← 교차점 (핀 위치)
│    나           │
│                 │
└─────────────────┘

결과:
- finalSide = Direction.NE (북동쪽 표시)
- side = Direction.E (동쪽 면)
- distance = 교차점의 y 좌표 (면 중점으로부터의 거리)

실제 사용 예시:

// 스마트폰 화면 크기
val screenWidth = 1080.0
val screenHeight = 1920.0

// 참가자가 북동쪽(45도)에 있음
val (pinDir, baseDir, dist) = rectangleSideAndDistance(
    screenWidth, screenHeight, 45.0
)

// 결과:
// pinDir = Direction.NE    → 북동쪽 화살표 아이콘 사용
// baseDir = Direction.E     → 오른쪽 면에 배치
// dist = 450.0              → 화면 오른쪽 끝에서 위로 450px 위치

핀 배치 좌표 계산 예시:

val (pinDir, baseDir, distance) = rectangleSideAndDistance(W, H, bearing)

val pinPosition = when (baseDir) {
    Direction.N -> Offset(distance, 0f)              // 상단
    Direction.E -> Offset(W.toFloat(), -distance)    // 우측
    Direction.S -> Offset(distance, H.toFloat())     // 하단
    Direction.W -> Offset(0f, -distance)             // 좌측
}

사용 예시

1. 기기 방향 감지

@Composable
fun CompassScreen() {
    val deviceOrientation = DeviceOrientationUtil.rememberDeviceOrientation()

    Text("현재 방향: ${deviceOrientation.value.toInt()}°")

    // deviceOrientation.value는 자동으로 업데이트됨
    // 라이프사이클에 따라 센서 자동 등록/해제
}

2. 목표까지의 상대 방향 계산

// 내 위치: 서울
val myLat = 37.5665
val myLon = 126.9780

// 내가 바라보는 방향: 동쪽 (90도)
val myHeading = 90.0

// 목표 위치: 부산
val targetLat = 35.1796
val targetLon = 129.0756

// 상대 방위각 계산
val relativeBearing = MapParticipantUtil.getRelativeBearing(
    myLat, myLon, myHeading,
    targetLat, targetLon
)

// 예: relativeBearing = 45.0 (오른쪽 앞쪽 방향)
val direction = MapParticipantUtil.getCardinalDirectionFromRelative(relativeBearing)
// direction = Direction.E (오른쪽)

3. 지도 화면에서 핀 위치 계산

// 화면 크기
val screenWidth = 1080.0
val screenHeight = 1920.0

// 참가자가 있는 방향 (진북 기준)
val participantBearing = 135.0  // 남동쪽

// 경계면과 교차점 계산
val (pinDirection, baseDirection, distance) =
    MapParticipantUtil.rectangleSideAndDistance(
        screenWidth,
        screenHeight,
        participantBearing
    )

// pinDirection = Direction.SE (남동쪽)
// baseDirection = Direction.E (동쪽 면)
// distance = 화면 중심에서 경계까지의 거리

// 이 정보로 화면 가장자리에 핀을 배치

4. 실시간 방향 추적

@Composable
fun ParticipantTracker(
    myLocation: LatLng,
    targetLocation: LatLng
) {
    val deviceOrientation = DeviceOrientationUtil.rememberDeviceOrientation()

    // 내 방향을 기준으로 목표의 상대 위치 계산
    val relativeBearing = MapParticipantUtil.getRelativeBearing(
        myLat = myLocation.latitude,
        myLon = myLocation.longitude,
        myHeading = deviceOrientation.value,  // 실시간 업데이트
        targetLat = targetLocation.latitude,
        targetLon = targetLocation.longitude
    )

    val direction = MapParticipantUtil.getCardinalDirectionFromRelative(relativeBearing)

    when (direction) {
        Direction.N -> Text("목표가 앞쪽에 있습니다")
        Direction.E -> Text("목표가 오른쪽에 있습니다")
        Direction.W -> Text("목표가 왼쪽에 있습니다")
        Direction.S -> Text("목표가 뒤쪽에 있습니다")
    }
}

성능 최적화

DeviceOrientationUtil

  • 센서 데이터 평활화: 노이즈 제거로 안정적인 값 제공
  • 변화량 임계값: 불필요한 리컴포지션 방지
  • 라이프사이클 인식: 백그라운드에서 센서 자동 해제

MapParticipantUtil

  • Pure 함수: 부작용 없는 계산
  • 객체 생성 최소화: 기본 타입 사용
  • 효율적인 알고리즘: 삼각함수 최적화

좌표계 및 각도 체계

방위각 (Azimuth/Bearing)

      0° (북)
        |
270° ---|--- 90° (동)
        |
     180° (남)

상대 각도 (Relative Angle)

     0° (정면)
        |
-90° ---|--- +90°
   (좌)  |   (우)
      ±180° (후)

화면 좌표계

     +Y (북)
      |
-X ---|--- +X (동)
(서)  |
     -Y (남)

Clone this wiki locally