feat(B04): 지표면 3D 뷰어에 방위 콤파스 추가

뷰어 좌표 규약이 `x, 높이, -y` 라 세계 북쪽은 -z 다. 카메라 오프셋(카메라 − 타깃)의
수평 성분으로 도북이 화면에서 몇 도 돌아갔는지 계산해(atan2(offset.x, offset.z)) 바늘을
돌린다. 뷰어 우하단 — 축척 막대(좌하단)의 반대편에 둔다.

뷰어 파일이 이미 946줄이라 콤파스는 별도 모듈로 분리(700줄 제한). 애니메이션 루프에서
0.5° 이상 움직였을 때만 transform 을 다시 쓴다.

검증: typecheck 통과. 공용 브라우저 실측 — [상단] 시점 0°(바늘이 북쪽=화면 위),
[사시도] 41.6°, 캔버스 220px 드래그 시 218.6° 로 카메라와 함께 회전.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 10:02:15 +09:00
co-authored by Claude Opus 5
parent 6bca521516
commit 16a24ace3d
3 changed files with 98 additions and 0 deletions
@@ -0,0 +1,52 @@
/* B04 지표면 3D 뷰어 방위 콤파스 — 바늘이 늘 도북(N)을 가리킨다.
*
* 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x, 높이, -y. 그래서 세계에서 북쪽은
* **-z** 다. 화면 위쪽은 카메라가 보는 수평 방향이므로, 카메라 오프셋(카메라 − 타깃)만
* 알면 북쪽이 화면에서 몇 도 돌아가 있는지 나온다 — `Math.atan2(offset.x, offset.z)`.
* (기본 시점 offset=(0, d, d·tilt)면 0° = 북쪽이 화면 위, 카메라가 동쪽으로 가면 +90°.)
*
* 뷰어 파일이 이미 900줄을 넘어 여기로 뺐다(CLAUDE.md 4장 700줄 제한).
*/
export interface TerrainCompass {
root: HTMLElement;
/** 카메라 오프셋(카메라 위치 − 타깃)으로 바늘 각도를 맞춘다. */
update(offsetX: number, offsetZ: number): void;
setVisible(visible: boolean): void;
}
const NEEDLE_SVG = `
<svg viewBox="0 0 44 44" width="44" height="44" aria-hidden="true">
<circle cx="22" cy="22" r="20" class="b04-surface__compass-ring" />
<polygon points="22,5 27,22 22,19 17,22" class="b04-surface__compass-north" />
<polygon points="22,39 17,22 22,25 27,22" class="b04-surface__compass-south" />
<text x="22" y="14" class="b04-surface__compass-letter">N</text>
</svg>`;
export function createTerrainCompass(): TerrainCompass {
const root = document.createElement("div");
root.className = "b04-surface__compass";
root.hidden = true;
root.title = "도북(N)";
const dial = document.createElement("div");
dial.className = "b04-surface__compass-dial";
dial.innerHTML = NEEDLE_SVG;
root.append(dial);
// 프레임마다 style을 다시 쓰지 않도록 직전 각도를 들고 있는다.
let lastHeading = Number.NaN;
return {
root,
update(offsetX: number, offsetZ: number): void {
const heading = (Math.atan2(offsetX, offsetZ) * 180) / Math.PI;
if (Number.isFinite(lastHeading) && Math.abs(heading - lastHeading) < 0.5) return;
lastHeading = heading;
dial.style.transform = `rotate(${heading.toFixed(1)}deg)`;
},
setVisible(visible: boolean): void {
root.hidden = !visible;
},
};
}
@@ -557,6 +557,44 @@
text-shadow: 0 1px 2px var(--color-surface-raised);
}
/* 방위 콤파스 — 3D 뷰어 우하단. 축척 막대(좌하단)와 짝. */
.b04-surface__compass {
position: absolute;
right: var(--spacing-16);
bottom: var(--spacing-16);
z-index: 2;
pointer-events: none;
filter: drop-shadow(0 1px 2px var(--color-surface-raised));
}
.b04-surface__compass-dial {
transform-origin: 50% 50%;
transition: transform 80ms linear;
}
.b04-surface__compass-ring {
fill: var(--color-surface-raised);
fill-opacity: 0.75;
stroke: var(--color-text-secondary);
stroke-width: 1;
}
.b04-surface__compass-north {
fill: var(--color-danger, #d64545);
}
.b04-surface__compass-south {
fill: var(--color-text-secondary);
}
.b04-surface__compass-letter {
fill: var(--color-text-body);
font-family: var(--font-body);
font-size: 9px;
font-weight: var(--font-weight-semibold);
text-anchor: middle;
}
/* --- 하단 2D 지도 --- */
.b04-map {
--b04-map-vector: var(--color-accent);
@@ -7,6 +7,7 @@ import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
// 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다.
import { createTerrainCompass } from "./B04_PreProcess_UI_Compass";
import { routeLineColor } from "./B04_PreProcess_UI_MapRender";
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
import {
@@ -166,6 +167,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
scaleBar.append(scaleLabel);
viewerArea.append(scaleBar);
// 방위 콤파스 — 축척 막대 반대편(우하단). 바늘 각도는 애니메이션 루프가 맞춘다.
const compass = createTerrainCompass();
viewerArea.append(compass.root);
// Elevation bounds legend bar overlay (I-403)
const legendBar = document.createElement("div");
legendBar.style.position = "absolute";
@@ -794,6 +799,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// Render scale bar dynamically
if (terrainMesh && terrainMesh.visible) {
scaleBar.hidden = false;
compass.setVisible(true);
compass.update(camera.position.x - controls.target.x, camera.position.z - controls.target.z);
const dist = camera.position.distanceTo(controls.target);
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
const roughMeters = 100 * metersPerPixel;
@@ -803,6 +810,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
} else {
scaleBar.hidden = true;
compass.setVisible(false);
}
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).