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:
@@ -1,204 +0,0 @@
|
||||
/* B04 지표면 3D 뷰어 방위 표시 — **지면에 누운 나침반 링**(2026-09-03 사용자 선택).
|
||||
*
|
||||
* 종전에는 방위각만으로 도는 평면 콤파스였다. 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줄 제한).
|
||||
*/
|
||||
|
||||
export interface TerrainCompass {
|
||||
root: HTMLElement;
|
||||
/** 카메라 오프셋(카메라 위치 − 타깃)으로 링·화살·표고침을 맞춘다. */
|
||||
update(offsetX: number, offsetY: number, offsetZ: number): void;
|
||||
setVisible(visible: boolean): void;
|
||||
}
|
||||
|
||||
const 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 function createTerrainCompass(): TerrainCompass {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b04-surface__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),
|
||||
"aria-hidden": "true",
|
||||
});
|
||||
const face = svg("path", { class: "b04-surface__compass-face", d: "" });
|
||||
const ring = svg("path", { class: "b04-surface__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" : ""}`,
|
||||
x1: "0",
|
||||
y1: "0",
|
||||
x2: "0",
|
||||
y2: "0",
|
||||
});
|
||||
ticks.push(tick);
|
||||
}
|
||||
const arrow = svg("path", { class: "b04-surface__compass-arrow", d: "" });
|
||||
const pole = svg("line", {
|
||||
class: "b04-surface__compass-pole",
|
||||
x1: "0",
|
||||
y1: "0",
|
||||
x2: "0",
|
||||
y2: "0",
|
||||
});
|
||||
const labels = CARDINALS.map(([name]) =>
|
||||
svg("text", {
|
||||
class: `b04-surface__compass-label${name === "N" ? " b04-surface__compass-label--north" : ""}`,
|
||||
x: "0",
|
||||
y: "0",
|
||||
}),
|
||||
);
|
||||
labels.forEach((label, index) => {
|
||||
label.textContent = CARDINALS[index][0];
|
||||
});
|
||||
const poleLabel = svg("text", { class: "b04-surface__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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user