Files
Aislo/B06_Section/B06_Section_UI_Page_Structures_Panel.ts
T
eomsangdonandClaude Opus 5 d5c5fe7b40 fix(b06): 구조물 패널이 스펙 기본값을 관 옵션에 채우지 않음 — 조작·저장분만(폼 ④)
- 페이지를 열기만 해도 관종·관경·구조·벽 길이·전/후·집수정 길이 기본값이 옵션에 박혀 임시 저장·[저장]에 확정으로 굳던 자리
- 벽 형태·높이는 조정창에서 조작했을 때만 싣음(앞 조각 c4eccee5 auto 커밋에 먼저 쓸려 올라감)
- 세월교·BOX: 저장돼 있던 칸이거나 조정창 조작으로 등록부 기본값과 달라진 칸만 스펙 값을 싣음
- ORCA 936be972 B06: 관 폼 관종 「안 정함」 · 저장된 관경 1000 만 · 벽 칸 빔+제안 · 임시 저장에 기본값 안 생김

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:34:31 +09:00

675 lines
32 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,
pipesToStructureMarks,
structureAnchorM,
writePendingStructures,
type StructureInstance,
type StructureType,
} from "../B05_Profile/B05_Profile_Api_Structures";
import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
readStructurePick,
writeStructurePick,
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { showToast } from "@ui/ui_template_elements";
import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend";
import {
adjustDockRoot,
refreshAdjustSlots,
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 = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요.";
const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다.";
/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */
const PIPE_ADD_NOTICE =
"계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
const PIPE_REMOVE_NOTICE =
"계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
/** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */
const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다.";
const PIPE_MOVE_NOTICE =
"기준 측점을 옮겼습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
export interface B06StructuresPanelDeps {
projectId: string | null;
/** 횡단 캐시 — 폼 기본값을 조정창과 **같은 원천**(culvert 스펙)에서 읽는다
* (2026-08-29 사용자: 예전 조정창이 보여주던 값이 기본값이다). */
detail?: () => SectionDetailResponse | null;
/** 지금 그려진 벽 높이(m) — 배관 벽은 스펙이 아니라 기하가 정하므로 폼 기본
* 높이도 이 값을 쓴다(조정창 표시값과 같다). */
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null;
/** 지금 그려진 벽 형태 — 조정값(m)이 있으면 그쪽이 정본이라 폼도 이 값을 보인다. */
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null;
/** 그 측점에서 유입·유출 벽이 각각 좌·우 어느 쪽인가(독립 기슭막이 칸 이름표). */
wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null;
/** 측점 간격(m) — 측점번호+잔여거리 환산용. */
stationInterval: () => number;
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */
focusChainage: (chainageM: number) => void;
/** 바깥(횡단도·3D 넘김값)에서 고른 것이 폼에 실릴 때 — 접힌 좌측 패널을 펼친다. */
reveal?: () => void;
/** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */
onMarks?: (structures: StructureInstance[], types: StructureType[]) => 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;
/** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로**
* 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */
onStructuresChanged?: () => void;
}
export interface B06StructuresPanel {
/** 「구조물 배치」 섹션(하위 [횡단 조정] 포함) — 좌측 패널에 붙인다. */
root: HTMLElement;
/** 배치된 구조물 목록 — 하단 고정 dock에 붙인다(B05와 같은 자리). */
listRoot: HTMLElement;
/** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */
load: () => Promise<void>;
/** 횡단도에서 고른 벽·구체의 시설을 폼에 올린다(null = 해제). */
showPipeAt: (chainageM: number | null) => void;
/** 기하가 한계로 자른 실제 값을 폼에 되돌린다 — 입력 숫자만 커지고 그림은 그대로인
* 상태를 막는다(2026-08-29 사용자: 재질 높이 한계에 걸린 값). */
overrideOptions: (chainageM: number, patch: Record<string, string | number>) => void;
/** 고른 횡단도(측점)에 있는 구조물을 폼에 올린다 — 계곡 통과 시설이 먼저고,
* 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). */
showAtChainage: (chainageM: number | null) => void;
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */
currentChainage: () => number | null;
/** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */
addStructureAt: (chainageM: number, typeId: string) => void;
removeStructureById: (structureId: string) => boolean;
addPipeAt: (chainageM: number) => void;
removePipeAt: (chainageM: number) => void;
movePipeTo: (fromChainageM: number, toChainageM: number) => void;
moveStructureTo: (structureId: string, toChainageM: number) => boolean;
/** 유입구 "구조"에 합쳐진 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;
}
type Merged = Record<string, string | number>;
/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */
function registryDefaults(types: StructureType[], typeId: string): Record<string, unknown> {
const options = types.find((type) => type.type_id === typeId)?.options ?? [];
return Object.fromEntries(options.map((option) => [option.key, option.default]));
}
/**
* 스펙 값 한 칸을 옵션에 싣는 규칙 — **저장돼 있던 칸이거나, 조작해 기본값과 달라진 칸만.**
* 기본값에서 온 값을 실으면 페이지를 열기만 해도 임시 저장·[저장]에 기본값이 확정으로 굳음
* (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 바꾸지 않는다」). 폼은 빈 칸에 제안을 보임.
*/
function keepSpec(
merged: Merged,
stored: Merged | undefined,
key: string,
spec: string | number | null | undefined,
fromDefault: unknown,
): void {
if (spec === null || spec === undefined) return;
if (stored?.[key] !== undefined || String(spec) !== String(fromDefault ?? "")) merged[key] = spec;
}
type WingSpec = {
installed: boolean;
height_m: number | null;
length_m: number | null;
angle_deg: number | null;
};
function keepWings(
merged: Merged,
stored: Merged | undefined,
defaults: Record<string, unknown>,
wings: ReadonlyArray<readonly [WingSpec, "wing_in" | "wing_out"]>,
): void {
for (const [wing, prefix] of wings) {
// 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45.
const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음";
keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install);
keepSpec(
merged,
stored,
`${prefix}_height_m`,
wing.height_m,
defaults[`${prefix}_height_m`] ?? 0,
);
keepSpec(
merged,
stored,
`${prefix}_length_m`,
wing.length_m,
defaults[`${prefix}_length_m`] ?? 0,
);
keepSpec(
merged,
stored,
`${prefix}_angle_deg`,
wing.angle_deg,
defaults[`${prefix}_angle_deg`] ?? 45,
);
}
}
/**
* 세월교 폼 옵션 — **지금 그려진 스펙**(`section.ford`) 중 조작·저장분만 싣는다. 조정창 조작이
* 스펙을 제자리에서 고치므로 그 값은 폼에 보여야 함(2026-08-30 사용자: 수량(련) 칸이 비고 조정창은
* 1련) — 다만 1련이 **기본값에서 온 값이면** 제안으로만(판정 ④).
*/
function withFordSpec(
options: Merged | undefined,
ford: NonNullable<SectionDetailResponse["cross_sections"][number]["ford"]>,
defaults: Record<string, unknown>,
): Merged {
const merged: Merged = { ...(options ?? {}) };
keepSpec(merged, options, "pipe_kind", ford.pipe_kind, defaults.pipe_kind);
const diameter = Math.round(ford.diameter_m * 1000);
keepSpec(merged, options, "pipe_diameter_mm", diameter, defaults.pipe_diameter_mm ?? 1000);
const count = Number(defaults.pipe_count ?? 0);
keepSpec(
merged,
options,
"pipe_count",
ford.pipe_count,
count > 0 ? Math.max(Math.trunc(count), 1) : 1,
);
keepSpec(
merged,
options,
"ford_width_m",
ford.span_m,
defaults.ford_width_m ?? FORD_BRIDGE_DEFAULT_WIDTH_M,
);
keepWings(merged, options, defaults, [
[ford.wing_in, "wing_in"],
[ford.wing_out, "wing_out"],
]);
return merged;
}
/** BOX암거 폼 옵션 — 세월교와 같은 규칙(조작·저장분만). 구체 길이·표고는 조정 채널이라 여기 안 옴. */
function withBoxSpec(
options: Merged | undefined,
box: NonNullable<SectionDetailResponse["cross_sections"][number]["box"]>,
defaults: Record<string, unknown>,
): Merged {
const merged: Merged = { ...(options ?? {}) };
keepSpec(merged, options, "body_width_m", box.inner_width_m, defaults.body_width_m ?? 2);
keepSpec(merged, options, "body_height_m", box.inner_height_m, defaults.body_height_m ?? 2);
keepWings(merged, options, defaults, [
[box.wing_in, "wing_in"],
[box.wing_out, "wing_out"],
]);
return merged;
}
/**
* 관 지점 옵션에 **조정창에서 조작한 값**만 싣는다 — 조정창과 폼이 어긋나지 않게(2026-08-29 사용자).
* ⚠ 스펙의 기본값(관종·관경·구조·벽 길이·전/후·집수정 길이)은 **안 실음** — 페이지를 열기만 해도
* 임시 저장에 기본값이 확정으로 굳던 자리(2026-09-14 브레인 판정 ④). 폼 빈 칸은 제안을 보임.
*/
function withSpecDefaults(
options: Merged | undefined,
detail: SectionDetailResponse | null,
chainageM: number,
types: StructureType[],
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null,
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null,
): Merged | undefined {
const owner = detail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
);
// 세월교·BOX는 폼과 조정창이 **같은 캐시**를 봄 — 조작한 값은 저장된 옵션보다 앞섬(2026-08-30).
if (owner?.ford) return withFordSpec(options, owner.ford, registryDefaults(types, "ford_bridge"));
if (owner?.box) return withBoxSpec(options, owner.box, registryDefaults(types, "box_culvert"));
const culvert = owner?.culvert;
if (!culvert) return options;
const merged: Merged = { ...(options ?? {}) };
for (const prefix of ["inlet", "outlet"] as const) {
// 형태·높이는 **조작한 값**이 이긴다 — 조정값이 저장된 옵션보다 앞서기 때문(2026-08-30 사용자:
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). 조작이 없으면 부르는 쪽이 null 을 줌.
const drawnForm = wallForm?.(chainageM, prefix);
if (drawnForm) merged[`${prefix}_revet_form`] = drawnForm;
const drawnHeight = wallHeight?.(chainageM, prefix);
if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1));
}
return merged;
}
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, "") },
);
let markTypes: StructureType[] = [];
/** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */
const pushMarks = (): void =>
deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes);
const section = createStructuresSection(
{
onChange: (next) => {
structures = withLocalIds(next);
section.setStructures(structures);
pushMarks();
if (deps.projectId) writePendingStructures(deps.projectId, structures);
deps.onStructuresChanged?.();
},
onSelect: (structure) => {
if (structure) deps.focusChainage(structureAnchorM(structure));
},
getInterval: deps.stationInterval,
onReveal: () => deps.reveal?.(),
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
onPipeAdd: (chainageM, attributes) => {
if (!deps.projectId) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
const at = Number(chainageM.toFixed(2));
if (pipeFacilities.some((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M)) {
showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error");
return;
}
pipeFacilities.push({
chainage_m: at,
facility: attributes.facility ?? "pipe",
source: "user",
options: attributes.options ?? {},
});
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
currentChainageM = at;
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
deps.onStructuresChanged?.();
showToast(PIPE_ADD_NOTICE, "success");
},
// 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다
// (2026-09-12 일원화). 재분할은 [저장]·[확정] 뒤 서버 몫이다.
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
// 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는
// 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M)
movePipeTo(fromChainageM, toChainageM);
const patch = attributes.options;
if (!patch || !Object.keys(patch).length) return;
if (!deps.queuePipeOptions) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
// 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야
// 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게).
// 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다.
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 };
persistPipes();
section.setPipeFacilities(pipeFacilities);
},
onPipeRemove: (chainageM) => removePipeAt(chainageM),
onPipeSelect: (chainageM) => {
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
writeStructurePick(deps.projectId, chainageM);
if (chainageM !== null) deps.focusChainage(chainageM);
},
},
{ includeDetail: true },
);
// 고른 것 없이 좌측 폼을 만지면 값이 아무 데도 가지 않는다(패널 실시간 반영이
// 선택된 항목에만 걸린다) — 조용히 무시되던 자리라 이유를 알린다(2026-08-30 사용자:
// "형태에 따라 모양을 넣어주는 기능이 누락되는 경우가 있어"의 원인).
section.facility.root.addEventListener("change", () => {
if (!section.hasSelection()) showToast(PIPE_PICK_GUIDE, "error");
});
// 하위 [횡단 조정] 컨테이너 — 폼 본문 맨 아래, 기존 항목과 경계 구분(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,
extra: section.facility.extraSlot,
revetInlet: section.facility.revetInletSlot,
revetOutlet: section.facility.revetOutletSlot,
wingInlet: section.facility.wingInSlot,
wingOutlet: section.facility.wingOutSlot,
});
async function load(): Promise<void> {
if (!deps.projectId) return;
const projectId = deps.projectId;
try {
const [types, stored, pipeResponse] = await Promise.all([
// B06 은 **구조물 전체**를 넣을 수 있어야 한다(2026-09-07 사용자 지시 5). B05 목록에서
// 빠져 있던 B군 종단배수·F군 생태/녹화도 여기서는 고를 수 있다 — 레지스트리 주석이
// 「B06 개별 횡단도 옵션으로 재사용」이라 적어 둔 그 자리다.
fetchStructureTypes(true),
fetchStructures(projectId),
fetchDetailPipePoints(projectId),
]);
section.setTypes(types);
markTypes = types;
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙).
structures = readPendingStructures(projectId) ?? stored.structures;
section.setStructures(structures);
const detail = deps.detail?.() ?? null;
// 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며,
// 이래야 B05 에서 넣고 B06 으로 넘어와도 같은 목록이 보인다(2026-09-12 사용자).
const pendingPipes = readPendingPipes(projectId);
pipeFacilities = (pendingPipes ?? 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: withSpecDefaults(
pipe.options,
detail,
pipe.chainage_m,
types,
deps.wallHeight,
deps.wallForm,
),
// 담당 유역의 설계유량 — 물넘이·세월교 개략 단면(필요 수심 = 월류 높이
// 자동값)이 이 값에서 나온다. B05와 같은 규칙으로 유역을 측점으로 짝짓는다
// (2026-08-30 사용자: 월류 폭을 넣어도 높이가 자동 계산되지 않았다).
design_flow_m3s:
pipeResponse.basins.find(
(basin) => Math.abs(basin.chainage_m - pipe.chainage_m) < PIPE_MATCH_M,
)?.design_flow_m3s ?? null,
}));
section.setPipeFacilities(pipeFacilities);
// 종단 알약 레인 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합쳐 B05 와 같은 표기로.
pushMarks();
// 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다
// (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도
// 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다.
const picked = currentChainageM ?? readStructurePick(deps.projectId)?.at ?? null;
if (picked !== null) section.selectPipeByChainage(picked);
} catch (error) {
showToast(
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
"error",
);
}
}
/** 폼에 올리기 직전에 좌·우 이름표를 그 측점 방향으로 맞춘다(독립 기슭막이 칸). */
function syncSideLabels(chainageM: number): void {
const labels = deps.wallSideLabels?.(chainageM);
if (labels) section.facility.setRevetSideLabels(labels);
}
/** 폼에 올리기 직전에 그 관의 옵션을 최신 스펙으로 보강한다(빈 칸만 채운다). */
function refreshSpecDefaults(chainageM: number): void {
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
);
if (!hit) return;
syncSideLabels(hit.chainage_m);
hit.options = withSpecDefaults(
hit.options,
deps.detail?.() ?? null,
hit.chainage_m,
markTypes,
deps.wallHeight,
deps.wallForm,
);
section.setPipeFacilities(pipeFacilities);
}
/**
* 관 목록을 **미저장 스냅샷**으로 남긴다 — B05 가 쓰는 바로 그 세션이다(2026-09-12
* 사용자: 담는 꼴도 한 벌이어야 한다). 종전에는 B06 만 「바뀐 것만」 따로 담아
* 두 페이지가 서로 다른 자리를 보고 저장 순서에 따라 결과가 갈렸다.
*/
function persistPipes(): void {
if (!deps.projectId) return;
writePendingPipes(
deps.projectId,
pipeFacilities.map((pipe) => ({
chainage_m: pipe.chainage_m,
source: pipe.source ?? "user",
facility: pipe.facility,
...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}),
...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}),
...(pipe.options ? { options: pipe.options } : {}),
})),
);
}
/** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */
function movePipeTo(fromChainageM: number, toChainageM: number): void {
if (!deps.projectId) {
showToast(PIPE_MOVE_GUIDE, "error");
return;
}
const to = Number(toChainageM.toFixed(2));
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.chainage_m = to;
if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M)
currentChainageM = to;
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
showToast(PIPE_MOVE_NOTICE, "success");
}
/** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */
function removePipeAt(chainageM: number): void {
if (!deps.projectId) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
const at = Number(chainageM.toFixed(2));
pipeFacilities = pipeFacilities.filter(
(entry) => Math.abs(entry.chainage_m - at) >= PIPE_MATCH_M,
);
if (currentChainageM !== null && Math.abs(currentChainageM - at) < PIPE_MATCH_M)
currentChainageM = null;
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
deps.onStructuresChanged?.();
showToast(PIPE_REMOVE_NOTICE, "success");
}
return {
root: section.root,
listRoot: section.listRoot,
load,
currentChainage: () => currentChainageM,
// 종단 우클릭 → 좌측 폼·목록과 같은 함수. 폼이 열리고 목록·알약도 함께 선다.
addStructureAt: (chainageM, typeId) => section.addAt(chainageM, typeId),
removeStructureById: (structureId) => section.removeById(structureId),
addPipeAt: (chainageM) => section.addAt(chainageM, "pipe"),
removePipeAt: (chainageM) => removePipeAt(chainageM),
movePipeTo: (fromChainageM, toChainageM) => movePipeTo(fromChainageM, toChainageM),
moveStructureTo: (structureId, toChainageM) => section.moveById(structureId, toChainageM),
facility: {
setInletStructure: (value) => section.facility.setInletStructure(value),
onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler),
},
overrideOptions: (chainageM, patch) => {
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
);
if (!hit) return;
hit.options = { ...(hit.options ?? {}), ...patch };
section.setPipeFacilities(pipeFacilities);
// 폼을 그 값으로 다시 세운다 — 조정창이 한계를 알려 주던 것과 같은 자리다.
section.selectPipeByChainage(hit.chainage_m);
},
showPipeAt: (chainageM) => {
currentChainageM = chainageM;
if (chainageM !== null) refreshSpecDefaults(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;
refreshSpecDefaults(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,
/** 고른 부재를 세션에 남긴다(2026-09-04). 해제(null)는 부르지 않는다 — 조정창 닫기가
* 카드(측점) 선택까지 지우면 안 된다. 측점 해제는 카드 선택 리스너가 따로 적는다. */
remember?: (chainageM: number, key: string) => void,
): { 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);
if (key) remember?.(ownerChainageOf(chainageM), key);
panel.showPipeAt(key ? ownerChainageOf(chainageM) : null);
if (key) syncInletStructure();
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다.
refreshAdjustSlots();
};
const origFord = stationControls.ford.select;
stationControls.ford.select = (chainageM, role) => {
markWallSelecting();
origFord(chainageM, role);
if (role) remember?.(chainageM, role);
panel.showPipeAt(role ? chainageM : null);
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다
// (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1).
refreshAdjustSlots();
};
const origBox = stationControls.box.select;
stationControls.box.select = (chainageM, role) => {
markWallSelecting();
origBox(chainageM, role);
if (role) remember?.(chainageM, role);
panel.showPipeAt(role ? chainageM : null);
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다.
refreshAdjustSlots();
};
return { syncInletStructure };
}