Files
Aislo/B06_Section/B06_Section_UI_Page_Structures_Panel.ts
T
eomsangdon 45c6f9aa07 feat(B06): 기준 측점 이동을 B06 구조물 배치에서도 한다
2026-08-29 사용자: 구조물 배치 컨테이너를 B06으로 가져온 이유가 이것이다 —
폼의 기준 측점 숫자를 바꾸면 관이 옮겨져야 한다.

- 관 저장기에 이동 예약(queueMove)을 더했다. 세션 키 b06:culvertmove에 담고,
  [저장]·[확정]의 flush가 옵션 병합과 함께 pipe_points에 반영한다. 세부 배수유역
  재분할은 서버가 관 목록을 다시 받아 처리한다.
- 폼에서 측점을 옮기면 목록·폼 캐시가 즉시 새 자리를 따르고, 옵션은 옮기기 전
  자리로 예약한다(저장 시점 관 목록이 그 자리 기준).
- 이동 안내는 차단 문구에서 '저장 뒤 다시 계산' 알림으로 바꿨다.
2026-08-29 16:53:17 +09:00

296 lines
14 KiB
TypeScript

/* =============================================================================
* 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, 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";
const PIPE_ADD_GUIDE = "계곡 통과 시설의 추가·삭제는 B05(종단) 화면에서 합니다.";
const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다.";
const PIPE_MOVE_NOTICE =
"기준 측점을 옮겼습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
export interface B06StructuresPanelDeps {
projectId: string | null;
/** 측점 간격(m) — 측점번호+잔여거리 환산용. */
stationInterval: () => number;
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */
focusChainage: (chainageM: number) => void;
/** 폼 [수정]으로 바뀐 관 옵션을 캐시에 예약한다 — [저장]·[확정]이 정본에 쓴다
* (2026-08-29: 조정창 구간값과 같은 경로). 없으면 안내만 한다. */
queuePipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
/** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 —
* 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */
applyPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
/** 기준 측점 이동을 예약한다 — 세부 배수유역 재분할은 [저장]·[확정] 때 서버가
* 관 목록을 다시 받아 처리한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다). */
movePipe?: (fromChainageM: number, toChainageM: number) => void;
}
export interface B06StructuresPanel {
/** 「구조물 배치」 섹션(하위 [횡단 조정] 포함) — 좌측 패널에 붙인다. */
root: HTMLElement;
/** 배치된 구조물 목록 — 하단 고정 dock에 붙인다(B05와 같은 자리). */
listRoot: HTMLElement;
/** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */
load: () => Promise<void>;
/** 횡단도에서 고른 벽·구체의 시설을 폼에 올린다(null = 해제). */
showPipeAt: (chainageM: number | null) => void;
/** 고른 횡단도(측점)에 있는 구조물을 폼에 올린다 — 계곡 통과 시설이 먼저고,
* 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). */
showAtChainage: (chainageM: number | null) => void;
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */
currentChainage: () => number | null;
/** 유입구 "구조"에 합쳐진 B06 유입측 형식 — 조정창 값과 맞춘다(2026-08-29 지시 5). */
facility: {
setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void;
onInletStructureChange: (handler: (value: "auto" | "revet" | "I" | "L" | "U") => void) => void;
};
}
/** 관 매칭 허용 오차(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다(B05와 같은 기준). */
const PIPE_MATCH_M = 0.51;
/** 이 구조물이 그 측점을 덮는가 — 점형은 근사 일치, 구간형은 시작~종료 안. */
function coversChainage(structure: StructureInstance, chainageM: number): boolean {
if (structure.placement === "interval" && structure.start_m != null && structure.end_m != null) {
return chainageM >= structure.start_m - 0.01 && chainageM <= structure.end_m + 0.01;
}
return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M;
}
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, "") },
);
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_ADD_GUIDE, "error"),
// 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 →
// [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다.
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
// 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는
// 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M;
if (moved) {
if (deps.movePipe) {
deps.movePipe(fromChainageM, toChainageM);
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.chainage_m = toChainageM;
currentChainageM = toChainageM;
section.setPipeFacilities(pipeFacilities);
showToast(PIPE_MOVE_NOTICE, "success");
} else {
showToast(PIPE_MOVE_GUIDE, "error");
}
}
const patch = attributes.options;
if (!patch || !Object.keys(patch).length) return;
if (!deps.queuePipeOptions) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
// 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다.
deps.queuePipeOptions(fromChainageM, patch as Record<string, number | string>);
deps.applyPipeOptions?.(fromChainageM, patch as Record<string, number | string>);
// 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다.
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.options = { ...(hit.options ?? {}), ...patch };
section.setPipeFacilities(pipeFacilities);
},
onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"),
onPipeSelect: (chainageM) => {
if (chainageM !== null) deps.focusChainage(chainageM);
},
});
// 하위 [횡단 조정] 컨테이너 — 폼 본문 맨 아래, 기존 항목과 경계 구분(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 });
async function load(): Promise<void> {
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);
pipeFacilities = 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(pipeFacilities);
} catch (error) {
showToast(
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
"error",
);
}
}
return {
root: section.root,
listRoot: section.listRoot,
load,
currentChainage: () => currentChainageM,
facility: {
setInletStructure: (value) => section.facility.setInletStructure(value),
onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler),
},
showPipeAt: (chainageM) => {
currentChainageM = chainageM;
section.selectPipeByChainage(chainageM);
},
showAtChainage: (chainageM) => {
currentChainageM = null;
if (chainageM === null) {
section.selectById(null);
return;
}
// 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에
// 그려지는 것은 이쪽이다.
const pipe = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
);
if (pipe) {
currentChainageM = pipe.chainage_m;
section.selectPipeByChainage(pipe.chainage_m);
return;
}
const hit = structures.find((entry) => coversChainage(entry, chainageM));
section.selectById(hit?.structure_id ?? null);
},
};
}
/**
* 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·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,
): { 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);
panel.showPipeAt(key ? ownerChainageOf(chainageM) : null);
if (key) syncInletStructure();
};
const origFord = stationControls.ford.select;
stationControls.ford.select = (chainageM, role) => {
markWallSelecting();
origFord(chainageM, role);
panel.showPipeAt(role ? chainageM : null);
};
const origBox = stationControls.box.select;
stationControls.box.select = (chainageM, role) => {
markWallSelecting();
origBox(chainageM, role);
panel.showPipeAt(role ? chainageM : null);
};
return { syncInletStructure };
}