diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 09e31e10..659c5c27 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -160,7 +160,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu // 자리에서, 같은 흐름(종류 선택 → 옵션 → 추가/수정)으로 편집한다(2026-08-17 // 사용자 지시 — 자동·수동 배관은 같은 구조물, 별도 UI 금지). 기슭막이 길이를 // 넣으면 시작·종료 측점이 기준 − 앞 ~ 기준 + 뒤로 자동 채워진다. - const facilityOptions = createFacilityOptionsForm(); + const facilityOptions = createFacilityOptionsForm({ onChange: () => liveCommit() }); /** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신 * 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션 @@ -433,6 +433,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu } const label = option.unit ? `${option.label} (${option.unit})` : option.label; optionRow.append(field(label, input)); + input.addEventListener("change", () => liveCommit()); optionInputs.push({ key: option.key, required: !!option.required, @@ -498,6 +499,24 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu if (hadPipe) callbacks.onPipeSelect(null); } + /** 정본 주입(setStructures) 되먹임 중인가 — 실시간 반영으로 목록이 돌아올 때 + * 폼을 리셋하지 않기 위한 표시(2026-08-29 사용자 지시 2). */ + let applyingLive = false; + + /** + * 실시간 반영 — 이미 고른 항목(구조물·계곡 통과 시설)을 고치는 중이면 값이 바뀔 + * 때마다 곧바로 반영한다(2026-08-29 사용자 지시 2). 새 항목은 종전대로 [추가]로만 + * 확정한다 — 입력이 끝나기 전에 반쪽짜리 항목이 생기면 안 된다. + */ + function liveCommit(): void { + if (!editingId && selectedPipeChainage === null) return; + if (tempPipeChainage !== null) return; // 임시 배치는 [추가]가 확정한다 + applyingLive = true; + void Promise.resolve(commit(true)).finally(() => { + applyingLive = false; + }); + } + function syncButtons(): void { primary.textContent = tempPipeChainage !== null @@ -507,6 +526,13 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu : "추가"; removeButton.disabled = editingId === null && selectedPipeChainage === null && tempPipeChainage === null; + // 고치는 중에는 [수정] 버튼을 쓰지 않는다(실시간 반영) — 자리는 남겨 삭제·리셋의 + // 위치·크기가 흔들리지 않게 한다(2026-08-29 사용자 지시 2). + const editing = + (editingId !== null || selectedPipeChainage !== null) && tempPipeChainage === null; + // visibility만 끈다 — 자리는 남고 클릭도 먹지 않는다(disabled는 건드리지 않는다, + // 다른 곳에서 종류 선택 여부로 이미 관리한다). + primary.style.visibility = editing ? "hidden" : "visible"; } function renderList(): void { @@ -650,7 +676,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu callbacks.onChange([...structures]); } - async function commit(): Promise { + async function commit(live = false): Promise { const type = currentType(); if (!type) return; const step = interval(); @@ -677,7 +703,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu } else { callbacks.onPipeAdd(anchor, attributes); } - loadForm(null); + // 실시간 반영 중에는 폼을 닫지 않는다 — 이어서 다른 칸도 고쳐야 한다. + if (!live) loadForm(null); return; } @@ -774,7 +801,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu } else { records.forEach((record) => structures.push({ ...record, structure_id: null })); } - loadForm(null); + if (!live) loadForm(null); emit(); } @@ -889,6 +916,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu } }); + memoField.addEventListener("change", () => liveCommit()); body.insertBefore(field("메모", memoField), actions); syncButtons(); renderList(); @@ -912,8 +940,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu // 서버 정본 주입 — onChange를 울리지 않는다. 울리면 Page가 다시 저장을 걸어 // 저장→재조회→주입→저장의 무한 고리가 된다(변경 알림은 사용자 조작에서만). structures = next.map((entry) => ({ ...entry })); - loadForm(null); - renderList(); + // 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다. + if (applyingLive) renderList(); + else { + loadForm(null); + renderList(); + } }, getStructures: () => [...structures], setPipeFacilities(pipes) { diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index db6d9f9f..79772480 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -21,6 +21,20 @@ export function setAdjustSideSlots(next: { inlet: HTMLElement; outlet: HTMLEleme sideSlots = next; } +/** 지금 붙는 사본이 **자동 선택**(카드를 골라 대표 벽이 딸려 온 경우)인가. + * 자동이면 그룹 강조를 켜지 않는다 — 강조는 도면에서 구조물을 직접 고를 때만 + * 쓴다(2026-08-29 사용자 지시). */ +let autoAdopt = false; + +export function withAutoAdopt(run: () => void): void { + autoAdopt = true; + try { + run(); + } finally { + autoAdopt = false; + } +} + /** 이식 자리가 지금 화면에 서 있는지 — 숨겨진 그룹(다른 시설)이면 옮기지 않는다. */ function slotUsable(target: HTMLElement | undefined): target is HTMLElement { if (!target) return false; @@ -28,10 +42,12 @@ function slotUsable(target: HTMLElement | undefined): target is HTMLElement { return !!group && !group.hidden; } -/** 옮겨 놨던 행을 비운다 — 사본이 갈릴 때마다 옛 행을 남기지 않는다. */ +/** 옮겨 놨던 행을 비운다 — 사본이 갈릴 때마다 옛 행을 남기지 않는다. 그룹 강조도 끈다. */ function clearSideSlots(): void { - sideSlots?.inlet.replaceChildren(); - sideSlots?.outlet.replaceChildren(); + for (const target of [sideSlots?.inlet, sideSlots?.outlet]) { + target?.replaceChildren(); + target?.closest("fieldset")?.classList.remove("is-active"); + } } /** @@ -51,6 +67,8 @@ function relocateSideRows(panelRoot: HTMLElement): void { if (row.classList.contains("is-hidden")) return; target.append(row); }); + // 도면에서 직접 고른 구조물만 그룹 테두리로 알린다(2026-08-29 사용자 지시 3). + if (!autoAdopt) target.closest("fieldset")?.classList.add("is-active"); } function ensureDock(): void { diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index b1b725f6..f96a600a 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -48,6 +48,7 @@ import { createB06StructuresPanel, wireStructureSelection, } from "./B06_Section_UI_Page_Structures_Panel"; +import { withAutoAdopt } from "./B06_Section_UI_Adjust_Dock"; import "./B06_Section_UI_Style.css"; import "./B06_Section_UI_Style_Cross.css"; import "./B06_Section_UI_Style_Cross_Controls.css"; @@ -470,6 +471,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls, () => sectionDetail, structuresPanel, + (chainageM) => sectionView.refreshCard(chainageM), ); // 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자). // 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다. @@ -498,8 +500,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (stationControls.revetOffset.selectedFor(section)) return; const key = culvert.outlet ? "outlet" : culvert.inlet ? "inlet" : null; if (!key) return; - stationControls.revetOffset.select(section.chainage_m, key); - sectionView.refreshCard(section.chainage_m); + // 자동으로 딸려 온 선택이라 그룹 강조는 켜지 않는다(2026-08-29 사용자 지시). + withAutoAdopt(() => { + stationControls.revetOffset.select(section.chainage_m, key); + sectionView.refreshCard(section.chainage_m); + }); } // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 679e1f7d..6645ee5a 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -24,7 +24,12 @@ import { } 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, setAdjustSideSlots } from "./B06_Section_UI_Adjust_Dock"; +import { + adjustDockRoot, + resetAdjustDock, + setAdjustSideSlots, + withAutoAdopt, +} from "./B06_Section_UI_Adjust_Dock"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; @@ -118,7 +123,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ); if (hit) hit.options = { ...(hit.options ?? {}), ...patch }; section.setPipeFacilities(pipeFacilities); - showToast("수정한 값은 [저장]·[확정] 때 반영됩니다.", "success"); }, onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"), onPipeSelect: (chainageM) => { @@ -208,6 +212,8 @@ export function wireStructureSelection( stationControls: StationControls, detail: () => SectionDetailResponse | null, panel: B06StructuresPanel, + /** 대표 벽으로 되돌릴 때 카드를 다시 그린다 — 없으면 되돌리지 않는다. */ + refreshCard?: (chainageM: number) => void, ): { syncInletStructure: () => void } { const ownerChainageOf = (chainageM: number): number => { const sectionAt = detail()?.cross_sections.find( @@ -235,7 +241,26 @@ export function wireStructureSelection( const origRevet = stationControls.revetOffset.select; stationControls.revetOffset.select = (chainageM, key) => { origRevet(chainageM, key); - panel.showPipeAt(key ? ownerChainageOf(chainageM) : null); + if (key) { + panel.showPipeAt(ownerChainageOf(chainageM)); + syncInletStructure(); + return; + } + // 선택을 풀어도 폼의 단 수·옵션 행은 남아야 한다 — 그 행들은 벽에 딸려 있으므로 + // 대표 벽으로 조용히 되돌린다(강조만 꺼진다, 2026-08-29 사용자 지시). + const target = detail()?.cross_sections.find( + (entry) => Math.abs(entry.chainage_m - chainageM) < 0.01, + ); + const fallback = target?.culvert ? (target.culvert.outlet ? "outlet" : "inlet") : null; + if (!fallback || !refreshCard) { + panel.showPipeAt(null); + return; + } + withAutoAdopt(() => { + origRevet(chainageM, fallback); + refreshCard(chainageM); + }); + panel.showPipeAt(ownerChainageOf(chainageM)); syncInletStructure(); }; const origFord = stationControls.ford.select; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index 93334062..0d11feb4 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -303,3 +303,87 @@ flex-direction: column; gap: 6px; } + +/* 유입구·유출구 그룹으로 옮겨 온 행(단 수·옵션) — 폼의 다른 항목과 같은 표기로 + * 맞추고 2열로 세운다(2026-08-29 사용자 지시 1). 조정창 안에서 쓰던 어두운 행 + * 양식은 여기서 벗는다. */ +.b05-structure__adjust-slot { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.b05-structure__adjust-slot .b06-structure-panel__struct { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.b05-structure__adjust-slot .b06-structure-panel__label { + color: var(--color-text-body); + font-size: var(--text-caption); + opacity: 1; +} + +/* 단 수 [-][값][+]도 폼의 숫자 조정칸과 같은 한 덩어리로 보이게 한다. */ +.b05-structure__adjust-slot .b06-structure-panel__controls { + display: flex; + overflow: hidden; + min-width: 0; + align-items: stretch; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + gap: 0; +} + +.b05-structure__adjust-slot .b06-structure-panel__controls .b06-structure-panel__btn { + width: 28px; + height: auto; + min-height: 24px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--color-text-body); +} + +.b05-structure__adjust-slot .b06-structure-panel__hval { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + justify-content: center; + border-right: 1px solid var(--color-border); + border-left: 1px solid var(--color-border); + color: var(--color-text); + font-size: var(--text-caption); +} + +/* 옵션 토글 둘은 한 덩어리 안에서 반씩 나눠 쓴다. */ +.b05-structure__adjust-slot + .b06-structure-panel__controls.b06-structure-panel__toggles + .b06-structure-panel__toggle { + width: auto; + flex: 1 1 0; + padding: 0; +} + +.b05-structure__adjust-slot + .b06-structure-panel__controls.b06-structure-panel__toggles + .b06-structure-panel__toggle + + .b06-structure-panel__toggle { + border-left: 1px solid var(--color-border); +} + +/* 도면에서 고른 구조물 쪽 그룹 강조(2026-08-29 사용자 지시) — 조정창 테두리와 같은 + 붉은 계열로 맞춰 "지금 만지는 구조물"이 한 색으로 읽히게 한다. */ +.b05-structure__group.is-active { + border-color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 8%, transparent); +} + +.b05-structure__group.is-active > legend { + color: var(--color-danger); + opacity: 1; +}