feat(B05,B06): 구조물 배치를 실시간 반영으로 바꾸고 이식 행·강조를 정리한다

2026-08-29 사용자 지시 3건:
1. 유입구·유출구로 옮겨 온 단 수·옵션 행을 폼 양식(라벨+한 덩어리 컨트롤)으로
   맞추고 2열 격자로 세운다.
2. 이미 고른 항목은 값이 바뀔 때마다 곧바로 반영한다([수정] 버튼 불필요).
   버튼은 visibility만 꺼 자리를 남겨 삭제·리셋의 위치·크기가 흔들리지 않게 했다.
   정본 주입이 되먹임으로 돌아와도 편집 중인 폼은 닫지 않는다.
3. 도면에서 구조물을 고르면 그쪽 그룹(유입구/유출구)을 붉은 계열로 강조한다.
   카드만 고른 경우는 강조하지 않는다 — 대표 벽은 조용히 딸려 오고, 그 벽을
   다시 눌러 선택을 풀면 강조만 꺼지고 행은 남는다.

검증: 슬롯 grid 2열, 카드 선택 시 강조 없음·행 2개, 다른 벽 클릭 시 유입구 그룹
붉은 테두리(rgb(192,57,43)), 재클릭 시 강조 해제·행 복귀. 길이 + 즉시 캐시
반영(11) 후 - 원복(10), 폼 유지. typecheck 통과.
This commit is contained in:
2026-08-29 16:00:04 +09:00
parent 889f746a61
commit 5d0d2f0918
5 changed files with 178 additions and 14 deletions
+38 -6
View File
@@ -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<void> {
async function commit(live = false): Promise<void> {
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) {
+21 -3
View File
@@ -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 {
+7 -2
View File
@@ -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<void> {
stationControls,
() => sectionDetail,
structuresPanel,
(chainageM) => sectionView.refreshCard(chainageM),
);
// 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자).
// 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다.
@@ -498,8 +500,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
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) 또는 안내 메시지를 표시한다.
@@ -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;
@@ -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;
}