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>
This commit is contained in:
2026-09-03 20:18:51 +09:00
co-authored by Claude Opus 5
parent 6313d2cf58
commit 3d037d869e
6 changed files with 126 additions and 75 deletions
+2 -59
View File
@@ -557,68 +557,11 @@
text-shadow: 0 1px 2px var(--color-surface-raised);
}
/* 방위 콤파스 — 3D 뷰어 우하단. 축척 막대(좌하단)와 짝. */
/* 방위 콤파스를 놓을 자리 — 3D 뷰어 우하단. 축척 막대(좌하단)와 짝.
위젯 모양·색은 공용(`ui_template/ui_template_compass.css`)이고 여기서는 위치만 준다. */
.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));
}
/* inline SVG의 baseline 여백을 없앤다 — 안 그러면 아래로 5px 떠 모서리 여백이 어긋난다. */
.b04-surface__compass svg {
display: block;
}
/* 지면에 누운 나침반 링 — 좌표는 카메라 자세대로 매 프레임 다시 쓰인다.
링 안쪽 면은 지형이 비치도록 아주 옅게만 깔아 도면 위 방위표처럼 읽힌다. */
.b04-surface__compass-face {
fill: var(--color-surface-raised);
fill-opacity: 0.5;
stroke: none;
}
.b04-surface__compass-ring {
fill: none;
stroke: var(--color-text-secondary);
stroke-width: 1.4;
}
.b04-surface__compass-tick {
stroke: var(--color-border);
stroke-width: 1;
}
.b04-surface__compass-tick--major {
stroke: var(--color-text-secondary);
stroke-width: 1.6;
}
/* 도북 화살 — 이 위젯에서 가장 먼저 읽혀야 하는 하나. */
.b04-surface__compass-arrow {
fill: var(--color-danger, #d64545);
}
/* 표고축 — 내려다볼수록 짧아져 시점 기울기를 한 번 더 알린다. */
.b04-surface__compass-pole {
stroke: var(--color-text-body);
stroke-width: 1.6;
stroke-linecap: round;
opacity: 0.75;
}
.b04-surface__compass-label {
fill: var(--color-text-secondary);
font-family: var(--font-body);
font-size: 11px;
font-weight: var(--font-weight-semibold);
text-anchor: middle;
}
.b04-surface__compass-label--north {
fill: var(--color-danger, #d64545);
}
/* --- 하단 2D 지도 --- */
@@ -7,7 +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 { createTerrainCompass } from "@ui/ui_template_compass";
import { routeLineColor } from "./B04_PreProcess_UI_MapRender";
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
import {
@@ -168,7 +168,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
viewerArea.append(scaleBar);
// 방위 콤파스 — 축척 막대 반대편(우하단). 바늘 각도는 애니메이션 루프가 맞춘다.
const compass = createTerrainCompass();
const compass = createTerrainCompass({ className: "b04-surface__compass" });
viewerArea.append(compass.root);
// Elevation bounds legend bar overlay (I-403)
+8
View File
@@ -192,6 +192,14 @@
gap: var(--spacing-8);
}
/* 방위 나침반을 놓을 자리 — ISO 버튼 바로 아래(2026-09-03 사용자 지시).
버튼 줄 높이 34px + 위 여백 16px 아래에 붙인다. 위젯 모양·색은 공용
(`ui_template/ui_template_compass.css`)이고 여기서는 위치만 준다. */
.b05-route__compass {
top: calc(var(--spacing-16) + 34px + var(--spacing-8));
left: var(--spacing-16);
}
.b05-route__view-group {
display: flex;
gap: var(--spacing-4);
+16 -1
View File
@@ -1,5 +1,6 @@
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { createTerrainCompass } from "@ui/ui_template_compass";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
import { API_BASE_URL } from "@config/config_frontend";
@@ -125,7 +126,10 @@ export function createRouteViewer(): RouteViewer {
status.className = "b05-route__viewer-status";
status.textContent = "확정 지표면을 불러오는 중입니다.";
const canvas = document.createElement("canvas");
root.append(canvas, status);
// 방위 나침반 — ISO 버튼 아래(2026-09-03 사용자 지시). 지면에 누운 링이라 사시도에서도
// 화면 북쪽과 어긋나지 않는다. 위젯은 B04 지표면 뷰어와 **같은 공용 모듈**을 쓴다.
const compass = createTerrainCompass({ sizePx: 104, className: "b05-route__compass" });
root.append(canvas, status, compass.root);
const scene = new THREE.Scene();
const systemDarkTheme = window.matchMedia("(prefers-color-scheme: dark)");
@@ -320,6 +324,17 @@ export function createRouteViewer(): RouteViewer {
function animate(): void {
frame = requestAnimationFrame(animate);
controls.update();
// 나침반은 시선 방향이 실제로 바뀔 때만 다시 그린다(모듈 안에서 걸러낸다).
if (terrain) {
compass.setVisible(true);
compass.update(
camera.position.x - controls.target.x,
camera.position.y - controls.target.y,
camera.position.z - controls.target.z,
);
} else {
compass.setVisible(false);
}
renderer.render(scene, camera);
}
animate();
+71
View File
@@ -0,0 +1,71 @@
/* =============================================================================
* ui_template_compass.css
* 3D 뷰어 방위 나침반(`ui_template_compass.ts`) 전용 스타일.
*
* B04 지표면 뷰어에만 있던 것을 B05 종단 3D 뷰어와 함께 쓰려고 공용으로 옮겼다
* (2026-09-03). 색상 하드코딩 금지 원칙대로 전부 토큰을 쓴다.
* ========================================================================== */
/* 3D 뷰어 방위 나침반 모양·색만 여기서 정한다.
**놓을 자리는 페이지 CSS **이다(B04 우하단, B05 ISO 버튼 아래). 위치를 여기 박으면
페이지마다 덮어써야 해서 규칙이 엉킨다. */
.ui-compass {
position: absolute;
z-index: 2;
pointer-events: none;
filter: drop-shadow(0 1px 2px var(--color-surface-raised));
}
/* inline SVG의 baseline 여백을 없앤다 — 안 그러면 아래로 5px 떠 모서리 여백이 어긋난다. */
.ui-compass svg {
display: block;
}
/* 지면에 누운 나침반 좌표는 카메라 자세대로 프레임 다시 쓰인다.
안쪽 면은 지형이 비치도록 아주 옅게만 깔아 도면 방위표처럼 읽힌다. */
.ui-compass-face {
fill: var(--color-surface-raised);
fill-opacity: 0.5;
stroke: none;
}
.ui-compass-ring {
fill: none;
stroke: var(--color-text-secondary);
stroke-width: 1.4;
}
.ui-compass-tick {
stroke: var(--color-border);
stroke-width: 1;
}
.ui-compass-tick--major {
stroke: var(--color-text-secondary);
stroke-width: 1.6;
}
/* 도북 화살 — 이 위젯에서 가장 먼저 읽혀야 하는 하나. */
.ui-compass-arrow {
fill: var(--color-danger, #d64545);
}
/* 표고축 — 내려다볼수록 짧아져 시점 기울기를 한 번 더 알린다. */
.ui-compass-pole {
stroke: var(--color-text-body);
stroke-width: 1.6;
stroke-linecap: round;
opacity: 0.75;
}
.ui-compass-label {
fill: var(--color-text-secondary);
font-family: var(--font-body);
font-size: 11px;
font-weight: var(--font-weight-semibold);
text-anchor: middle;
}
.ui-compass-label--north {
fill: var(--color-danger, #d64545);
}
@@ -1,4 +1,8 @@
/* B04 3D ** **(2026-09-03 ).
/* 3D ** **(2026-09-03 ).
*
* B04 B05 3D
* (2026-09-03). (`ui_template_compass.css`), ** **
* B04 ( ), B05 ISO .
*
* . 3D
* ( 27.7° 0.47) ,
@@ -15,6 +19,8 @@
* 900 (CLAUDE.md 4 700 ).
*/
import "./ui_template_compass.css";
export interface TerrainCompass {
root: HTMLElement;
/** 카메라 오프셋(카메라 위치 − 타깃)으로 링·화살·표고침을 맞춘다. */
@@ -22,7 +28,8 @@ export interface TerrainCompass {
setVisible(visible: boolean): void;
}
const SIZE_PX = 84;
/** 기본 크기(px). 호출부가 `sizePx` 로 덮을 수 있다 — B05 는 조금 크게 쓴다. */
const DEFAULT_SIZE_PX = 84;
const VIEW_BOX = 104;
const CENTER = 0;
const RING_RADIUS = 34;
@@ -56,25 +63,32 @@ function svg(tag: string, attributes: Record<string, string>): SVGElement {
return element;
}
export function createTerrainCompass(): TerrainCompass {
export interface TerrainCompassOptions {
/** 위젯 한 변의 크기(px). 기본 84. */
sizePx?: number;
/** 놓을 자리를 정하는 페이지 클래스 — 위치 지정은 페이지 CSS 몫이다. */
className?: string;
}
export function createTerrainCompass(options: TerrainCompassOptions = {}): TerrainCompass {
const root = document.createElement("div");
root.className = "b04-surface__compass";
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(SIZE_PX),
height: String(SIZE_PX),
width: String(options.sizePx ?? DEFAULT_SIZE_PX),
height: String(options.sizePx ?? DEFAULT_SIZE_PX),
"aria-hidden": "true",
});
const face = svg("path", { class: "b04-surface__compass-face", d: "" });
const ring = svg("path", { class: "b04-surface__compass-ring", d: "" });
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: `b04-surface__compass-tick${bearing % 90 === 0 ? " b04-surface__compass-tick--major" : ""}`,
class: `ui-compass-tick${bearing % 90 === 0 ? " ui-compass-tick--major" : ""}`,
x1: "0",
y1: "0",
x2: "0",
@@ -82,9 +96,9 @@ export function createTerrainCompass(): TerrainCompass {
});
ticks.push(tick);
}
const arrow = svg("path", { class: "b04-surface__compass-arrow", d: "" });
const arrow = svg("path", { class: "ui-compass-arrow", d: "" });
const pole = svg("line", {
class: "b04-surface__compass-pole",
class: "ui-compass-pole",
x1: "0",
y1: "0",
x2: "0",
@@ -92,7 +106,7 @@ export function createTerrainCompass(): TerrainCompass {
});
const labels = CARDINALS.map(([name]) =>
svg("text", {
class: `b04-surface__compass-label${name === "N" ? " b04-surface__compass-label--north" : ""}`,
class: `ui-compass-label${name === "N" ? " ui-compass-label--north" : ""}`,
x: "0",
y: "0",
}),
@@ -100,7 +114,7 @@ export function createTerrainCompass(): TerrainCompass {
labels.forEach((label, index) => {
label.textContent = CARDINALS[index][0];
});
const poleLabel = svg("text", { class: "b04-surface__compass-label", x: "0", y: "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);