Files
Aislo/B06_Section/B06_Section_UI_Page_Structures_Panel.ts
T
eomsangdonandClaude Opus 5.5 086ead9d6a fix(472-5 5-A 값 주인 하나): 관 · 구조물 주인 = 설계 가게 칸 — 화면 사본은 가게 알림으로 맞춤 · 되돌리기도 알림
- 되돌리기 · 다시하기(세션 직접 되씀) 뒤 announceDesignStore — 관 · 구조물 · 추가 측점 듣는 쪽을 되돌린 값으로
  (옛: 종단 목록 · 횡단 판 · 배수 편집기가 옛 값을 쥐고 다음 조작이 되돌림을 덮음)
- 종단 보기 — 가게 관 → 배수 편집기 adoptPipes(숨어도 · 제 쓰기 메아리 빼고 · 옵션만 바뀐 것도) · 가게 구조물 → takeStructures(보일 때)
- 횡단 보기 — 판이 마지막으로 읽거나 쓴 값과 다르면(옵션만도) 다시 실음(seen · foreign) · 제 쓰기는 쓰기 전에 적음
- 종단 고름 · 알약 관 = 시설 줄(가게 초안 자리 · 노선 따라감 뒤도) — 옛 알림 자리 안 봄
- 시험 test_472_5_shop_owner(옛 코드 실패 확인) · test_188_1 글 한 줄(까닭: 고름 관 원천 바뀜) · 위키 values · rules

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:53:26 +09:00

625 lines
32 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Page_Structures_Panel.ts
* 횡단 보기의 「구조물 배치」 몫 — 폼 · 목록은 종단 보기와 **같은 노드 한 벌**(`design_shared`)이고,
* 여기서는 횡단 보기가 보일 때 폼 알림을 받는 동작 · 횡단 조정 이음만 건다(계획서 24-3b).
*
* · 폼·목록 구현 = 공용 진입점(`A00_Common/b_structures_section`) — B05 그대로.
* · 구조물(B~G군) 추가·수정·삭제는 세션 미저장분(`writePendingStructures`)에 쌓고
* [임시저장]·[확정]의 `flushPendingStructures`가 정본에 쓴다(캐시→저장 원칙).
* · 계곡 통과 시설(A군, pipe_points 정본)은 **표시·선택만** — 추가·이동·삭제는
* 배수유역 재분할 체인이 있는 B05 몫이라 안내만 한다(2026-08-29 1차 범위).
* · 목록·폼 선택 → 해당 측점 카드 선택·스크롤. 횡단도 벽·구체 선택 → 폼에 그 시설
* 로드(소유 측점 기준). 하위 [횡단 조정] 컨테이너는 Adjust_Dock이 맡는다.
* ========================================================================== */
import type { PipeFacilityItem } from "../A00_Common/b_structures_section";
import { bindDesignView, sharedStructures } from "../A00_Common/design/design_shared";
import {
fetchStructureTypes,
staleDefinitionNotice,
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 { mergeFacilityOptions } from "../B05_Profile/logic/B05_Profile_UI_Drainage_Facility_Merge";
import { fordWaitsOf, waitsFordHeight } from "../B05_Profile/B05_Profile_UI_Drainage_Ford_Fill";
import { pagePipes, pageStructures, pageTypesChecked } from "../A00_Common/design/design_page_data";
import {
linkFields,
readGroupItems,
readSummaryItem,
} from "../common_util/common_util_summary_item";
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import {
coversAtStation,
coversChainage,
NO_SELECTION,
PIPE_MATCH_M,
readSelection,
resolveSelection,
selectDesign,
type DesignSelection,
} from "../A00_Common/design/design_selection";
import { showToast } from "@ui/ui_template_elements";
import { snapStructureStation } from "../common_util/common_util_structure_station";
import { plantsStation } from "../B05_Profile/B05_Profile_UI_Profile_Structures";
import { snapNotice, snapPlan } from "../B05_Profile/B05_Profile_UI_Structures_Snap";
import {
adjustDockRoot,
refreshAdjustSlots,
resetAdjustDock,
setAdjustSideSlots,
} from "./B06_Section_UI_Adjust_Dock";
import { withSpecDefaults } from "./B06_Section_UI_Page_Structures_Spec";
import { listPick } from "../common_util/common_util_structure_catalog";
import { followMovedPipe, pipeIdAt } from "../A00_Common/design/design_pipe_ids";
import type {
B06StructuresPanel,
B06StructuresPanelDeps,
} from "./B06_Section_UI_Page_Structures_Panel_Types";
export type {
B06StructuresPanel,
B06StructuresPanelDeps,
} from "./B06_Section_UI_Page_Structures_Panel_Types";
const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요.";
const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다.";
/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 뒤에서 바로(298-1). */
const PIPE_ADD_NOTICE =
"계곡 통과 시설을 넣었습니다 — 횡단도는 바로 보이고, 세부 배수유역은 뒤에서 다시 나눕니다.";
const PIPE_REMOVE_NOTICE = "계곡 통과 시설을 뺐습니다 — 세부 배수유역은 뒤에서 다시 나눕니다.";
/** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */
const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다.";
const PIPE_MOVE_NOTICE =
"기준 측점을 옮겼습니다 — 횡단도는 바로 보이고, 세부 배수유역은 뒤에서 다시 나눕니다.";
type PlaceOf = { chainage_m?: number | null; start_m?: number | null; end_m?: number | null };
/** 목록 자리 지문 — 누가거리 · 구간만(옵션 고침은 안 셈 · 440). */
const placeKey = (list: ReadonlyArray<PlaceOf>): string =>
JSON.stringify(
list.map((entry) => [entry.chainage_m ?? null, entry.start_m ?? null, entry.end_m ?? null]),
);
export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06StructuresPanel {
let structures: StructureInstance[] = [];
let pipeFacilities: PipeFacilityItem[] = [];
/** 마지막으로 실은 저장분(초안이 비면 목록이 견줄 자리 · 440). */
let storedStructures: StructureInstance[] = [];
let storedPipes: ReadonlyArray<PlaceOf> = [];
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리 — 유입측 형식 반영 대상. */
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[] = [];
/** 카드 누름이 덮어 여는 구조물 — 노면공(G군)은 뺌(152-5 · 목록 · 알약 고름은 id 길로 그대로). */
const coverable = (entry: StructureInstance): boolean =>
coversAtStation(markTypes.find((type) => type.type_id === entry.type_id)?.group);
/** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */
let preferredId: string | null = null;
/** 측점 고름(카드 하나 · 측점만 남은 고름 다시 세움)이 폼을 여는 중 — 폼 알림이 카드를 옮기지 않게(231-1). */
let openingAtStation = false;
/** 그 구조물로 갈 카드 측점 — 구간형은 범위 안 카드 중 기준점에 가까운 것(경계 기준점이 범위 밖 카드로 안 감 · 165-2). */
const focusOf = (structure: StructureInstance): number => {
const anchor = structureAnchorM(structure);
const inside = (deps.detail?.()?.cross_sections ?? [])
.map((entry) => entry.chainage_m)
.filter((at) => coversChainage(structure, at));
return inside.length
? inside.reduce((best, at) => (Math.abs(at - anchor) < Math.abs(best - anchor) ? at : best))
: anchor;
};
/** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). 알약 · 측점선 · 관 id 장부 = 페이지
* 시설 줄 한 벌(462 · 숨은 B05 평면 마커도 따라감) · `carryEdits` = 사용자 고침(불러옴은 false). */
const pushMarks = (carryEdits = true): void => {
deps.shared.lane.set({ structures, pipes: pipeFacilities }, carryEdits);
deps.onMarks?.(
[...structures, ...pipesToStructureMarks(pipeFacilities)],
markTypes,
pipeFacilities,
);
};
// 폼 한 벌(계획서 24-3b) — 횡단 보기가 보일 때 폼 알림을 받는 동작만 건다. 상세 옵션 칸은
// 한 벌이 보기에 따라 그린다(횡단 보기에서만).
/** 가게 관 · 구조물 초안 중 이 판이 마지막으로 읽거나 쓴 값(JSON) — 다르면 남(되돌리기 · 종단 보기 ·
* 저장 뒤)이 고친 것이라 다시 실음(472-5 · 옵션만 바뀌어도). */
let seen = { structures: "", pipes: "" };
const section = sharedStructures(deps.shared);
bindDesignView(deps.shared, "cross", {
structures: {
onChange: (next) => {
structures = withLocalIds(next);
section.setStructures(structures);
pushMarks();
seen.structures = JSON.stringify(structures); // 제 쓰기 — 듣는 쪽이 남 고침과 가름(472-5)
if (deps.projectId) writePendingStructures(deps.projectId, structures);
// 고른 구조물을 다른 측점으로 옮기면 횡단 카드도 따라감(53-1)
const picked = readSelection(deps.projectId);
const moved = picked?.structureId
? structures.find((entry) => entry.structure_id === picked.structureId)
: undefined;
if (moved && picked?.chainageM != null && !coversChainage(moved, picked.chainageM)) {
const id = moved.structure_id ?? null;
preferredId = id;
deps.focusChainage(structureAnchorM(moved));
preferredId = null;
deps.markStructure?.(id); // 카드 고름이 옛 자리로 알약을 풀었으면 다시 세움
selectDesign(
deps.projectId,
{ chainageM: structureAnchorM(moved), structureId: id },
{ from: "b06" },
);
}
},
onSelect: (structure) => {
if (!structure) {
deps.markStructure?.(null); // 풂 = 알약도 끔(53-2)
return;
}
currentChainageM = null; // 폼 = 이 구조물 — 옛 관을 붙든 채 남지 않게(165-2)
// 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다.
// 노면공 = 카드 하나 안 고름 · 범위 카드 전부 테두리 · 스크롤 없음 · 폼 유지(177-4 · 307)
// 측점 없는 구조물(제 측점을 안 심음)도 같은 길 — 근처 카드 측점선을 켜지 않음(요구 178)
const revealOnly = !coverable(structure) || !plantsStation(structure, markTypes);
const id = structure.structure_id ?? null;
preferredId = id;
// 측점 고름(카드 하나 · 다시 세움)이 연 폼 = 누른 카드 그대로 — 구조물 기준 카드로 옮겨 가지 않음(189-1 · 231-1)
if (!openingAtStation) deps.focusChainage(focusOf(structure), revealOnly);
preferredId = null;
// 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2)
deps.markStructure?.(id, !openingAtStation); // 측점 고름이 연 것 = 카드 하나 · 범위 테두리 없음(231-1)
// 목록 · 알약 · 형상 고름은 늘 고름 한 칸에 그 구조물 — 같은 카드라 카드 리스너가 안 돌아도
// 오감 · 새로 고침 뒤 그 폼(188-1 · 231-2) · 측점 고름이 연 것은 측점만(카드 리스너 몫 · 189-1)
const at = { chainageM: focusOf(structure), structureId: id };
if (!openingAtStation) selectDesign(deps.projectId, at, { from: "b06" });
},
getInterval: deps.stationInterval,
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
onPipeAdd: (chainageM, attributes) => {
if (!deps.projectId) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
const at = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1)
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 ?? {},
summary_item: attributes.summary_item ?? null,
group_items: attributes.group_items ?? null,
});
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
currentChainageM = at;
// 빈 월류 높이 = 다시 나눈 유량으로 채움(126-1) — 배수유역도 판이 이 표를 보고 채움(298-1)
if (waitsFordHeight(attributes.facility, attributes.options))
fordWaitsOf(deps.projectId).add(at);
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
showToast(PIPE_ADD_NOTICE, "success");
},
// 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다
// (2026-09-12 일원화). 세부유역은 관 초안을 쓰면 배수유역도가 뒤에서 다시 나눈다(298-1).
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
// 값만 고치면 폼이 옛 측점을 그대로 돌려줌(안 고친 칸) — 0.005m 넘으면 옮긴 것(80.17 → 80 도 · 234-1) ·
// 옮긴 뒤 옵션 · 캐시 얹기는 옮긴 자리(맞춘 정수 m)로 찾음(옛 자리 = 비운 그림 · 470 N2)
const moved = Math.abs(fromChainageM - toChainageM) > 0.005;
const at = (moved ? movePipeTo(fromChainageM, toChainageM) : null) ?? fromChainageM;
if (attributes.summary_item !== undefined || attributes.group_items !== undefined) {
const mine = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M,
);
const before = mine ? JSON.stringify(linkFields(mine)) : "";
if (mine && attributes.summary_item !== undefined)
mine.summary_item = attributes.summary_item;
if (mine && attributes.group_items !== undefined)
mine.group_items = attributes.group_items;
if (mine && JSON.stringify(linkFields(mine)) !== before) {
persistPipes();
section.setPipeFacilities(pipeFacilities);
}
}
// 폼 값은 **폼 칸 전부**다 — 비운 칸은 지운 것(계획서 24-4a · B05 [저장]과 같은 규칙).
const patch = (attributes.options ?? {}) as Record<string, number | string>;
if (!deps.projectId) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
// 값은 관 목록 초안(가게 pipes) 하나로 나간다 — 조정 예약(culvertopt)에 따로 담으면 비운 칸이
// [저장] 때 옛 값으로 되살아난다.
const hit = pipeFacilities.find((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M);
const was = hit?.facility ?? "pipe";
const facility = attributes.facility ?? was;
// 종류를 바꿈(54-4) — 옛 종류 그림 캐시에 새 종류 칸을 얹지 않음 · 옛 칸은 안 이음
if (facility === was) deps.applyPipeOptions?.(at, patch);
if (hit) {
hit.options =
mergeFacilityOptions(
{ facility: was, options: hit.options },
{ facility, options: patch },
).options ?? {};
hit.facility = facility;
}
persistPipes();
section.setPipeFacilities(pipeFacilities);
// 카드 · 그림 다시 = 초안 구독(323-6 · `watchDrafts` — 종류가 바뀌면 그 관 자리 그림이 새 종류)
if (facility !== was) pushMarks();
},
onPipeRemove: (chainageM) => removePipeAt(chainageM),
onPipeSelect: (chainageM) => {
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
selectDesign(
deps.projectId,
chainageM === null ? NO_SELECTION : { chainageM, structureId: null },
{ from: "b06" },
);
if (chainageM !== null) deps.focusChainage(chainageM);
// 알약 = 그 관 알약(같은 측점 구조물 말고 · 측점선 없는 물넘이포장도) — 카드 뒤에(53-2)
deps.markStructure?.(chainageM === null ? null : pipeIdAt(chainageM, deps.projectId));
},
onListRelease: () => deps.releaseCard?.(),
},
});
// 고른 것 없이 좌측 폼을 만지면 값이 아무 데도 가지 않는다(패널 실시간 반영이
// 선택된 항목에만 걸린다) — 조용히 무시되던 자리라 이유를 알린다(2026-08-30 사용자:
// "형태에 따라 모양을 넣어주는 기능이 누락되는 경우가 있어"의 원인).
section.facility.root.addEventListener("change", () => {
if (deps.shared.view() === "cross" && !section.hasSelection())
showToast(PIPE_PICK_GUIDE, "error");
});
// 하위 [횡단 조정] 컨테이너 — 폼 본문 맨 아래, 기존 항목과 경계 구분(2026-08-29
// 사용자: 병합 항목은 하위 컨테이너로 담아 혼동 방지).
resetAdjustDock();
section.root.querySelector(".b05-route__panel-body")?.append(adjustDockRoot());
// 단 수·옵션 행은 유입구·유출구 그룹으로 옮겨 붙는다(2026-08-29 지시 4).
// 상자를 새로 세우면(단 수 · 유입 형식 · 벽 조작 · 저장 · 넘김) 고른 상자 강조를 다시 얹음(54-30)
section.facility.root.addEventListener("specpaint", () => refreshAdjustSlots(false));
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 {
await pageTypesChecked(projectId); // 정의 판 확인 · 저장분 = 페이지 자료 줄 한 번(322-0 · 386-2)
const [types, stored, pipeResponse] = await Promise.all([
// B06 은 **구조물 전체**를 넣을 수 있어야 한다(2026-09-07 사용자 지시 5). B05 목록에서
// 빠져 있던 B군 종단배수·F군 생태/녹화도 여기서는 고를 수 있다 — 레지스트리 주석이
// 「B06 개별 횡단도 옵션으로 재사용」이라 적어 둔 그 자리다.
fetchStructureTypes(true),
pageStructures(projectId),
pagePipes(projectId),
]);
section.setTypes(types, listPick("b06")); // 표 목록 both + b06(322-1)
markTypes = types;
deps.shared.lane.setTypes(types);
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙).
storedStructures = stored.structures;
deps.shared.lane.setStored(storedStructures);
storedPipes = pipeResponse.pipe_points;
structures = readPendingStructures(projectId) ?? stored.structures;
seen = {
structures: JSON.stringify(readPendingStructures(projectId)),
pipes: JSON.stringify(readPendingPipes(projectId)),
};
const note = staleDefinitionNotice(projectId, stored.definition_version); // 322-2
if (note) showToast(note);
section.setStructures(structures);
const detail = deps.detail?.() ?? null;
// 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며,
// 이래야 B05 에서 넣고 B06 으로 넘어와도 같은 목록이 보인다(2026-09-12 사용자).
const pendingPipes = readPendingPipes(projectId);
pipeFacilities = (pendingPipes ?? pipeResponse.pipe_points).map((pipe) => ({
id: pipe.id, // 측점 열쇠 `pipe:<id>` — 초안까지 들고 감(445 H2)
chainage_m: pipe.chainage_m,
facility: pipe.facility ?? "pipe",
start_m: pipe.start_m,
end_m: pipe.end_m,
source: pipe.source,
summary_item: readSummaryItem(pipe.summary_item),
group_items: readGroupItems(pipe.group_items),
// 폼 기본값 = 조정창이 쓰던 값(횡단 스펙). 저장된 옵션이 있으면 그쪽이 이긴다.
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,
}));
// 옛 소수 관 자리 = 정수 m 초안(요구 167 · B05 `snapOldStations` 와 같은 셈) — B06 을 바로 열어도
// 카드 · 측점이 B05 를 거친 것과 같음 · 정본은 [저장]에서
if (!pendingPipes) snapOldPipes(projectId);
section.setPipeFacilities(pipeFacilities);
// 종단 알약 레인 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합쳐 B05 와 같은 표기로.
pushMarks(false);
// 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다
// (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도
// 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다.
const pick = readStructurePick(deps.projectId);
const picked = currentChainageM ?? pick?.at ?? null;
// 관이면 그 관, 옹벽 · 기슭막이 같은 구조물이면 저장된 구조물 id 로 폼을 세운다.
// 측점만 남은 고름은 노면공을 덮어 열지 않음 — 카드 고름과 같은 거름(152-5 · 231-1)
if (picked !== null) {
const structureId = currentChainageM === null ? (pick?.structureId ?? null) : null;
openingAtStation = structureId === null;
try {
section.selectAt({ chainageM: picked, structureId }, coverable);
} finally {
openingAtStation = false;
}
}
} 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;
const next = pipeFacilities.map((pipe) => ({
...(pipe.id ? { id: pipe.id } : {}), // B05 초안과 같은 꼴(445 H2)
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 } : {}),
...linkFields(pipe),
}));
seen.pipes = JSON.stringify(next); // 제 쓰기(472-5)
writePendingPipes(deps.projectId, next);
deps.onPipesWritten?.();
}
/** 옛 소수 관 자리 → 정수 m(확인 창 없이) — 계획고 편집 · 고름도 새 자리로(`movePipeTo` 와 같은 따라가기). */
function snapOldPipes(projectId: string): void {
const plan = snapPlan([], pipeFacilities);
plan.pipes.forEach(({ from, to, attributes }) => {
const hit = pipeFacilities.find((pipe) => pipe.chainage_m === from);
if (!hit) return;
deps.shared.profile?.resetStationEdit(from, to);
followMovedPipe(projectId, from, to);
Object.assign(hit, { chainage_m: to, start_m: attributes.start_m, end_m: attributes.end_m });
});
if (plan.pipes.length) {
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
persistPipes();
}
snapNotice(plan.pipes.length, plan.skipped);
}
/** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */
/** 관을 옮김 — 옮긴 자리(정수 m) · 그 자리 관이 없으면 null. */
function movePipeTo(fromChainageM: number, toChainageM: number): number | null {
if (!deps.projectId) {
showToast(PIPE_MOVE_GUIDE, "error");
return null;
}
const to = snapStructureStation(toChainageM); // 정수 m · 일반 측점 붙기(234-1)
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) {
// 그 관 측점의 계획고 편집도 새 자리로 — 옛 키가 유령 꼭짓점으로 남지 않게(213-2)
deps.shared.profile?.resetStationEdit(hit.chainage_m, to);
hit.chainage_m = to;
}
if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M)
currentChainageM = to;
followMovedPipe(deps.projectId, fromChainageM, to); // 고른 관이면 고름도 새 자리로(322-3)
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
showToast(PIPE_MOVE_NOTICE, "success");
return hit ? to : null;
}
/** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */
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();
showToast(PIPE_REMOVE_NOTICE, "success");
}
return {
root: section.root,
listRoot: section.listRoot,
load,
foreign: () =>
!!deps.projectId &&
(JSON.stringify(readPendingStructures(deps.projectId)) !== seen.structures ||
JSON.stringify(readPendingPipes(deps.projectId)) !== seen.pipes),
listStale: () => {
if (!deps.projectId) return false;
const shopStructures = readPendingStructures(deps.projectId) ?? storedStructures;
const shopPipes = readPendingPipes(deps.projectId) ?? storedPipes;
return (
placeKey(shopStructures) !== placeKey(structures) ||
placeKey(shopPipes) !== placeKey(pipeFacilities)
);
},
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),
recount: () => section.facility.recount(),
},
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);
},
getStructures: () => structures,
adoptFordHeights: () => {
// 배수유역도가 초안에 채운 월류 높이(126-1)를 목록 · 폼에 받음 — 빈 칸만(298-1)
const draft = deps.projectId ? readPendingPipes(deps.projectId) : null;
const filled = pipeFacilities.filter((entry) => {
if (entry.options?.ford_height_m !== undefined) return false;
const height = draft?.find(
(pipe) => Math.abs(pipe.chainage_m - entry.chainage_m) < PIPE_MATCH_M,
)?.options?.ford_height_m;
if (height === undefined) return false;
entry.options = { ...entry.options, ford_height_m: height };
return true;
});
if (!filled.length) return;
section.setPipeFacilities(pipeFacilities);
const open = filled.find((entry) => entry.chainage_m === currentChainageM);
if (open) section.selectPipeByChainage(open.chainage_m);
},
releasePipe: () => {
currentChainageM = null;
section.selectPipeByChainage(null);
},
selectionAt: (chainageM) => {
const selection = { chainageM, structureId: null };
return {
chainageM,
structureId: resolveSelection(selection, pipeFacilities, structures, coverable).structureId,
};
},
prefer: (structureId) => {
preferredId = structureId;
},
preferred: () => preferredId,
selectStructure: (structureId) => section.selectById(structureId),
focusPicked: (structureId, focus) => {
const known = structures.find((entry) => entry.structure_id === structureId);
if (!known || plantsStation(known, markTypes)) {
preferredId = structureId;
focus();
preferredId = null;
}
if (known) section.selectById(structureId);
},
showAtChainage: (chainageM) => {
currentChainageM = null;
const selection: DesignSelection = { chainageM, structureId: preferredId };
// 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에
// 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳).
// 카드 하나 고름 = 그 측점 기준 — 연 노면공은 풂(189-1 · 노면공은 덮어 열지 않음 152-5)
const hit = resolveSelection(selection, pipeFacilities, structures, coverable);
// 폼은 위에서 푼 값으로 엶 — 공용 `selectAt` 은 노면공을 덮어 엶(152-5)
if (hit.pipeChainageM !== null) {
currentChainageM = hit.pipeChainageM;
refreshSpecDefaults(hit.pipeChainageM);
section.selectPipeByChainage(hit.pipeChainageM);
// 알약은 폼 뒤 — 폼이 연 구조물을 닫으며 알약을 끄므로(165-2 · 측점선 없는 물넘이포장도 53-2)
deps.markStructure?.(pipeIdAt(hit.pipeChainageM, deps.projectId));
} else {
openingAtStation = preferredId === null;
try {
section.selectById(hit.structureId);
} finally {
openingAtStation = false;
}
}
return { chainageM, structureId: hit.structureId };
},
selectPipeAt: (chainageM) => {
const pipe = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
);
if (!pipe) return;
const id = pipeIdAt(pipe.chainage_m, deps.projectId);
deps.focusChainage(pipe.chainage_m, true); // 카드 안 고름 · 스크롤 없음 — 옛 카드 · 측점선 풂(177-4 길 · 307)
currentChainageM = pipe.chainage_m;
refreshSpecDefaults(pipe.chainage_m);
section.selectPipeByChainage(pipe.chainage_m);
deps.markStructure?.(id); // 알약은 폼 뒤(165-2)
selectDesign(
deps.projectId,
{ chainageM: pipe.chainage_m, structureId: id },
{ from: "b06" },
);
},
};
}