-
Notifications
You must be signed in to change notification settings - Fork 0
MapParticipationUtil
조익성 edited this page Nov 14, 2025
·
3 revisions
위치 기반 방향 계산을 위한 유틸리티 클래스입니다. 지도상에서 참가자들의 상대적 위치와 방향을 계산합니다.
MapParticipant1.mp4
MapParticipant2.mp4
내 위치 + 목표 위치
↓
calculateBearing() ← 두 좌표 사이의 진북 기준 방위각
↓
calcRelativeAngle() ← 내가 보는 방향과의 차이 계산
↓
getRelativeBearing() ← "내 기준" 상대 방위각 (-180~180°)
↓
getCardinalDirectionFromRelative() ← 사분면 판별 (N/E/S/W)
↓
rectangleSideAndDistance() ← 화면 가장자리 핀 배치 위치
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° (남동쪽)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° 회전)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도 (오른쪽 뒤쪽, 남동쪽 방향)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 (뒤)fun rectangleSideAndDistance(
W: Double,
H: Double,
bearingDeg: Double
): Triple<Direction, Direction, Double>🎯 목적: "화면 밖에 있는 참가자를 화면 가장자리에 핀으로 표시할 위치 계산"
사용 시나리오:
- 지도 앱: 화면 밖 친구의 위치를 가장자리 화살표로 표시
- 게임: 시야 밖 적의 방향을 UI 경계에 표시
- AR: 카메라 화면 밖 관심 지점을 가장자리에 표시
문제 정의:
┌─────────────────┐
│ │ 참가자가 북동쪽(45°)에 있다면?
│ 나 │ → 화면의 어느 가장자리에 핀을 표시?
│ │ → 가장자리 중 어느 위치에 정확히?
└─────────────────┘
핵심 로직: 광선 투사법 (Ray Casting)
- 방위각을 방향 벡터로 변환
val θ = Math.toRadians(bearingDeg)
val dx = sin(θ) // 동쪽(+x) 성분
val dy = cos(θ) // 북쪽(+y) 성분- 네 면과의 교차점 매개변수(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 ∞ // 서쪽 면- 가장 가까운 면 선택
// t가 가장 작은 면이 실제로 만나는 면
val (tMin, side) = minOf(tTop, tBottom, tRight, tLeft)- 교차점 좌표 계산
val ix = tMin * dx // 교차점의 x 좌표
val iy = tMin * dy // 교차점의 y 좌표- 세부 방향 판별 (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) // 좌측
}@Composable
fun CompassScreen() {
val deviceOrientation = DeviceOrientationUtil.rememberDeviceOrientation()
Text("현재 방향: ${deviceOrientation.value.toInt()}°")
// deviceOrientation.value는 자동으로 업데이트됨
// 라이프사이클에 따라 센서 자동 등록/해제
}// 내 위치: 서울
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 (오른쪽)// 화면 크기
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 = 화면 중심에서 경계까지의 거리
// 이 정보로 화면 가장자리에 핀을 배치@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("목표가 뒤쪽에 있습니다")
}
}- 센서 데이터 평활화: 노이즈 제거로 안정적인 값 제공
- 변화량 임계값: 불필요한 리컴포지션 방지
- 라이프사이클 인식: 백그라운드에서 센서 자동 해제
- Pure 함수: 부작용 없는 계산
- 객체 생성 최소화: 기본 타입 사용
- 효율적인 알고리즘: 삼각함수 최적화
0° (북)
|
270° ---|--- 90° (동)
|
180° (남)
0° (정면)
|
-90° ---|--- +90°
(좌) | (우)
±180° (후)
+Y (북)
|
-X ---|--- +X (동)
(서) |
-Y (남)