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:
@@ -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,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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user