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:
@@ -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);
|
||||
}
|
||||
|
||||
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
|
||||
|
||||
Reference in New Issue
Block a user