/* ============================================================================= * B06_Section_UI_Cross_Panel_Base.ts * 구조물 조정 오버레이 창 **공용 부품**(2026-08-25 사용자: 세월교·BOX암거 창을 * 기슭막이 창(`_Cross_Structure_Panel.ts`)과 같은 템플릿으로 일원화). * * 템플릿 규약: * · 머리줄(`__head`) = 제목 + ✕ 닫기. * · 항목 행 = 1행 이름 라벨 + 2행 값 조작(2026-08-22 확정 2행 구조). * · **십자(D-pad)·9키 조작 세트는 항상 창 맨 아래**(2026-08-24·08-25 사용자). * · 키보드 방향키 = 십자 버튼(2026-08-23) — 셀렉트·입력 포커스 중엔 양보. * ========================================================================== */ import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const"; /** 조작 버튼 하나 — 클릭이 카드 선택·팬으로 번지지 않게 막는다. */ export 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; } /** 항목 행 — 1행 이름 라벨 + 2행 값 조작. */ export function makeRow(labelText: string): { row: HTMLElement; controls: HTMLElement } { const row = document.createElement("div"); row.className = "b06-structure-panel__struct"; const label = document.createElement("span"); label.className = "b06-structure-panel__label"; label.textContent = labelText; const controls = document.createElement("div"); controls.className = "b06-structure-panel__controls"; row.append(label, controls); return { row, controls }; } /** D-pad 슬롯 버튼 — 방향키 바인딩이 슬롯 클래스로 버튼을 찾는다. */ export function dpadButton( label: string, slot: "up" | "down" | "left" | "right" | "reset" | "equal", title: string, onClick: () => void, ): HTMLButtonElement { const button = makeButton(label, title, onClick); button.classList.add(`b06-structure-panel__btn--${slot}`); return button; } /** 창 뼈대 — root(.b06-structure-panel, 숨김 시작) + 머리줄(제목·닫기). */ export function buildPanelShell(onClose: () => void): { root: HTMLElement; head: HTMLElement; title: HTMLSpanElement; } { 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 closeButton = makeButton("✕", "닫기", onClose); 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); return { root, head, title }; } /** 마지막으로 누른 자리 — 방향키는 **초점 있는 쪽만** 받는다(계획서 24-3b). 횡단 보기에서 조정창과 * 종단도(▲▼ 계획고)가 한 화면에 함께 있어, 종단도를 누른 뒤에는 조정창이 방향키를 넘긴다. */ let lastPointer: Element | null = null; document.addEventListener( "pointerdown", (event) => { lastPointer = event.target instanceof Element ? event.target : null; }, true, ); /** 방향키가 종단도 몫인가 — 마지막으로 누른 자리가 종단도 안이면 참. */ export function arrowsBelongToProfile(): boolean { return !!lastPointer?.closest(".b05-route-profile"); } /** 고른 구조물의 도형 — 벽 · 집수정 · 측벽은 `culvert` 계열, BOX 끝은 판정면이 켜진다. */ const ACTIVE_SHAPES = '[class*="culvert"].is-active, .b06-chart__box-hit.is-active'; /** 끈 자리를 패널 걸음 · 한계로 맞춘 결과 — 화면 이동(m · 왼쪽 + · 아래 +) · 미리 보기 글 · 반영. */ export interface DragSnap { leftM: number; downM: number; text: string; apply: () => void; } /** 끌기 손잡이가 조정 창에 묻는 것. */ export interface DragHandleDeps { /** 끈 거리(m · 화면 왼쪽 + · 실제 아래 +) → 맞춘 자리. null = 지금 고른 것은 끌 수 없다. */ snap: (leftM: number, downM: number) => DragSnap | null; /** 끄는 동안 창에 적을 미리 보기(빈 글이면 지운다). */ preview: (text: string) => void; } const round1 = (value: number): number => Math.round(value * 10) / 10; const clampTo = (value: number, low: number, high: number): number => Math.min(Math.max(value, low), high); /** 십자 두 축 끌기를 맞추는 데 필요한 것 — 걸음 · ▲▼ 축 뜻 · 한계 · 반영. */ export interface AxisDragSpec { stepM: number; /** ▲▼ 한 칸이 1:1.2 사면(바깥 d · 아래 d/1.2)이면 사면 바깥의 화면 방향(왼쪽 = 1) · 순수 수직이면 null. */ slopeOutward: number | null; /** 걸음 맞춘 축 이동(◀ + · ▼ +) → 한계 안 가장 가까운 축 이동. */ clamp: (left: number, down: number) => [number, number]; apply: (left: number, down: number) => void; } /** 1:1.2 사면 — 사면 축 한 칸 = 바깥 d · 아래 d/1.2(`_Culvert_Solve.placePipeWall` · 집수정과 같음). */ const SLOPE_RUN = FILL_SLOPE_RATIO_MIN; /** * 끈 거리를 십자 두 축으로 나눠 걸음에 맞춘다(108-2) — ◀▶ 축 + ▲▼ 축을 함께. * 사면 축이면 아래 성분이 사면 칸을 정하고, 남은 가로가 ◀▶ 칸이 된다. 한계에 걸리면 * `clamp` 가 가장 가까운 허용 자리로 자르고, 미리 보기도 그 자리(놓은 뒤 결과와 같음)다. */ export function snapAxisDrag(leftM: number, downM: number, spec: AxisDragSpec): DragSnap { const { stepM, slopeOutward: outward } = spec; const snapStep = (value: number): number => round1(Math.round(value / stepM) * stepM); const wantDown = snapStep(outward === null ? downM : downM * SLOPE_RUN); const wantLeft = snapStep(leftM - (outward ?? 0) * wantDown); const [left, down] = spec.clamp(wantLeft, wantDown).map(round1); return { leftM: left + (outward ?? 0) * down, downM: outward === null ? down : down / SLOPE_RUN, text: moveText(left, down), apply: () => spec.apply(left, down), }; } /** 미리 보기 글 — 십자 단추 뜻 그대로(◀▶ m · ▲▼ m). */ function moveText(left: number, down: number): string { return [ left ? `${left > 0 ? "◀" : "▶"} ${Math.abs(left).toFixed(1)}m` : "", down ? `${down > 0 ? "▼" : "▲"} ${Math.abs(down).toFixed(1)}m` : "", ] .filter(Boolean) .join(" · "); } /** 축 한계 — 지금 값 `from` 에서 `move` 만큼 가되 [low, high] 안(조정값 공간). */ export function clampAxis(from: number, move: number, low: number, high: number): number { return round1(clampTo(from + move, low, high) - from); } /** * 그림 위 손잡이(계획서 24-4a) — 고른 구조물 도형을 누른 채 끌면 십자와 같은 걸음으로 옮긴다. * 끄는 동안은 도형만 맞춘 자리로 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가 * 다시 그려진다. 좌우 · 상하 두 축을 함께 쓴다(108-2). 축척은 카드 도면이 적어 둔 `data-px-per-m`. */ export function bindDragHandle( root: HTMLElement, deps: DragHandleDeps, ): { attach: () => void; detach: () => void } { const onDown = (event: PointerEvent): void => { if (!root.isConnected) { document.removeEventListener("pointerdown", onDown, true); return; } if (event.button !== 0 || root.classList.contains("is-hidden")) return; const svg = root.parentElement?.querySelector("svg.b06-section__chart"); const layer = svg?.querySelector(".b06-chart__plot-layer"); if (!svg || !layer || !svg.contains(event.target as Node)) return; const shapes = [...svg.querySelectorAll(ACTIVE_SHAPES)]; const onShape = shapes.some((shape) => { const box = shape.getBoundingClientRect(); return ( event.clientX >= box.left - 3 && event.clientX <= box.right + 3 && event.clientY >= box.top - 3 && event.clientY <= box.bottom + 3 ); }); const pxPerM = Number(svg.dataset.pxPerM); const exaggeration = Number(svg.dataset.exaggeration) || 1; const ctm = layer.getScreenCTM(); if (!onShape || !deps.snap(0, 0) || !(pxPerM > 0) || !ctm) return; event.preventDefault(); event.stopPropagation(); const [startX, startY] = [event.clientX, event.clientY]; let snapped: DragSnap | null = null; const move = (next: PointerEvent): void => { const leftM = -(next.clientX - startX) / (pxPerM * ctm.a); const downM = (next.clientY - startY) / (pxPerM * exaggeration * ctm.d); snapped = deps.snap(leftM, downM); const tx = -(snapped?.leftM ?? 0) * pxPerM; const ty = (snapped?.downM ?? 0) * pxPerM * exaggeration; shapes.forEach((shape) => shape.setAttribute("transform", `translate(${tx} ${ty})`)); deps.preview(snapped?.text ?? ""); }; const up = (): void => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); shapes.forEach((shape) => shape.removeAttribute("transform")); deps.preview(""); if (!snapped?.text) return; // 놓은 자리의 click 이 「같은 구조물 다시 누름 = 선택 해제」로 번지지 않게 한 번 막는다. const swallow = (click: Event): void => click.stopPropagation(); window.addEventListener("click", swallow, { capture: true, once: true }); setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); snapped.apply(); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", up); }; return { attach: () => document.addEventListener("pointerdown", onDown, true), detach: () => document.removeEventListener("pointerdown", onDown, true), }; } /** 끄는 동안 쓰는 미리 보기 글 자리 — 창 읽기 줄 아래. */ export function dragReadout(): HTMLSpanElement { const line = document.createElement("span"); line.className = "b06-structure-panel__drag"; return line; } const ARROW_SLOT: Record = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", }; /** * 키보드 방향키 = 십자 버튼. `containers` 중 숨겨지지 않은 첫 행의 슬롯 버튼을 * 누른다. 창이 숨겨지면 리스너를 떼고, 카드가 통째로 사라지면 스스로 해제한다. * 반환: show/hide 때 부를 (attach)와 (detach). */ export function bindArrowKeys( root: HTMLElement, containers: () => HTMLElement[], ): { attach: () => void; detach: () => void } { const onArrowKey = (event: KeyboardEvent): void => { if (!root.isConnected) { document.removeEventListener("keydown", onArrowKey); return; } const slot = ARROW_SLOT[event.key]; // 보기가 숨었으면(한 페이지 두 보기 · 계획서 24-3a) 다른 보기의 방향키를 먹지 않는다. if (!slot || root.classList.contains("is-hidden") || !root.getClientRects().length) return; if (arrowsBelongToProfile()) return; const focus = event.target as HTMLElement | null; if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return; for (const container of containers()) { if (container.classList.contains("is-hidden")) continue; const button = container.querySelector( `.b06-structure-panel__btn--${slot}`, ); if (button) { event.preventDefault(); button.click(); return; } } }; return { attach: () => document.addEventListener("keydown", onArrowKey), detach: () => document.removeEventListener("keydown", onArrowKey), }; }