/* ============================================================================= * B06_Section_UI_Page_Structures_Panel.ts * 횡단 보기의 「구조물 배치」 몫 — 폼 · 목록은 종단 보기와 **같은 노드 한 벌**(`design_shared`)이고, * 여기서는 횡단 보기가 보일 때 폼 알림을 받는 동작 · 횡단 조정 이음만 건다(계획서 24-3b). * * · 폼·목록 구현 = 공용 진입점(`A00_Common/b_structures_section`) — B05 그대로. * · 구조물(B~G군) 추가·수정·삭제는 세션 미저장분(`writePendingStructures`)에 쌓고 * [임시저장]·[확정]의 `flushPendingStructures`가 정본에 쓴다(캐시→저장 원칙). * · 계곡 통과 시설(A군, pipe_points 정본)은 **표시·선택만** — 추가·이동·삭제는 * 배수유역 재분할 체인이 있는 B05 몫이라 안내만 한다(2026-08-29 1차 범위). * · 목록·폼 선택 → 해당 측점 카드 선택·스크롤. 횡단도 벽·구체 선택 → 폼에 그 시설 * 로드(소유 측점 기준). 하위 [횡단 조정] 컨테이너는 Adjust_Dock이 맡는다. * ========================================================================== */ import type { PipeFacilityItem } from "../A00_Common/b_structures_section"; import { bindDesignView, sharedStructures, type DesignPageShared, } from "../A00_Common/design/design_shared"; import { fetchStructures, fetchStructureTypes, readPendingStructures, pipesToStructureMarks, structureAnchorM, writePendingStructures, type StructureInstance, type StructureType, } from "../B05_Profile/B05_Profile_Api_Structures"; import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { readSummaryItem } from "../common_util/common_util_summary_item"; import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, PIPE_MATCH_M, resolveSelection, selectDesign, type DesignSelection, } from "../A00_Common/design/design_selection"; import { showToast } from "@ui/ui_template_elements"; import { adjustDockRoot, refreshAdjustSlots, resetAdjustDock, setAdjustSideSlots, } from "./B06_Section_UI_Adjust_Dock"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; import { withSpecDefaults } from "./B06_Section_UI_Page_Structures_Spec"; const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요."; const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다."; /** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */ const PIPE_ADD_NOTICE = "계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; const PIPE_REMOVE_NOTICE = "계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; /** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */ const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다."; const PIPE_MOVE_NOTICE = "기준 측점을 옮겼습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; export interface B06StructuresPanelDeps { projectId: string | null; /** 한 페이지 한 벌 — 「구조물 배치」 폼은 종단 보기와 같은 노드다(계획서 24-3b). */ shared: DesignPageShared; /** 횡단 캐시 — 폼 기본값을 조정창과 **같은 원천**(culvert 스펙)에서 읽는다 * (2026-08-29 사용자: 예전 조정창이 보여주던 값이 기본값이다). */ detail?: () => SectionDetailResponse | null; /** 지금 그려진 벽 높이(m) — 배관 벽은 스펙이 아니라 기하가 정하므로 폼 기본 * 높이도 이 값을 쓴다(조정창 표시값과 같다). */ wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null; /** 지금 그려진 벽 형태 — 조정값(m)이 있으면 그쪽이 정본이라 폼도 이 값을 보인다. */ wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null; /** 그 측점에서 유입·유출 벽이 각각 좌·우 어느 쪽인가(독립 기슭막이 칸 이름표). */ wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null; /** 측점 간격(m) — 측점번호+잔여거리 환산용. */ stationInterval: () => number; /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */ focusChainage: (chainageM: number) => void; /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ onMarks?: (structures: StructureInstance[], types: StructureType[]) => void; /** 폼 [수정]으로 바뀐 관 옵션을 캐시에 예약한다 — [저장]·[확정]이 정본에 쓴다 * (2026-08-29: 조정창 구간값과 같은 경로). 없으면 안내만 한다. */ queuePipeOptions?: (chainageM: number, patch: Record) => void; /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — * 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */ applyPipeOptions?: (chainageM: number, patch: Record) => void; /** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로** * 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */ onStructuresChanged?: () => void; /** 관 목록 초안을 고쳤다 — 한 벌 배수유역도가 그 초안으로 다시 선다. */ onPipesWritten?: () => void; } export interface B06StructuresPanel { /** 「구조물 배치」 섹션(하위 [횡단 조정] 포함) — 좌측 패널에 붙인다. */ root: HTMLElement; /** 배치된 구조물 목록 — 하단 고정 dock에 붙인다(B05와 같은 자리). */ listRoot: HTMLElement; /** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */ load: () => Promise; /** 기하가 한계로 자른 실제 값을 폼에 되돌린다 — 입력 숫자만 커지고 그림은 그대로인 * 상태를 막는다(2026-08-29 사용자: 재질 높이 한계에 걸린 값). */ overrideOptions: (chainageM: number, patch: Record) => void; /** 고른 횡단도(측점)에 있는 구조물을 폼에 올린다 — 계곡 통과 시설이 먼저고, * 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). 횡단도의 카드 · * 벽 · 구체 어디를 눌러도 이 길 하나다. 폼이 연 것을 고름 값으로 돌려준다. */ showAtChainage: (chainageM: number | null) => DesignSelection; /** 횡단도에서 벽·구체 선택을 풀 때 — 관 계열 폼만 비운다. 구조물 폼은 그대로 둔다(카드를 * 갈아 선택하며 옛 카드의 벽 선택이 풀리는 순간 새 카드의 폼을 지우면 안 된다). */ releasePipe: () => void; /** 그 측점에서 폼이 열 항목을 **열지 않고** 고름 값으로 풀어 준다(세션에 적는 데 쓴다). */ selectionAt: (chainageM: number | null) => DesignSelection; /** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */ getStructures: () => StructureInstance[]; /** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */ currentChainage: () => number | null; /** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */ addStructureAt: (chainageM: number, typeId: string) => void; removeStructureById: (structureId: string) => boolean; addPipeAt: (chainageM: number) => void; removePipeAt: (chainageM: number) => void; movePipeTo: (fromChainageM: number, toChainageM: number) => void; moveStructureTo: (structureId: string, toChainageM: number) => boolean; /** 유입구 "구조"에 합쳐진 B06 유입측 형식 — 조정창 값과 맞춘다(2026-08-29 지시 5). */ facility: { setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void; onInletStructureChange: (handler: (value: "auto" | "revet" | "I" | "L" | "U") => void) => void; }; } export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06StructuresPanel { let structures: StructureInstance[] = []; let pipeFacilities: PipeFacilityItem[] = []; /** 지금 폼에 올라온 계곡 통과 시설의 누가거리 — 유입측 형식 반영 대상. */ let currentChainageM: number | null = null; /** 새 항목에 식별자를 미리 붙인다 — 저장 전에도 목록에서 고르고 지울 수 있어야 * 하고, 서버는 빈 값일 때만 새로 발급한다(B05 다리와 같은 규칙). */ const withLocalIds = (next: StructureInstance[]): StructureInstance[] => next.map((item) => item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") }, ); let markTypes: StructureType[] = []; /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ const pushMarks = (): void => deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); // 폼 한 벌(계획서 24-3b) — 횡단 보기가 보일 때 폼 알림을 받는 동작만 건다. 상세 옵션 칸은 // 한 벌이 보기에 따라 그린다(횡단 보기에서만). const section = sharedStructures(deps.shared); bindDesignView(deps.shared, "cross", { structures: { onChange: (next) => { structures = withLocalIds(next); section.setStructures(structures); pushMarks(); if (deps.projectId) writePendingStructures(deps.projectId, structures); deps.onStructuresChanged?.(); }, onSelect: (structure) => { if (structure) deps.focusChainage(structureAnchorM(structure)); }, getInterval: deps.stationInterval, // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. onPipeAdd: (chainageM, attributes) => { if (!deps.projectId) { showToast(PIPE_ADD_GUIDE, "error"); return; } const at = Number(chainageM.toFixed(2)); if (pipeFacilities.some((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M)) { showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error"); return; } pipeFacilities.push({ chainage_m: at, facility: attributes.facility ?? "pipe", source: "user", options: attributes.options ?? {}, summary_item: attributes.summary_item ?? null, }); pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); currentChainageM = at; persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); deps.onStructuresChanged?.(); showToast(PIPE_ADD_NOTICE, "success"); }, // 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다 // (2026-09-12 일원화). 재분할은 [저장]·[확정] 뒤 서버 몫이다. onPipeUpdate: (fromChainageM, toChainageM, attributes) => { // 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는 // 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다 // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M) movePipeTo(fromChainageM, toChainageM); if (attributes.summary_item !== undefined) { // 옮긴 뒤 자리(toChainageM)에서 찾는다 — 안 옮겼으면 from 과 0.51m 안이라 같은 관이다. const mine = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - toChainageM) < PIPE_MATCH_M, ); if (mine && (mine.summary_item ?? null)?.key !== attributes.summary_item?.key) { mine.summary_item = attributes.summary_item; persistPipes(); section.setPipeFacilities(pipeFacilities); deps.onStructuresChanged?.(); } } const patch = attributes.options; if (!patch || !Object.keys(patch).length) return; if (!deps.queuePipeOptions) { showToast(PIPE_ADD_GUIDE, "error"); return; } // 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야 // 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게). // 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다. deps.queuePipeOptions(fromChainageM, patch as Record); deps.applyPipeOptions?.(fromChainageM, patch as Record); // 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다. const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); if (hit) hit.options = { ...(hit.options ?? {}), ...patch }; persistPipes(); section.setPipeFacilities(pipeFacilities); }, onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { // 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. selectDesign( deps.projectId, chainageM === null ? NO_SELECTION : { chainageM, structureId: null }, { from: "b06" }, ); if (chainageM !== null) deps.focusChainage(chainageM); }, }, }); // 고른 것 없이 좌측 폼을 만지면 값이 아무 데도 가지 않는다(패널 실시간 반영이 // 선택된 항목에만 걸린다) — 조용히 무시되던 자리라 이유를 알린다(2026-08-30 사용자: // "형태에 따라 모양을 넣어주는 기능이 누락되는 경우가 있어"의 원인). section.facility.root.addEventListener("change", () => { if (deps.shared.view() === "cross" && !section.hasSelection()) showToast(PIPE_PICK_GUIDE, "error"); }); // 하위 [횡단 조정] 컨테이너 — 폼 본문 맨 아래, 기존 항목과 경계 구분(2026-08-29 // 사용자: 병합 항목은 하위 컨테이너로 담아 혼동 방지). resetAdjustDock(); section.root.querySelector(".b05-route__panel-body")?.append(adjustDockRoot()); // 단 수·옵션 행은 유입구·유출구 그룹으로 옮겨 붙는다(2026-08-29 지시 4). setAdjustSideSlots({ inlet: section.facility.inletSlot, outlet: section.facility.outletSlot, extra: section.facility.extraSlot, revetInlet: section.facility.revetInletSlot, revetOutlet: section.facility.revetOutletSlot, wingInlet: section.facility.wingInSlot, wingOutlet: section.facility.wingOutSlot, }); async function load(): Promise { if (!deps.projectId) return; const projectId = deps.projectId; try { const [types, stored, pipeResponse] = await Promise.all([ // B06 은 **구조물 전체**를 넣을 수 있어야 한다(2026-09-07 사용자 지시 5). B05 목록에서 // 빠져 있던 B군 종단배수·F군 생태/녹화도 여기서는 고를 수 있다 — 레지스트리 주석이 // 「B06 개별 횡단도 옵션으로 재사용」이라 적어 둔 그 자리다. fetchStructureTypes(true), fetchStructures(projectId), fetchDetailPipePoints(projectId), ]); section.setTypes(types); markTypes = types; // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙). structures = readPendingStructures(projectId) ?? stored.structures; section.setStructures(structures); const detail = deps.detail?.() ?? null; // 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며, // 이래야 B05 에서 넣고 B06 으로 넘어와도 같은 목록이 보인다(2026-09-12 사용자). const pendingPipes = readPendingPipes(projectId); pipeFacilities = (pendingPipes ?? pipeResponse.pipe_points).map((pipe) => ({ chainage_m: pipe.chainage_m, facility: pipe.facility ?? "pipe", start_m: pipe.start_m, end_m: pipe.end_m, source: pipe.source, summary_item: readSummaryItem(pipe.summary_item), // 폼 기본값 = 조정창이 쓰던 값(횡단 스펙). 저장된 옵션이 있으면 그쪽이 이긴다. options: withSpecDefaults( pipe.options, detail, pipe.chainage_m, types, deps.wallHeight, deps.wallForm, ), // 담당 유역의 설계유량 — 물넘이·세월교 개략 단면(필요 수심 = 월류 높이 // 자동값)이 이 값에서 나온다. B05와 같은 규칙으로 유역을 측점으로 짝짓는다 // (2026-08-30 사용자: 월류 폭을 넣어도 높이가 자동 계산되지 않았다). design_flow_m3s: pipeResponse.basins.find( (basin) => Math.abs(basin.chainage_m - pipe.chainage_m) < PIPE_MATCH_M, )?.design_flow_m3s ?? null, })); section.setPipeFacilities(pipeFacilities); // 종단 알약 레인 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합쳐 B05 와 같은 표기로. pushMarks(); // 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다 // (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도 // 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다. const pick = readStructurePick(deps.projectId); const picked = currentChainageM ?? pick?.at ?? null; // 관이면 그 관, 옹벽 · 기슭막이 같은 구조물이면 저장된 구조물 id 로 폼을 세운다. if (picked !== null) { section.selectAt({ chainageM: picked, structureId: currentChainageM === null ? (pick?.structureId ?? null) : null, }); } } catch (error) { showToast( error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.", "error", ); } } /** 폼에 올리기 직전에 좌·우 이름표를 그 측점 방향으로 맞춘다(독립 기슭막이 칸). */ function syncSideLabels(chainageM: number): void { const labels = deps.wallSideLabels?.(chainageM); if (labels) section.facility.setRevetSideLabels(labels); } /** 폼에 올리기 직전에 그 관의 옵션을 최신 스펙으로 보강한다(빈 칸만 채운다). */ function refreshSpecDefaults(chainageM: number): void { const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, ); if (!hit) return; syncSideLabels(hit.chainage_m); hit.options = withSpecDefaults( hit.options, deps.detail?.() ?? null, hit.chainage_m, markTypes, deps.wallHeight, deps.wallForm, ); section.setPipeFacilities(pipeFacilities); } /** * 관 목록을 **미저장 스냅샷**으로 남긴다 — B05 가 쓰는 바로 그 세션이다(2026-09-12 * 사용자: 담는 꼴도 한 벌이어야 한다). 종전에는 B06 만 「바뀐 것만」 따로 담아 * 두 페이지가 서로 다른 자리를 보고 저장 순서에 따라 결과가 갈렸다. */ function persistPipes(): void { if (!deps.projectId) return; writePendingPipes( deps.projectId, pipeFacilities.map((pipe) => ({ chainage_m: pipe.chainage_m, source: pipe.source ?? "user", facility: pipe.facility, ...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}), ...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}), ...(pipe.options ? { options: pipe.options } : {}), ...(pipe.summary_item ? { summary_item: pipe.summary_item } : {}), })), ); deps.onPipesWritten?.(); } /** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */ function movePipeTo(fromChainageM: number, toChainageM: number): void { if (!deps.projectId) { showToast(PIPE_MOVE_GUIDE, "error"); return; } const to = Number(toChainageM.toFixed(2)); const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); if (hit) hit.chainage_m = to; if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M) currentChainageM = to; pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); showToast(PIPE_MOVE_NOTICE, "success"); } /** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */ function removePipeAt(chainageM: number): void { if (!deps.projectId) { showToast(PIPE_ADD_GUIDE, "error"); return; } const at = Number(chainageM.toFixed(2)); pipeFacilities = pipeFacilities.filter( (entry) => Math.abs(entry.chainage_m - at) >= PIPE_MATCH_M, ); if (currentChainageM !== null && Math.abs(currentChainageM - at) < PIPE_MATCH_M) currentChainageM = null; persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); deps.onStructuresChanged?.(); showToast(PIPE_REMOVE_NOTICE, "success"); } return { root: section.root, listRoot: section.listRoot, load, currentChainage: () => currentChainageM, // 종단 우클릭 → 좌측 폼·목록과 같은 함수. 폼이 열리고 목록·알약도 함께 선다. addStructureAt: (chainageM, typeId) => section.addAt(chainageM, typeId), removeStructureById: (structureId) => section.removeById(structureId), addPipeAt: (chainageM) => section.addAt(chainageM, "pipe"), removePipeAt: (chainageM) => removePipeAt(chainageM), movePipeTo: (fromChainageM, toChainageM) => movePipeTo(fromChainageM, toChainageM), moveStructureTo: (structureId, toChainageM) => section.moveById(structureId, toChainageM), facility: { setInletStructure: (value) => section.facility.setInletStructure(value), onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler), }, overrideOptions: (chainageM, patch) => { const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, ); if (!hit) return; hit.options = { ...(hit.options ?? {}), ...patch }; section.setPipeFacilities(pipeFacilities); // 폼을 그 값으로 다시 세운다 — 조정창이 한계를 알려 주던 것과 같은 자리다. section.selectPipeByChainage(hit.chainage_m); }, getStructures: () => structures, releasePipe: () => { currentChainageM = null; section.selectPipeByChainage(null); }, selectionAt: (chainageM) => { const selection = { chainageM, structureId: null }; return { chainageM, structureId: section.resolveAt(selection).structureId }; }, showAtChainage: (chainageM) => { currentChainageM = null; const selection: DesignSelection = { chainageM, structureId: null }; // 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에 // 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳). const hit = resolveSelection(selection, pipeFacilities, structures); if (hit.pipeChainageM !== null) { currentChainageM = hit.pipeChainageM; refreshSpecDefaults(hit.pipeChainageM); } section.selectAt(selection); return { chainageM, structureId: hit.structureId }; }, }; } /** * 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그 * 구조물의 **소유 측점** 시설을 좌측 폼에 올린다(2026-08-29 일원화 3단계). * 제어 객체의 select를 감싸기만 한다 — 조작·저장 경로는 그대로다. */ /** 지금 처리 중인 선택이 **도면에서 벽·구체를 찍은 것**인가 — 카드 선택 리스너가 * 이 값으로 "카드만 고른 경우"를 가려낸다(2026-08-29 사용자: 측점 선택 때 구조물이 * 자동으로 골라지면 안 된다). 같은 턴에서만 참이다. */ let wallSelecting = false; export function isWallSelecting(): boolean { return wallSelecting; } function markWallSelecting(): void { wallSelecting = true; queueMicrotask(() => { wallSelecting = false; }); } export function wireStructureSelection( stationControls: StationControls, detail: () => SectionDetailResponse | null, panel: B06StructuresPanel, /** 고른 부재를 세션에 남긴다(2026-09-04). 해제(null)는 부르지 않는다 — 조정창 닫기가 * 카드(측점) 선택까지 지우면 안 된다. 측점 해제는 카드 선택 리스너가 따로 적는다. */ remember?: (chainageM: number, key: string) => void, ): { syncInletStructure: () => void } { const ownerChainageOf = (chainageM: number): number => { const sectionAt = detail()?.cross_sections.find( (entry) => Math.abs(entry.chainage_m - chainageM) < 0.01, ); return (sectionAt && stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m) ?? chainageM; }; // 유입구 "구조" ↔ B06 유입측 형식 — 폼에서 고르면 조정값에 반영하고, 시설을 폼에 // 올릴 때는 조정값을 폼으로 되읽는다(2026-08-29 지시 5 병합). const sectionAt = (chainageM: number) => detail()?.cross_sections.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.01); panel.facility.onInletStructureChange((value) => { const chainageM = panel.currentChainage(); if (chainageM === null) return; stationControls.inletStructure.set(chainageM, value); }); /** 폼에 올라온 시설의 유입측 형식을 폼 드롭다운에 되싣는다. */ const syncInletStructure = (): void => { const chainageM = panel.currentChainage(); const target = chainageM === null ? undefined : sectionAt(chainageM); if (target) panel.facility.setInletStructure(stationControls.inletStructure.valueFor(target)); }; const origRevet = stationControls.revetOffset.select; stationControls.revetOffset.select = (chainageM, key) => { markWallSelecting(); origRevet(chainageM, key); if (key) remember?.(ownerChainageOf(chainageM), key); if (key) panel.showAtChainage(ownerChainageOf(chainageM)); else panel.releasePipe(); if (key) syncInletStructure(); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다. refreshAdjustSlots(); }; const origFord = stationControls.ford.select; stationControls.ford.select = (chainageM, role) => { markWallSelecting(); origFord(chainageM, role); if (role) remember?.(chainageM, role); if (role) panel.showAtChainage(chainageM); else panel.releasePipe(); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다 // (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1). refreshAdjustSlots(); }; const origBox = stationControls.box.select; stationControls.box.select = (chainageM, role) => { markWallSelecting(); origBox(chainageM, role); if (role) remember?.(chainageM, role); if (role) panel.showAtChainage(chainageM); else panel.releasePipe(); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다. refreshAdjustSlots(); }; return { syncInletStructure }; }