/* ============================================================================= * 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) — 셀렉트·입력 포커스 중엔 양보. * ========================================================================== */ /** 조작 버튼 하나 — 클릭이 카드 선택·팬으로 번지지 않게 막는다. */ 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)이 null 이면 지금 고른 것은 끌 수 없다. */ export interface DragHandleDeps { stepM: () => number | null; /** 끄는 동안 창에 적을 미리 보기(빈 글이면 지운다). */ preview: (text: string) => void; /** 놓을 때 한 번 — 화면 왼쪽(◀) 걸음 수 · 아래(▼) 걸음 수. 둘 중 하나만 0 이 아니다. */ commit: (stepsLeft: number, stepsDown: number) => void; } /** * 그림 위 손잡이(계획서 24-4a) — 고른 구조물 도형을 누른 채 끌면 십자와 같은 걸음으로 옮긴다. * 끄는 동안은 도형만 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가 다시 그려진다. * 좌우 · 상하 중 많이 끈 쪽 하나만 쓴다(십자와 같음). 축척은 카드 도면이 적어 둔 `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 stepM = deps.stepM(); const pxPerM = Number(svg.dataset.pxPerM); const exaggeration = Number(svg.dataset.exaggeration) || 1; const ctm = layer.getScreenCTM(); if (!onShape || !stepM || !(pxPerM > 0) || !ctm) return; event.preventDefault(); event.stopPropagation(); const [startX, startY] = [event.clientX, event.clientY]; let left = 0; let down = 0; const move = (next: PointerEvent): void => { const dx = next.clientX - startX; const dy = next.clientY - startY; const horizontal = Math.abs(dx) >= Math.abs(dy); left = horizontal ? Math.round(-dx / (pxPerM * ctm.a) / stepM) : 0; down = horizontal ? 0 : Math.round(dy / (pxPerM * exaggeration * ctm.d) / stepM); const tx = -left * stepM * pxPerM; const ty = down * stepM * pxPerM * exaggeration; shapes.forEach((shape) => shape.setAttribute("transform", `translate(${tx} ${ty})`)); const meters = (Math.abs(left || down) * stepM).toFixed(1); deps.preview( left ? `${left > 0 ? "◀" : "▶"} ${meters}m` : down ? `${down > 0 ? "▼" : "▲"} ${meters}m` : "", ); }; const up = (): void => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); shapes.forEach((shape) => shape.removeAttribute("transform")); deps.preview(""); if (!left && !down) 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); deps.commit(left, down); }; 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), }; }