Files
Aislo/ui_template/ui_template_compass.ts
T
eomsangdonandClaude Opus 5 3d037d869e feat(B05): 종단 3D 뷰어에 방위 나침반 추가 + 나침반 위젯 공용 이관
B05 3D 뷰어에는 방위 표시가 없어 방향을 읽을 수 없었음(사용자 지적, 메인 창 경유).
B04 가 오늘 새로 만든 「지면에 누운 나침반 링」을 그대로 쓰되, 두 페이지가 함께 쓰도록
공용으로 옮김.

- `B04_PreProcess_UI_Compass.ts` → `ui_template/ui_template_compass.ts` (git mv).
  `createTerrainCompass({ sizePx, className })` 로 크기·자리를 호출부가 정함.
- 스타일도 `ui_template/ui_template_compass.css` 로 옮기고 모듈이 직접 물고 옴.
  **놓을 자리만** 페이지 CSS 몫 — B04 우하단(`b04-surface__compass`),
  B05 는 ISO 버튼 아래(`b05-route__compass`).
- B05 뷰어는 104px 로 쓰고 렌더 루프에서 카메라 오프셋으로 갱신, 지형이 없으면 감춤.
  좌표 규약이 B04 와 같음(북 −z · 동 +x · 표고 +y, `Viewer.ts:295` 정점 변환) — 그대로 맞음.

화면 검증(5174): B05 나침반 104×104, ISO 버튼 아래 8px. 도북 화살이 뷰 전환에 반응 —
TOP 에서 `M0.00 -33.30`(수직), ISO 에서 `M23.56 -13.60`. 후자는 카메라 오프셋 (d,d,d)
에 대한 투영 이론값(x=R/√2=23.56, y=−R/√6=−13.60)과 일치. B04 쪽도 그대로(84×84,
우하단 16px, 링·화살 색 토큰 유지). `tsc --noEmit` 통과, 뷰어 693줄로 제한 안.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 20:18:51 +09:00

219 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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<string, string>): 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;
},
};
}