From 2e28af6e8697f5f8c4e401d30832f7bea0345147 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 14:20:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05,B06):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88?= =?UTF-8?q?=EB=A5=BC=20B06=EC=97=90=20=EC=9E=A5=EC=B0=A9=ED=95=98=EA=B3=A0?= =?UTF-8?q?=20[=ED=9A=A1=EB=8B=A8=20=EC=A1=B0=EC=A0=95]=20=EC=82=AC?= =?UTF-8?q?=EB=B3=B8=EC=9D=84=20=EC=84=B8=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05/B06 인터페이스 일원화 1차 (2026-08-29 사용자 확정 계획): - A00_Common/b_structures_section.ts: B05 구조물 배치 폼·목록 공용 진입점 (재수출 + 스타일 동봉) - B06 좌측 패널에 [구조물 배치] 섹션 + 하단 dock [목록][구분선][저장버튼] (B05 동일 템플릿). 구조물 조작은 세션 pending으로 쌓여 기존 flushPendingStructures([임시저장]·[확정])로 정본 반영 - 선택 연동: 목록·폼 → 최근접 카드 focusStation, 횡단도 벽·세월교·BOX → 좌측 폼에 소유 측점 시설 로드(wireStructureSelection) - [횡단 조정] 하위 컨테이너(Adjust_Dock): 오버레이 조정창을 같은 deps로 사본 인스턴스 생성해 세움 — 로직 이동 0, 카드 재렌더로 자동 동기. 9키·십자 행은 사본에서 숨김(사용자 지시 7), 방향키 이중 입력 방지 - A군 시설 추가·이동·삭제는 B06에서 안내 토스트(배수유역 체인은 B05 몫) 검증: 공용 브라우저 — 구간값 길이 사본 + 10.0→11.0m 양측 동기, 오버레이 - 원복, ✕ 닫기 시 사본 해제. B05 회귀 없음. typecheck 통과. --- A00_Common/b_structures_section.ts | 17 ++ B06_Section/B06_Section_UI_Adjust_Dock.ts | 60 +++++++ B06_Section/B06_Section_UI_Cross_Box_Panel.ts | 10 +- .../B06_Section_UI_Cross_Ford_Panel.ts | 10 +- .../B06_Section_UI_Cross_Structure_Panel.ts | 12 +- B06_Section/B06_Section_UI_Cross_View.ts | 70 ++++---- .../B06_Section_UI_Cross_View_Bodies.ts | 72 +++++--- B06_Section/B06_Section_UI_Page.ts | 37 ++++- .../B06_Section_UI_Page_Structures_Panel.ts | 156 ++++++++++++++++++ B06_Section/B06_Section_UI_Section_View.ts | 7 + .../B06_Section_UI_Style_Cross_Controls.css | 42 +++++ 11 files changed, 427 insertions(+), 66 deletions(-) create mode 100644 A00_Common/b_structures_section.ts create mode 100644 B06_Section/B06_Section_UI_Adjust_Dock.ts create mode 100644 B06_Section/B06_Section_UI_Page_Structures_Panel.ts diff --git a/A00_Common/b_structures_section.ts b/A00_Common/b_structures_section.ts new file mode 100644 index 00000000..051169fc --- /dev/null +++ b/A00_Common/b_structures_section.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * b_structures_section.ts + * 「구조물 배치」 컨테이너 공용 진입점 — B05·B06 두 페이지가 같은 주소로 쓴다 + * (2026-08-29 사용자: B05/B06 인터페이스 일원화 — 배치 폼·하단 목록 템플릿 동일). + * + * 구현은 B05 모듈(`B05_Profile_UI_Structures_Panel` 외)에 있다. 이 파일은 재수출과 + * 스타일 동봉만 한다 — B06이 이 파일 하나만 import해도 B05와 같은 모양이 실린다. + * ========================================================================== */ +import "../B05_Profile/B05_Profile_UI_Style.css"; +import "../B05_Profile/B05_Profile_UI_Style_Structures.css"; + +export { + createStructuresSection, + GROUP_LABELS, + type PipeFacilityItem, + type StructuresSection, +} from "../B05_Profile/B05_Profile_UI_Structures_Panel"; diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts new file mode 100644 index 00000000..f267c4be --- /dev/null +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * B06_Section_UI_Adjust_Dock.ts + * 좌측 [구조물 배치] 섹션 안 **[횡단 조정] 하위 컨테이너**(2026-08-29 사용자: + * B05/B06 일원화 — 오버레이 조정창 항목을 좌측에도 보이되, 기존 폼과 구분되게 + * 접이식 하위 컨테이너에 담는다. 9키·십자 위치제어는 오버레이 전용). + * + * 카드가 조정창을 만들 때 같은 deps로 **사본 창**을 하나 더 만들어 이 슬롯에 + * 세운다(adopt). 값·동작이 같은 함수라 오버레이·좌측 어느 쪽을 만져도 카드 + * 재렌더에서 두 창이 함께 새 값으로 그려진다 — 별도 동기화 코드가 없다. + * 카드가 여럿이어도 슬롯은 하나 — 마지막으로 고른 구조물의 사본만 선다. + * ========================================================================== */ + +let dockRoot: HTMLDetailsElement | null = null; +let slot: HTMLElement | null = null; +let placeholder: HTMLElement | null = null; + +function ensureDock(): void { + if (dockRoot) return; + dockRoot = document.createElement("details"); + dockRoot.className = "b06-adjust-dock"; + dockRoot.open = true; + const summary = document.createElement("summary"); + summary.textContent = "횡단 조정"; + placeholder = document.createElement("p"); + placeholder.className = "b06-adjust-dock__empty"; + placeholder.textContent = "횡단도에서 구조물(벽·구체)을 고르면 조정 항목이 여기에 뜹니다."; + slot = document.createElement("div"); + slot.className = "b06-adjust-dock__slot"; + dockRoot.append(summary, placeholder, slot); +} + +/** 하위 컨테이너 루트 — 페이지가 [구조물 배치] 섹션 본문에 붙인다. */ +export function adjustDockRoot(): HTMLElement { + ensureDock(); + return dockRoot!; +} + +/** 페이지 진입 시 초기 상태(빈 슬롯 + 안내문)로 되돌린다. */ +export function resetAdjustDock(): void { + ensureDock(); + slot!.replaceChildren(); + placeholder!.hidden = false; +} + +/** 조정창 사본을 슬롯에 세운다 — 이미 다른 사본이 있으면 갈아 끼운다. */ +export function adoptAdjustPanel(panelRoot: HTMLElement): void { + ensureDock(); + panelRoot.classList.add("b06-structure-panel--dock"); + if (slot!.firstChild !== panelRoot || slot!.childNodes.length !== 1) { + slot!.replaceChildren(panelRoot); + } + placeholder!.hidden = true; +} + +/** 이 사본이 슬롯에 서 있으면 내린다 — 다른 사본이 이미 대신 서 있으면 손대지 않는다. */ +export function releaseAdjustPanel(panelRoot: HTMLElement): void { + if (!slot || !slot.contains(panelRoot)) return; + slot.replaceChildren(); + placeholder!.hidden = false; +} diff --git a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts index a4760a28..7897c67f 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts @@ -42,10 +42,12 @@ export interface BoxPanelHandle { /** 한 걸음(m) — 사용자 지정 0.1m. */ const STEP_M = 0.1; -/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */ -export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle { +/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. + * `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29): 흐름 배치 + 방향키 미장착. */ +export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): BoxPanelHandle { const shell = buildPanelShell(() => deps.close()); const { root, title } = shell; + if (opts?.dock) root.classList.add("b06-structure-panel--dock"); const value = document.createElement("div"); value.className = "b06-structure-panel__value"; @@ -93,7 +95,9 @@ export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle { root.append(value, moveRow.row); - const arrows = bindArrowKeys(root, () => [moveRow.controls]); + const arrows = opts?.dock + ? { attach: (): void => undefined, detach: (): void => undefined } + : bindArrowKeys(root, () => [moveRow.controls]); function render(): void { if (!current) return; diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts index 1cf5e408..d2d967aa 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -65,10 +65,12 @@ const ANGLE_STEP_DEG = 5; /** 관경 선택지(mm) — B05 레지스트리 `ford_bridge.pipe_diameter_mm` choices와 같다. */ const DIAMETER_CHOICES_MM = [800, 1000, 1200, 1500]; -/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */ -export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle { +/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다. + * `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29): 흐름 배치 + 방향키 미장착. */ +export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle { const shell = buildPanelShell(() => deps.close()); const { root, title } = shell; + if (opts?.dock) root.classList.add("b06-structure-panel--dock"); const value = document.createElement("div"); value.className = "b06-structure-panel__value"; @@ -233,7 +235,9 @@ export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle { moveRow.row, ); - const arrows = bindArrowKeys(root, () => [moveRow.controls]); + const arrows = opts?.dock + ? { attach: (): void => undefined, detach: (): void => undefined } + : bindArrowKeys(root, () => [moveRow.controls]); function render(): void { if (!current) return; diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index dc00bb8b..a85cfd06 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -111,11 +111,17 @@ const LENGTH_STEP_M = SPAN_STEP_M * 2; */ const panelScrollTops = new Map(); -/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */ -export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHandle { +/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. + * `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29 일원화): 흐름 배치 스타일을 입고, + * 방향키 문서 리스너를 달지 않는다(오버레이 창과 이중으로 눌리는 것 방지). */ +export function buildStructurePanel( + deps: StructurePanelDeps, + opts?: { dock?: boolean }, +): StructurePanelHandle { const root = document.createElement("div"); root.className = "b06-structure-panel"; root.classList.add("is-hidden"); + if (opts?.dock) root.classList.add("b06-structure-panel--dock"); root.addEventListener("scroll", () => { if (!root.classList.contains("is-hidden")) panelScrollTops.set(deps.scrollKey, root.scrollTop); }); @@ -482,7 +488,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan root.classList.toggle("is-hidden", key === null); document.removeEventListener("keydown", onArrowKey); if (!key) return; - document.addEventListener("keydown", onArrowKey); + if (!opts?.dock) document.addEventListener("keydown", onArrowKey); // 다단 벽 — 유출측(extra0…)과 유입·집수정 계류측(bextra0…)이 같은 이름을 쓴다. const isExtra = key.startsWith("extra") || key.startsWith("bextra"); const extraIndex = key.startsWith("bextra") ? Number(key.slice(6)) : Number(key.slice(5)); diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index df4f6938..3e553132 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -42,6 +42,7 @@ import type { StructureSpanControl, } from "./B06_Section_UI_Cross_Culvert_Wire"; import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; +import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock"; import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure"; import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom"; @@ -685,36 +686,47 @@ export function createCrossSectionCard( const source = isLinkedCulvert && culvertLink ? culvertLink.source : section; return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null }; }; - const panel = buildStructurePanel( - structurePanelDeps({ - section, - scrollKey: section.station_id, - adjustChainage: () => adjustTarget().chainage_m, - isLinked: isLinkedCulvert, - revetOffset, - inletStructure, - extraWalls, - structureSpan, - revetLink, - adjustOf, - outwardOf, - heightOfWall, - formOfWall, - appliedD: () => culvertAppliedD, - pipeLengthM: () => culvertPipeLengthM, - inletIsBasin: () => culvertInletIsBasin, - hiddenPipe: () => culvertHiddenPipe, - inletOptions: () => culvertInletOptions, - extraState: () => culvertExtraState, - basinExtraState: () => culvertBasinExtraState, - activeRevet: () => activeRevet, - setActiveRevet: (key) => { - activeRevet = key; - }, - toggleRevet, - }), + const panelContext = { + section, + scrollKey: section.station_id, + adjustChainage: () => adjustTarget().chainage_m, + isLinked: isLinkedCulvert, + revetOffset, + inletStructure, + extraWalls, + structureSpan, + revetLink, + adjustOf, + outwardOf, + heightOfWall, + formOfWall, + appliedD: () => culvertAppliedD, + pipeLengthM: () => culvertPipeLengthM, + inletIsBasin: () => culvertInletIsBasin, + hiddenPipe: () => culvertHiddenPipe, + inletOptions: () => culvertInletOptions, + extraState: () => culvertExtraState, + basinExtraState: () => culvertBasinExtraState, + activeRevet: () => activeRevet, + setActiveRevet: (key: RevetKey | null) => { + activeRevet = key; + }, + toggleRevet, + }; + const panel = buildStructurePanel(structurePanelDeps(panelContext)); + // 좌측 [횡단 조정] 사본 — 같은 deps라 어느 쪽 조작이든 카드 재렌더에서 두 창이 + // 함께 새 값으로 그려진다(2026-08-29 일원화). 스크롤 기억 키만 분리한다. + const dockPanel = buildStructurePanel( + structurePanelDeps({ ...panelContext, scrollKey: `dock:${section.station_id}` }), + { dock: true }, ); - showRevetControl = (visible) => panel.show(visible ? activeRevet : null); + showRevetControl = (visible) => { + const key = visible ? activeRevet : null; + panel.show(key); + dockPanel.show(key); + if (key) adoptAdjustPanel(dockPanel.root); + else releaseAdjustPanel(dockPanel.root); + }; showRevetControl(activeRevet !== null); // 세월교 조정창 — 배수관 창과 조작 축이 달라 따로 만든다(2026-08-25 사용자). // 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23). diff --git a/B06_Section/B06_Section_UI_Cross_View_Bodies.ts b/B06_Section/B06_Section_UI_Cross_View_Bodies.ts index b09845c5..d48b917a 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Bodies.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Bodies.ts @@ -24,6 +24,7 @@ import type { import { buildBoxPanel } from "./B06_Section_UI_Cross_Box_Panel"; import type { BoxControl, BoxPanelHandle } from "./B06_Section_UI_Cross_Box_Panel"; import { boxPanelDeps, structureToggle } from "./B06_Section_UI_Cross_View_Box"; +import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock"; export interface BodyWiringDeps { section: CrossSection; @@ -60,39 +61,53 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring { let fordHeights = new Map(); let boxLayout: BoxLayout | null = null; - const fordPanel = + const fordDeps = section.ford && ford - ? buildFordPanel( - fordPanelDeps({ - section, - ford, - heightFor: (role) => fordHeights.get(role) ?? 0, - slabLengthM: () => fordSlabLengthM, - close: () => { - if (fordRole) toggleFord(fordRole); - }, - }), - ) + ? fordPanelDeps({ + section, + ford, + heightFor: (role) => fordHeights.get(role) ?? 0, + slabLengthM: () => fordSlabLengthM, + close: () => { + if (fordRole) toggleFord(fordRole); + }, + }) : null; - const boxPanel = + const boxDeps = section.box && box - ? buildBoxPanel( - boxPanelDeps({ - chainageM: chainage, - box, - layout: () => boxLayout, - close: () => { - if (boxRole) toggleBox(boxRole); - }, - }), - ) + ? boxPanelDeps({ + chainageM: chainage, + box, + layout: () => boxLayout, + close: () => { + if (boxRole) toggleBox(boxRole); + }, + }) : null; + const fordPanel = fordDeps ? buildFordPanel(fordDeps) : null; + const boxPanel = boxDeps ? buildBoxPanel(boxDeps) : null; + // 좌측 [횡단 조정] 사본 — 배수관 조정창과 같은 규칙(2026-08-29 일원화): 같은 deps로 + // 하나 더 만들어 고른 동안만 좌측 슬롯에 세운다. + const fordDock = fordDeps ? buildFordPanel(fordDeps, { dock: true }) : null; + const boxDock = boxDeps ? buildBoxPanel(boxDeps, { dock: true }) : null; + const syncDock = ( + dock: FordPanelHandle | BoxPanelHandle | null, + role: FordWallRole | BoxSideRole | null, + ): void => { + if (!dock) return; + (dock as { show: (role: FordWallRole | BoxSideRole | null) => void }).show(role); + if (role) adoptAdjustPanel(dock.root); + else releaseAdjustPanel(dock.root); + }; const toggleFord = structureToggle({ current: () => fordRole, setCurrent: (value) => (fordRole = value), setActive: (value) => setFordActive(value), - showPanel: (visible) => fordPanel?.show(visible ? fordRole : null), + showPanel: (visible) => { + fordPanel?.show(visible ? fordRole : null); + syncDock(fordDock, visible ? fordRole : null); + }, persist: (value) => ford?.select(chainage, value), isCardSelected: deps.isCardSelected, clearArea: deps.clearArea, @@ -102,7 +117,10 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring { current: () => boxRole, setCurrent: (value) => (boxRole = value), setActive: (value) => setBoxActive(value), - showPanel: (visible) => boxPanel?.show(visible ? boxRole : null), + showPanel: (visible) => { + boxPanel?.show(visible ? boxRole : null); + syncDock(boxDock, visible ? boxRole : null); + }, persist: (value) => box?.select(chainage, value), isCardSelected: deps.isCardSelected, clearArea: deps.clearArea, @@ -111,6 +129,8 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring { fordPanel?.show(fordRole); boxPanel?.show(boxRole); + syncDock(fordDock, fordRole); + syncDock(boxDock, boxRole); return { fordAdjust: () => ford?.adjustFor(chainage), @@ -125,12 +145,14 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring { if (fordRole) setter(fordRole); // 창은 그리기 전에 만들어져 값이 비어 있다 — 계산 결과가 들어온 뒤 다시 렌더한다. fordPanel?.show(fordRole); + syncDock(fordDock, fordRole); }, attachBox: (setter, layout) => { setBoxActive = setter; boxLayout = layout; if (boxRole) setter(boxRole); boxPanel?.show(boxRole); + syncDock(boxDock, boxRole); }, clearSelection: () => { if (fordRole !== null) toggleFord(fordRole); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index a1276877..d30d88a6 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -44,6 +44,10 @@ import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { staleDesignChainages } from "./B06_Section_UI_Section_Common"; import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel"; +import { + createB06StructuresPanel, + wireStructureSelection, +} from "./B06_Section_UI_Page_Structures_Panel"; import "./B06_Section_UI_Style.css"; import "./B06_Section_UI_Style_Cross.css"; import "./B06_Section_UI_Style_Cross_Controls.css"; @@ -115,13 +119,36 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }); confirmButton.disabled = true; const actionRow = document.createElement("div"); - // 사이드 최하단 고정(공용 ui-sidebar-actions) — 스크롤에서 제외(2026-08-05 사용자 지시). - actionRow.className = "b06-profile__actions ui-sidebar-actions"; + actionRow.className = "b06-profile__actions"; actionRow.append(goProfileButton, saveButton, confirmButton); + /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */ + const focusStationAt = (chainageM: number): void => { + const sections = sectionDetail?.cross_sections; + if (!sections?.length) return; + const nearest = sections.reduce((best, entry) => + Math.abs(entry.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM) ? entry : best, + ); + sectionView.focusStation(nearest.station_id); + }; + + // 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화). + // 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행]. + const structuresPanel = createB06StructuresPanel({ + projectId, + stationInterval: () => stationInterval ?? 20, + focusChainage: focusStationAt, + }); + const dockDivider = document.createElement("hr"); + dockDivider.className = "b05-structure__divider"; + const actionDock = document.createElement("div"); + actionDock.className = "b05-route__dock ui-sidebar-actions"; + actionDock.append(structuresPanel.listRoot, dockDivider, actionRow); + const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; - leftForm.append(viewGroup, standardGroup, actionRow); + // 순서: 보기 반폭 → 구조물 배치(표준 횡단면 위 — 2026-08-29 사용자 확정) → 표준. + leftForm.append(viewGroup, structuresPanel.root, standardGroup, actionDock); // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); @@ -437,6 +464,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls.ford, stationControls.box, ); + // 횡단도 벽·구체 선택 → 좌측 「구조물 배치」 폼에 그 시설 로드(2026-08-29 일원화). + wireStructureSelection(stationControls, () => sectionDetail, structuresPanel); // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. const mainArea = document.createElement("div"); @@ -659,6 +688,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { currentRouteId = context.route_id; loadRockOffsets(); stationControls.load(); + // 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. + void structuresPanel.load(); try { const existing = await getSections(projectId, context.route_id); if (!existing.longitudinal) { diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts new file mode 100644 index 00000000..23f4c237 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -0,0 +1,156 @@ +/* ============================================================================= + * B06_Section_UI_Page_Structures_Panel.ts + * B06 좌측 「구조물 배치」 — B05와 **같은 컨테이너·목록 템플릿**을 세운다 + * (2026-08-29 사용자: B05/B06 인터페이스 일원화. 페이지 본체는 700줄 제한으로 + * 여기서 조립만 받아 간다). + * + * · 폼·목록 구현 = 공용 진입점(`A00_Common/b_structures_section`) — B05 그대로. + * · 구조물(B~G군) 추가·수정·삭제는 세션 미저장분(`writePendingStructures`)에 쌓고 + * [임시저장]·[확정]의 `flushPendingStructures`가 정본에 쓴다(캐시→저장 원칙). + * · 계곡 통과 시설(A군, pipe_points 정본)은 **표시·선택만** — 추가·이동·삭제는 + * 배수유역 재분할 체인이 있는 B05 몫이라 안내만 한다(2026-08-29 1차 범위). + * · 목록·폼 선택 → 해당 측점 카드 선택·스크롤. 횡단도 벽·구체 선택 → 폼에 그 시설 + * 로드(소유 측점 기준). 하위 [횡단 조정] 컨테이너는 Adjust_Dock이 맡는다. + * ========================================================================== */ + +import { createStructuresSection, type PipeFacilityItem } from "../A00_Common/b_structures_section"; +import { + fetchStructures, + fetchStructureTypes, + readPendingStructures, + structureAnchorM, + writePendingStructures, + type StructureInstance, +} from "../B05_Profile/B05_Profile_Api_Structures"; +import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { showToast } from "@ui/ui_template_elements"; +import { adjustDockRoot, resetAdjustDock } from "./B06_Section_UI_Adjust_Dock"; +import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; + +const PIPE_GUIDE = "계곡 통과 시설의 추가·이동·삭제는 B05(종단) 화면에서 합니다."; + +export interface B06StructuresPanelDeps { + projectId: string | null; + /** 측점 간격(m) — 측점번호+잔여거리 환산용. */ + stationInterval: () => number; + /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */ + focusChainage: (chainageM: number) => void; +} + +export interface B06StructuresPanel { + /** 「구조물 배치」 섹션(하위 [횡단 조정] 포함) — 좌측 패널에 붙인다. */ + root: HTMLElement; + /** 배치된 구조물 목록 — 하단 고정 dock에 붙인다(B05와 같은 자리). */ + listRoot: HTMLElement; + /** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */ + load: () => Promise; + /** 횡단도에서 고른 벽·구체의 시설을 폼에 올린다(null = 해제). */ + showPipeAt: (chainageM: number | null) => void; +} + +export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06StructuresPanel { + let structures: StructureInstance[] = []; + + /** 새 항목에 식별자를 미리 붙인다 — 저장 전에도 목록에서 고르고 지울 수 있어야 + * 하고, 서버는 빈 값일 때만 새로 발급한다(B05 다리와 같은 규칙). */ + const withLocalIds = (next: StructureInstance[]): StructureInstance[] => + next.map((item) => + item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") }, + ); + + const section = createStructuresSection({ + onChange: (next) => { + structures = withLocalIds(next); + section.setStructures(structures); + if (deps.projectId) writePendingStructures(deps.projectId, structures); + }, + onSelect: (structure) => { + if (structure) deps.focusChainage(structureAnchorM(structure)); + }, + getInterval: deps.stationInterval, + onPipeAdd: () => showToast(PIPE_GUIDE, "error"), + onPipeUpdate: () => showToast(PIPE_GUIDE, "error"), + onPipeRemove: () => showToast(PIPE_GUIDE, "error"), + onPipeSelect: (chainageM) => { + if (chainageM !== null) deps.focusChainage(chainageM); + }, + }); + + // 하위 [횡단 조정] 컨테이너 — 폼 본문 맨 아래, 기존 항목과 경계 구분(2026-08-29 + // 사용자: 병합 항목은 하위 컨테이너로 담아 혼동 방지). + resetAdjustDock(); + section.root.querySelector(".b05-route__panel-body")?.append(adjustDockRoot()); + + async function load(): Promise { + if (!deps.projectId) return; + const projectId = deps.projectId; + try { + const [types, stored, pipeResponse] = await Promise.all([ + fetchStructureTypes(), + fetchStructures(projectId), + fetchDetailPipePoints(projectId), + ]); + section.setTypes(types); + // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙). + structures = readPendingStructures(projectId) ?? stored.structures; + section.setStructures(structures); + const pipes: PipeFacilityItem[] = 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, + options: pipe.options, + design_flow_m3s: null, + })); + section.setPipeFacilities(pipes); + } catch (error) { + showToast( + error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.", + "error", + ); + } + } + + return { + root: section.root, + listRoot: section.listRoot, + load, + showPipeAt: (chainageM) => section.selectPipeByChainage(chainageM), + }; +} + +/** + * 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그 + * 구조물의 **소유 측점** 시설을 좌측 폼에 올린다(2026-08-29 일원화 3단계). + * 제어 객체의 select를 감싸기만 한다 — 조작·저장 경로는 그대로다. + */ +export function wireStructureSelection( + stationControls: StationControls, + detail: () => SectionDetailResponse | null, + panel: B06StructuresPanel, +): 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; + }; + + const origRevet = stationControls.revetOffset.select; + stationControls.revetOffset.select = (chainageM, key) => { + origRevet(chainageM, key); + panel.showPipeAt(key ? ownerChainageOf(chainageM) : null); + }; + const origFord = stationControls.ford.select; + stationControls.ford.select = (chainageM, role) => { + origFord(chainageM, role); + panel.showPipeAt(role ? chainageM : null); + }; + const origBox = stationControls.box.select; + stationControls.box.select = (chainageM, role) => { + origBox(chainageM, role); + panel.showPipeAt(role ? chainageM : null); + }; +} diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index bbd85b5c..fd0cbe01 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -112,6 +112,9 @@ export interface SectionViewController { ) => void; /** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */ refreshCard: (chainageM: number) => void; + /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 + * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */ + focusStation: (stationId: string) => void; clear: () => void; dispose: () => void; } @@ -701,6 +704,10 @@ export function createSectionView( if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root)); }, refreshCard, + focusStation(stationId) { + if (selectedStationId !== stationId) selectStation(stationId, true); + else revealCard(stationId, "smooth"); + }, clear() { currentDetail = null; selectedStationId = null; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index 9a66455a..6294c3b8 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -227,3 +227,45 @@ color: var(--color-text); background: var(--color-surface); } + +/* ── 좌측 [횡단 조정] 사본(2026-08-29 B05/B06 일원화) ───────────────────── + * 오버레이 창과 같은 DOM을 좌측 사이드바에 흐름 배치로 세운다. */ +.b06-structure-panel--dock { + position: static; + z-index: auto; + max-height: none; + margin-top: var(--spacing-8); + border-color: var(--color-border); + overflow: visible; +} + +/* 9키·십자 위치제어(이동 D-pad·집수정 9키)는 횡단도 오버레이 전용 + * (2026-08-29 사용자 지시 7) — 사본에서는 그 행을 통째로 숨긴다. */ +.b06-structure-panel--dock + .b06-structure-panel__struct:has(> .b06-structure-panel__controls.b06-structure-panel__buttons), +.b06-structure-panel--dock + .b06-structure-panel__struct:has( + > .b06-structure-panel__controls.b06-structure-panel__basin-buttons + ) { + display: none; +} + +/* [횡단 조정] 하위 컨테이너 — 구조물 배치 폼과 경계 구분(접이식). */ +.b06-adjust-dock { + margin-top: var(--spacing-8); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.b06-adjust-dock > summary { + color: var(--color-text-body); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + cursor: pointer; +} + +.b06-adjust-dock__empty { + margin: var(--spacing-8) 0 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +}