뷰어 좌표 규약이 `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>
53 lines
2.1 KiB
TypeScript
53 lines
2.1 KiB
TypeScript
/* 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;
|
|
},
|
|
};
|
|
}
|