diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts new file mode 100644 index 00000000..9e434e9f --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -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`; + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 255ff8a6..e61f6903 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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; } diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index adb3ce59..3ec1658d 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -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 사용자 확정). */ diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 3d4ba95c..2cc6840f 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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"],