feat(B06): 구조물 위치 조정을 횡단도 안 오버레이 창으로

카드 하단 버튼 그룹을 걷어내고 **해당 횡단도 안**에 뜨는 조정창으로 바꿨다
(2026-08-21 사용자 확정). 앞으로 구조물 위치 조정은 모두 이 창을 통한다.

- _UI_Cross_Structure_Panel.ts 신설. 그래프 영역 좌측 하단에 절대 위치로 얹는다
  (줌 버튼 우측 상단·면적표 중상단과 겹치지 않는 자리).
- 창 내용: 구조물 이름 · 자동 자리 대비 이동량 · ◀/▶/↺ · ✕(닫기 = 선택 해제).
- 이동량은 부호(+/−) 대신 "바깥 0.2m"처럼 적는다 — 좌·우 벽에서 부호만으로는
  어느 쪽인지 읽히지 않는다.

검증(공용 브라우저 4+4.3): 유입 벽 클릭 → 창 열림(제목·자동 자리), ◀ 2회 →
"바깥 0.2m"·도형 이동, ↺ → 자동 자리 복귀, ✕ → 창 닫힘·강조 해제.
tsc 통과, pytest 148 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 16:34:09 +09:00
co-authored by Claude Opus 5
parent 83a1309a37
commit 4eed191d2a
4 changed files with 190 additions and 52 deletions
@@ -0,0 +1,114 @@
/* =============================================================================
* B06_Section_UI_Cross_Structure_Panel.ts
* 횡단도 안에 뜨는 **구조물 위치 조정 오버레이 창**(2026-08-21 사용자 확정).
*
* 조정 조작구는 카드 하단이 아니라 **해당 횡단도 안에** 있어야 한다 — 어느 도면의
* 구조물을 만지는지 눈으로 붙어 있어야 하고, 카드 하단은 표시 반폭 조작구와 섞여
* 구분이 안 됐다. 앞으로 구조물 위치 조정은 모두 이 창을 통한다.
*
* 창은 그래프 영역(`.b06-cross-card__chart-wrap`) 안에 절대 위치로 얹힌다. 줌 버튼은
* 우측 상단, 면적표는 중상단이라 이 창은 **좌측 하단**에 둔다.
* ========================================================================== */
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
import { L } from "./B06_Section_UI_Section_Common";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
export interface StructurePanelDeps {
/** 지금 이동량(m, + = 계류측 바깥). 창을 열거나 값이 바뀔 때마다 읽는다. */
shiftFor: (key: RevetKey) => number;
/** 화면 좌(+)/우(−) 방향으로 미는 양(m). 부호 환산은 카드가 한다. */
nudge: (key: RevetKey, screenDeltaM: number) => void;
/** 자동 자리로 되돌린다. */
reset: (key: RevetKey) => void;
/** 창을 닫는다 = 구조물 선택 해제. */
close: () => void;
}
export interface StructurePanelHandle {
root: HTMLElement;
/** null이면 숨긴다. 값이 오면 그 구조물 기준으로 다시 그린다. */
show: (key: RevetKey | null) => void;
}
/** 한 걸음 이동량(m) — 벽 높이 눈금(0.1m)과 같은 단위로 맞춘다. */
const STEP_M = 0.1;
function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-structure-panel__btn";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
// 카드 선택·팬으로 번지면 도면이 다시 그려져 방금 맞춘 배율이 날아간다.
event.stopPropagation();
onClick();
});
return button;
}
/** 구조물 위치 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHandle {
const root = document.createElement("div");
root.className = "b06-structure-panel is-hidden";
// 창 안에서의 클릭이 카드 선택으로 번지지 않게 한 번에 막는다.
root.addEventListener("click", (event) => event.stopPropagation());
const title = document.createElement("span");
title.className = "b06-structure-panel__title";
const value = document.createElement("span");
value.className = "b06-structure-panel__value";
let current: RevetKey | null = null;
const act = (run: (key: RevetKey) => void) => () => {
if (current) run(current);
};
const buttons = document.createElement("div");
buttons.className = "b06-structure-panel__buttons";
buttons.append(
makeButton(
"◀",
L("B06_Cross_Revet_Left"),
act((key) => deps.nudge(key, STEP_M)),
),
makeButton(
"▶",
L("B06_Cross_Revet_Right"),
act((key) => deps.nudge(key, -STEP_M)),
),
makeButton(
"↺",
L("B06_Cross_Revet_Reset"),
act((key) => deps.reset(key)),
),
);
const closeButton = makeButton("✕", L("B06_Cross_Revet_Close"), () => deps.close());
closeButton.classList.add("b06-structure-panel__close");
const head = document.createElement("div");
head.className = "b06-structure-panel__head";
head.append(title, closeButton);
root.append(head, value, buttons);
return {
root,
show: (key) => {
current = key;
root.classList.toggle("is-hidden", key === null);
if (!key) return;
title.textContent = L(key === "inlet" ? "B06_Cross_Revet_Inlet" : "B06_Cross_Revet_Outlet");
const shift = deps.shiftFor(key);
// 자동 자리 기준 이동량 — 0이면 "자동"으로 적어 손을 안 댔음을 바로 알린다.
// 부호(+/−)만 적으면 좌·우 벽에서 어느 쪽인지 읽히지 않아 **안/바깥**으로 적는다.
const direction = L(shift > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward");
value.textContent =
Math.abs(shift) < 1e-9
? L("B06_Cross_Revet_Auto")
: `${direction} ${Math.abs(shift).toFixed(1)}m`;
},
};
}
+18 -43
View File
@@ -24,6 +24,7 @@ import {
type RockBoundaryControl,
} from "./B06_Section_UI_Cross_Design";
import { appendCulvertOverlay, computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert";
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import {
@@ -560,7 +561,23 @@ export function createCrossSectionCard(
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다.
const readout = buildAreaReadout(section.design, toggleArea);
setChipActive = readout.setActive;
chartWrap.append(svg, readout.root, buildZoomControls(zoomPan));
// 구조물 위치 조정은 **도면 안 오버레이 창**으로 한다(2026-08-21 사용자 확정).
// 벽이 서는 쪽(outward): 상단측(유입)이 좌측이면 유입 벽은 좌(+), 유출 벽은 우(−).
// 화면 좌(◀)로 민다 = offset이 커진다 — 벽 기준 이동량으로 환산해 넘긴다.
const outwardOf = (role: RevetKey): number =>
((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1;
const panel = buildStructurePanel({
shiftFor: (key) => revetOffset?.shiftFor(section, key) ?? 0,
nudge: (key, screenDeltaM) =>
revetOffset?.adjust(section.chainage_m, key, screenDeltaM * outwardOf(key)),
reset: (key) => revetOffset?.reset(section.chainage_m, key),
close: () => {
if (activeRevet) toggleRevet(activeRevet);
},
});
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
showRevetControl(activeRevet !== null);
chartWrap.append(svg, readout.root, buildZoomControls(zoomPan), panel.root);
// 다시 그려지기 전에 켜져 있던 강조를 되살린다(부모가 들고 있던 값).
if (activeArea) {
setBandActive(activeArea);
@@ -606,48 +623,6 @@ export function createCrossSectionCard(
);
footer.append(widthControl);
}
// 기슭막이 X 자리 ◀/▶/↺ — 벽을 골랐을 때만 보인다(2026-08-21 사용자 ①). 0.1m씩 민다.
// ◀/▶은 **화면 좌우**가 아니라 벽 기준 안쪽/바깥쪽이면 좌우 벽에서 뜻이 뒤집혀 헷갈린다.
// 화면 좌우 그대로 두고, 벽의 outward 부호는 제어 쪽에서 맞춘다.
if (revetOffset) {
const revetControl = document.createElement("div");
revetControl.className = "b06-cross-card__revetctl";
const makeButton = (label: string, title: string, onClick: () => void): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-design__rockb-btn";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
event.stopPropagation();
onClick();
});
return button;
};
// 벽이 서는 쪽(outward): 상단측(유입)이 좌측이면 유입 벽은 좌(+), 유출 벽은 우(−).
const outwardOf = (role: RevetKey): number =>
((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1;
// 화면 좌(◀)로 민다 = offset이 커진다. 이동량은 벽 기준(outward)으로 환산해 넘긴다.
const nudge = (screenDeltaM: number) => () => {
if (activeRevet) {
revetOffset.adjust(section.chainage_m, activeRevet, screenDeltaM * outwardOf(activeRevet));
}
};
const revetLabel = document.createElement("span");
revetLabel.className = "b06-cross-card__revetctl-label";
revetLabel.textContent = L("B06_Cross_Revet_Label");
revetControl.append(
revetLabel,
makeButton("◀", L("B06_Cross_Revet_Left"), nudge(0.1)),
makeButton("▶", L("B06_Cross_Revet_Right"), nudge(-0.1)),
makeButton("↺", L("B06_Cross_Revet_Reset"), () => {
if (activeRevet) revetOffset.reset(section.chainage_m, activeRevet);
}),
);
showRevetControl = (visible) => revetControl.classList.toggle("is-hidden", !visible);
showRevetControl(activeRevet !== null);
footer.append(revetControl);
}
card.append(footer);
return card;
}
+54 -9
View File
@@ -563,24 +563,69 @@
margin-left: auto;
}
/* 기슭막이 X 자리 ◀/▶/↺(2026-08-21) — 벽을 골랐을 때만 보인다. 표시 반폭 그룹과
나란히 서므로 어느 쪽 조작구인지 라벨로 가른다. */
.b06-cross-card__revetctl {
/* 구조물 위치 조정 오버레이 창(2026-08-21 사용자 확정) — **횡단도 안**에 뜬다.
줌 버튼이 우측 상단, 면적표가 중상단이라 이 창은 좌측 하단에 둔다. */
.b06-structure-panel {
position: absolute;
bottom: var(--spacing-8);
left: var(--spacing-8);
z-index: 3;
display: flex;
flex: 0 0 auto;
align-items: center;
flex-direction: column;
gap: 2px;
margin-left: var(--spacing-8);
padding: var(--spacing-8);
border: 1px solid var(--color-danger);
border-radius: var(--radius-inputs);
background: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
}
.b06-cross-card__revetctl.is-hidden {
.b06-structure-panel.is-hidden {
display: none;
}
.b06-cross-card__revetctl-label {
.b06-structure-panel__head {
display: flex;
align-items: center;
gap: var(--spacing-8);
justify-content: space-between;
}
.b06-structure-panel__title {
color: var(--color-danger);
font-size: var(--text-caption);
margin-right: 2px;
font-weight: var(--font-weight-medium);
}
.b06-structure-panel__value {
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b06-structure-panel__buttons {
display: flex;
gap: 2px;
}
.b06-structure-panel__btn {
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--text-caption);
line-height: 1.6;
}
.b06-structure-panel__btn:hover {
border-color: var(--color-danger);
color: var(--color-danger);
}
.b06-structure-panel__close {
padding: 0 4px;
border-color: transparent;
background: transparent;
}
/* 줌 버튼 — 그래프 우측 상단 오버레이. 휠 대신 쓰는 조작구다(2026-08-02 사용자 확정). */
+4
View File
@@ -240,6 +240,10 @@ export const ui_locales_b2 = {
"Select revetment — nudge with arrows",
],
B06_Cross_Revet_Label: ["기슭막이", "Revetment"],
B06_Cross_Revet_Close: ["조정창 닫기", "Close adjuster"],
B06_Cross_Revet_Auto: ["자동 자리", "Solved position"],
B06_Cross_Revet_Outward: ["바깥", "outward"],
B06_Cross_Revet_Inward: ["안쪽", "inward"],
B06_Cross_Revet_Left: ["기슭막이 0.1m 왼쪽으로", "Move revetment 0.1m left"],
B06_Cross_Revet_Right: ["기슭막이 0.1m 오른쪽으로", "Move revetment 0.1m right"],
B06_Cross_Revet_Reset: ["기슭막이 자동 자리로 초기화", "Reset revetment to solved position"],