/* 3D 뷰어 방위 표시 — **지면에 누운 나침반 링**(2026-09-03 사용자 선택). * * B04 지표면 뷰어 전용이었으나 B05 종단 3D 뷰어도 같은 표시가 필요해져 공용으로 옮겼다 * (2026-09-03). 스타일은 이 모듈이 직접 물고 오고(`ui_template_compass.css`), **놓을 자리만** * 페이지가 클래스로 준다 — B04 는 우하단(축척 막대와 짝), B05 는 ISO 버튼 아래다. * * 종전에는 방위각만으로 도는 평면 콤파스였다. 3D 화면은 기울어 있어 남북이 화면에서 * 눌리는데(사시도 앙각 27.7° → 0.47배) 바늘은 안 눌린 각도로 돌아, 실제 화면 북쪽과 * 최대 20.8° 어긋났다(2026-09-03 사용자 지적·실측). 링·눈금·도북 화살을 **지면 평면 * 위의 도형으로 두고 카메라에 투영**하면 그 어긋남이 원리적으로 사라지고, 링이 눌린 * 정도가 곧 시점의 기울기가 된다(TerriaJS·cesium-navigation 계열 표기). * * 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x(동), 높이, -y. 그래서 세계에서 * 북쪽은 **-z**, 동쪽은 **+x**, 표고는 **+y** 다. 카메라 오프셋(카메라 − 타깃)만 있으면 * 화면 기저를 만들 수 있다(up = +y 고정): * · 오른쪽 r = (oz, 0, −ox)/h, 위 u = (−ox·oy, h², −oz·oy)/(h·L) * · 화면 좌표 = (v·r, v·u), 깊이 = v·(−offset)/L — 양수면 시선 방향(뒤)이라 흐리게. * * 뷰어 파일이 이미 900줄을 넘어 여기로 뺐다(CLAUDE.md 4장 700줄 제한). */ import "./ui_template_compass.css"; export interface TerrainCompass { root: HTMLElement; /** 카메라 오프셋(카메라 위치 − 타깃)으로 링·화살·표고침을 맞춘다. */ update(offsetX: number, offsetY: number, offsetZ: number): void; setVisible(visible: boolean): void; } /** 기본 크기(px). 호출부가 `sizePx` 로 덮을 수 있다 — B05 는 조금 크게 쓴다. */ const DEFAULT_SIZE_PX = 84; const VIEW_BOX = 104; const CENTER = 0; const RING_RADIUS = 34; /** 눈금 간격(도)과 90°마다 주는 긴 눈금. */ const TICK_STEP_DEG = 15; const TICK_INNER = 0.93; const TICK_INNER_MAJOR = 0.86; const LABEL_RADIUS = RING_RADIUS * 1.3; /** 링을 그리는 다각형 분할(도) — 6°면 84px에서 원으로 보인다. */ const RING_STEP_DEG = 6; /** 이보다 뒤로 넘어간 글자는 흐리게 — 0 근처에서 깜빡이지 않게 여유를 둔다. */ const BEHIND_DEPTH = 0.3; const DEG = Math.PI / 180; /** 방위(도, 북=0, 시계 방향) 위치의 지면 벡터. */ function bearingVector(bearingDeg: number, radius: number): [number, number, number] { const radians = bearingDeg * DEG; return [Math.sin(radians) * radius, 0, -Math.cos(radians) * radius]; } const CARDINALS: Array<[string, number]> = [ ["N", 0], ["E", 90], ["S", 180], ["W", 270], ]; function svg(tag: string, attributes: Record): SVGElement { const element = document.createElementNS("http://www.w3.org/2000/svg", tag); for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value); return element; } export interface TerrainCompassOptions { /** 위젯 한 변의 크기(px). 기본 84. */ sizePx?: number; /** 놓을 자리를 정하는 페이지 클래스 — 위치 지정은 페이지 CSS 몫이다. */ className?: string; } export function createTerrainCompass(options: TerrainCompassOptions = {}): TerrainCompass { const root = document.createElement("div"); root.className = options.className ? `ui-compass ${options.className}` : "ui-compass"; root.hidden = true; root.title = "도북(N) — 링이 지면에 누워 시점 기울기를 함께 보여 준다"; const half = VIEW_BOX / 2; const canvas = svg("svg", { viewBox: `${-half} ${-half} ${VIEW_BOX} ${VIEW_BOX}`, width: String(options.sizePx ?? DEFAULT_SIZE_PX), height: String(options.sizePx ?? DEFAULT_SIZE_PX), "aria-hidden": "true", }); const face = svg("path", { class: "ui-compass-face", d: "" }); const ring = svg("path", { class: "ui-compass-ring", d: "" }); const ticks: SVGElement[] = []; for (let bearing = 0; bearing < 360; bearing += TICK_STEP_DEG) { const tick = svg("line", { class: `ui-compass-tick${bearing % 90 === 0 ? " ui-compass-tick--major" : ""}`, x1: "0", y1: "0", x2: "0", y2: "0", }); ticks.push(tick); } const arrow = svg("path", { class: "ui-compass-arrow", d: "" }); const pole = svg("line", { class: "ui-compass-pole", x1: "0", y1: "0", x2: "0", y2: "0", }); const labels = CARDINALS.map(([name]) => svg("text", { class: `ui-compass-label${name === "N" ? " ui-compass-label--north" : ""}`, x: "0", y: "0", }), ); labels.forEach((label, index) => { label.textContent = CARDINALS[index][0]; }); const poleLabel = svg("text", { class: "ui-compass-label", x: "0", y: "0" }); poleLabel.textContent = "Z"; canvas.append(face, ring, ...ticks, arrow, pole, ...labels, poleLabel); root.append(canvas); // 프레임마다 다시 그리지 않도록 직전 시선 방향(단위 벡터)을 들고 있는다. let last = { x: Number.NaN, y: 0, z: 0 }; return { root, update(offsetX: number, offsetY: number, offsetZ: number): void { const length = Math.hypot(offsetX, offsetY, offsetZ); if (!(length > 0)) return; const unit = { x: offsetX / length, y: offsetY / length, z: offsetZ / length }; const moved = !Number.isFinite(last.x) || Math.abs(unit.x - last.x) + Math.abs(unit.y - last.y) + Math.abs(unit.z - last.z) > 0.008; if (!moved) return; last = unit; // 수평 성분. 정확히 수직으로 내려다보면 0이 되므로 하한을 둔다(시점 프리셋도 2° 기울임). const horizontal = Math.max(Math.hypot(offsetX, offsetZ), 1e-6); /** 세계 벡터 → 화면 좌표(SVG는 y가 아래로 자라 위 성분을 뒤집는다)와 깊이. */ const project = ( vector: [number, number, number], ): { x: number; y: number; depth: number } => { const [vx, vy, vz] = vector; const alongCamera = vx * offsetX + vz * offsetZ; // 깊이는 방향만 보므로 벡터 길이로 정규화한다(1 = 시선 정방향, −1 = 화면 앞). const size = Math.max(Math.hypot(vx, vy, vz), 1e-6); return { x: CENTER + (vx * offsetZ - vz * offsetX) / horizontal, y: CENTER - (vy * horizontal * horizontal - alongCamera * offsetY) / (horizontal * length), depth: -(vx * unit.x + vy * unit.y + vz * unit.z) / size, }; }; /** 지면 원(반지름 r)을 다각형으로 — 시점이 누우면 그대로 눌린 타원이 된다. */ const groundCircle = (radius: number): string => { let path = ""; for (let bearing = 0; bearing <= 360; bearing += RING_STEP_DEG) { const point = project(bearingVector(bearing, radius)); path += `${path ? "L" : "M"}${point.x.toFixed(2)} ${point.y.toFixed(2)}`; } return `${path}Z`; }; ring.setAttribute("d", groundCircle(RING_RADIUS)); face.setAttribute("d", groundCircle(RING_RADIUS * 0.97)); ticks.forEach((tick, index) => { const bearing = index * TICK_STEP_DEG; const inner = project( bearingVector( bearing, RING_RADIUS * (bearing % 90 === 0 ? TICK_INNER_MAJOR : TICK_INNER), ), ); const outer = project(bearingVector(bearing, RING_RADIUS)); tick.setAttribute("x1", inner.x.toFixed(2)); tick.setAttribute("y1", inner.y.toFixed(2)); tick.setAttribute("x2", outer.x.toFixed(2)); tick.setAttribute("y2", outer.y.toFixed(2)); }); // 도북 화살도 지면에 누운 삼각형이라 시점이 눕는 만큼 함께 눌린다. const tip = project(bearingVector(0, RING_RADIUS * 0.98)); const left = project(bearingVector(-14, RING_RADIUS * 0.52)); const right = project(bearingVector(14, RING_RADIUS * 0.52)); arrow.setAttribute( "d", `M${tip.x.toFixed(2)} ${tip.y.toFixed(2)}L${left.x.toFixed(2)} ${left.y.toFixed(2)}` + `L${right.x.toFixed(2)} ${right.y.toFixed(2)}Z`, ); // 표고축 — 링 가운데에서 곧게 선 침. 내려다볼수록 짧아져 시점을 한 번 더 알린다. const up = project([0, RING_RADIUS * 0.9, 0]); pole.setAttribute("x1", String(CENTER)); pole.setAttribute("y1", String(CENTER)); pole.setAttribute("x2", up.x.toFixed(2)); pole.setAttribute("y2", up.y.toFixed(2)); labels.forEach((label, index) => { const point = project(bearingVector(CARDINALS[index][1], LABEL_RADIUS)); label.setAttribute("x", point.x.toFixed(2)); // 글자는 baseline이 아래라 시각 중심을 맞추려면 조금 내린다. label.setAttribute("y", (point.y + 3.5).toFixed(2)); label.setAttribute("opacity", point.depth > BEHIND_DEPTH ? "0.5" : "1"); }); const poleTip = project([0, LABEL_RADIUS * 0.78, 0]); poleLabel.setAttribute("x", poleTip.x.toFixed(2)); poleLabel.setAttribute("y", (poleTip.y + 3.5).toFixed(2)); // 위에서 내려다보면 표고축이 점으로 눌려 링 가운데 글자만 남는다 — 그때는 감춘다. poleLabel.setAttribute("opacity", Math.abs(poleTip.y - CENTER) < 8 ? "0" : "0.8"); }, setVisible(visible: boolean): void { root.hidden = !visible; }, }; }