feat(B05): 3D 흑백 지형 토글·서브패널 손잡이 규칙·좌측 여백 확대

- 3D 뷰 토글에 '흑백 지형' 추가(측점 라벨 우측, 기본 무지개). 정점색 1회
  바꿔치기(원본 userData 보관)라 로딩·회전 성능 영향 없음
- 유토곡선 접힘 버튼은 항상 바닥 — 순서(종단>테이블>유토곡선)상 테이블 아래에서
  나오므로. 테이블 접힘 버튼만 열린 유토곡선 위 경계에 나란히
- LONG_PAD.left 62->78: 좌측 패널 접기 버튼이 Y축 라벨·행제목을 가리는 문제.
  sticky 마스크·행제목 폭이 같은 값에서 파생돼 가로 스크롤 값 누출 없음

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 19:30:04 +09:00
co-authored by Claude Fable 5
parent 75232b9f73
commit 0025538b18
6 changed files with 48 additions and 15 deletions
+1
View File
@@ -310,6 +310,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onAxesVisible: viewer.setAxesVisible,
onStationLinesVisible: viewer.setStationLinesVisible,
onStationLabelsVisible: viewer.setStationLabelsVisible,
onSurfaceGrayscale: viewer.setSurfaceGrayscale,
onView: viewer.setView,
onResetView: () => viewer.setView("top"),
onMovePoint: viewer.beginMoveSelected,
+4
View File
@@ -54,6 +54,8 @@ interface PanelCallbacks {
onStationLinesVisible: (visible: boolean) => void;
/** 측점 번호·이름 라벨 표시 토글(기본 켜짐 — BP·EP·5측점 배수·구조물). */
onStationLabelsVisible: (visible: boolean) => void;
/** 지표면 흑백 표시 토글(기본 꺼짐 — 무지개 고도색). */
onSurfaceGrayscale: (grayscale: boolean) => void;
onView: (view: "iso" | "top" | "front" | "side") => void;
onResetView: () => void;
onMovePoint: () => void;
@@ -162,6 +164,8 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
toggleButton("축 표시", false, callbacks.onAxesVisible),
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible),
// 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청).
toggleButton("흑백 지형", false, callbacks.onSurfaceGrayscale),
);
const separator1 = document.createElement("span");
separator1.className = "b05-route__view-separator";
@@ -128,8 +128,6 @@ export interface RouteMassHaulPanel {
/** 오버레이 서브패널 — 호출한 쪽이 `position: relative`인 패널 본문 래퍼에 붙인다. */
overlay: HTMLElement;
isOpen(): boolean;
/** 접힌 손잡이가 앉을 바닥 높이(px) — 열린 테이블 오버레이 위 경계에 일렬로 놓기 위함. */
setHandleBase(px: number): void;
setContext(next: RouteMassHaulContext | null): void;
/** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */
attachScrollSync(main: HTMLElement): void;
@@ -228,13 +226,11 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
/**
* 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와
* 테이블 영역을 침범해도 된다). 접혀 있으면 열린 서브패널 더미(테이블 오버레이)의
* 위 경계(handleBase)로 올라간다 — 접힌 버튼들이 열린 패널 수평선에 일렬로 놓이도록
* (2026-08-05 사용자 지시). 아무것도 안 열려 있으면 패널 바닥(0)이다.
* 테이블 영역을 침범해도 된다). 접혀 있으면 패널 바닥으로 돌아온다 — 유토곡선은 서브패널
* 순서상 맨 아래라 테이블이 열려 있어도 버튼은 바닥에 남는다(2026-08-05 사용자 확정).
*/
let handleBase = 0;
function syncHandlePosition(): void {
handle.style.bottom = open ? `${overlay.offsetHeight}px` : `${handleBase}px`;
handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px";
}
function applyOpen(next: boolean): void {
@@ -371,11 +367,6 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
handle,
overlay,
isOpen: () => open,
setHandleBase(px) {
if (handleBase === px) return;
handleBase = px;
syncHandlePosition();
},
setContext(next) {
context = next;
},
@@ -492,8 +492,9 @@ export function createRouteProfilePanel(
const massHeight = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight;
const tableOverlayHeight = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0;
tableOverlay.setBottomOffset(massHeight);
// 접힌 유토곡선 손잡이는 열린 테이블의 위 경계에 일렬로 앉힌다(2026-08-05 사용자 지시).
massHaul.setHandleBase(tableOverlayHeight);
// 접힌 유토곡선 손잡이는 **하단(바닥)** 그대로 둔다 — 순서가 종단>테이블>유토곡선이라
// 유토곡선은 테이블 아래에서 나오기 때문(2026-08-05 사용자 확정). 반대로 접힌 테이블
// 손잡이는 열린 유토곡선 위 경계에 나란히 앉는다(TableOverlay.syncHandlePosition).
// 종단 그래프 최소 높이는 반드시 확보한다 — 서브패널이 갑자기 펼쳐져 자리가 모자라면
// 종단이 뒤로 넘어가는 대신 **메인 하단 패널 높이를 키운다**(2026-08-05 사용자 지시).
const deficit =
+33
View File
@@ -44,6 +44,8 @@ export interface RouteViewer {
) => Promise<void>;
reloadContours: (interval: number) => Promise<void>;
setSurfaceVisible: (visible: boolean) => void;
/** 지표면 흑백 표시 — 무지개 고도색이 헷갈릴 때 명도만 남긴다(정점색 1회 변환, 성능 영향 없음). */
setSurfaceGrayscale: (grayscale: boolean) => void;
setContoursVisible: (visible: boolean) => void;
setAxesVisible: (visible: boolean) => void;
setStationLinesVisible: (visible: boolean) => void;
@@ -331,6 +333,30 @@ export function createRouteViewer(): RouteViewer {
}
animate();
// 지표면 흑백 표시(2026-08-05 사용자 요청). 정점색 배열을 한 번 바꿔치기하는 것뿐이라
// (원본은 userData에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다.
let surfaceGrayscale = false;
function applySurfaceGrayscale(): void {
terrain?.traverse((child) => {
const geometry = (child as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
const color = geometry?.getAttribute("color") as THREE.BufferAttribute | undefined;
if (!geometry || !color) return;
const target = color.array as Float32Array;
if (!child.userData.originalColors) child.userData.originalColors = target.slice();
const original = child.userData.originalColors as Float32Array;
const stride = color.itemSize;
if (surfaceGrayscale) {
for (let i = 0; i + 2 < original.length; i += stride) {
const gray = 0.299 * original[i] + 0.587 * original[i + 1] + 0.114 * original[i + 2];
target[i] = target[i + 1] = target[i + 2] = gray;
}
} else {
target.set(original);
}
color.needsUpdate = true;
});
}
return {
root,
markers,
@@ -356,6 +382,8 @@ export function createRouteViewer(): RouteViewer {
if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide;
});
scene.add(terrain);
// 흑백 토글이 켜진 채 모델을 다시 불러와도 상태를 유지한다.
applySurfaceGrayscale();
fit("top");
markers.renderMarkers();
await reloadContours(interval);
@@ -365,6 +393,11 @@ export function createRouteViewer(): RouteViewer {
setSurfaceVisible(visible) {
if (terrain) terrain.visible = visible;
},
setSurfaceGrayscale(grayscale) {
if (surfaceGrayscale === grayscale) return;
surfaceGrayscale = grayscale;
applySurfaceGrayscale();
},
setContoursVisible(visible) {
contours.visible = visible;
},
@@ -44,7 +44,10 @@ export const CROSS_GRID_GAP = 16;
// bottom은 측점 라벨 한 줄 몫이다. X축 제목을 이름표로 합치며 남던 여백을 걷어내,
// 종단면도 아래 라벨이 그 밑 유토곡선과의 틈 **가운데**에 오도록 좁혔다.
// left/right는 유토곡선과 X축을 맞추는 값이라 함부로 바꾸면 두 그래프 측점선이 어긋난다.
export const LONG_PAD = { left: 62, right: 24, top: 12, bottom: 26 };
// left 62→78: B05 좌측 패널 접기 버튼이 Y축 라벨·행제목을 가려 여유를 더 줬다
// (2026-08-05 사용자 보고). sticky Y축 마스크·테이블 행제목 폭이 모두 이 값에서
// 파생되므로 함께 넓어져 가로 스크롤 시 값 누출이 없다.
export const LONG_PAD = { left: 78, right: 24, top: 12, bottom: 26 };
export const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 52 };
export interface YScaleOptions {