/* ============================================================================= * B05_Profile_UI_Structures_Snap.ts * 구조물 기준 측점 정수 맞춤(234-1) — 끌기 · 방향키로 옮기는 구조물 한 건의 모양과 * 기존 소수 자료를 옮기는 [정수 맞춤] 단추(좌측 구조물 목록 머리 · B05 · B06 한 벌). * * 맞춤 규칙은 공용 `common_util_structure_station`(파이썬 짝) 한 벌. 단추는 확인 창 뒤 * 구조물은 한 번에, 관은 하나씩 폼 수정과 같은 길(`onPipeUpdate`)로 옮긴다 — 계획고 편집 * 따라가기(213-2) · 시설 속성 이어받기가 손으로 옮길 때와 같다. 정본은 [저장]에서 나간다. * ========================================================================== */ import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { isSnappedStation, snapStructureStation, } from "../common_util/common_util_structure_station"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; export { snapStructureStation }; import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel_Types"; /** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤. */ export function movedStructure(target: StructureInstance, toChainageM: number): StructureInstance { const anchor = snapStructureStation(toChainageM); if (target.placement !== "interval") return { ...target, chainage_m: anchor }; const length = (target.end_m ?? 0) - (target.start_m ?? 0); const start = snapStructureStation(anchor - (structureAnchorM(target) - (target.start_m ?? 0))); const snappedEnd = snapStructureStation(start + length); const end = snappedEnd > start ? snappedEnd : start + length; return { ...target, chainage_m: Math.min(Math.max(anchor, start), end), start_m: start, end_m: end, }; } /** 사라진 측점의 계획고 편집이 따라갈 새 측점 — 맞춘 자리(0.006m 안 · 213-2 와 같은 폭)의 것. */ export function followSnapped( station: T, born: ReadonlyArray, ): T | null { const to = snapStructureStation(station.chainage_m); return born.find((next) => Math.abs(next.chainage_m - to) < 0.006) ?? null; } const offGrid = (value: number | null | undefined): boolean => typeof value === "number" && !isSnappedStation(value); /** 기준 · 시작 · 끝 중 하나라도 맞춤 밖(소수)인 구조물인가. */ export const structureOffGrid = (structure: StructureInstance): boolean => offGrid(structure.chainage_m) || offGrid(structure.start_m) || offGrid(structure.end_m); export interface SnapPlan { /** 목록 자리 · 옮긴 모양. */ structures: Array<{ index: number; next: StructureInstance }>; pipes: Array<{ from: number; to: number; pipe: PipeFacilityItem }>; /** 맞춘 자리에 다른 관이 있어 그대로 둔 관 수. */ skipped: number; } /** 옮길 것 셈 — 구조물은 옮긴 모양, 관은 (옛 자리 → 새 자리) · 자리 겹친 관은 건너뜀. */ export function snapPlan( structures: ReadonlyArray, pipes: ReadonlyArray, ): SnapPlan { const moved = structures.flatMap((structure, index) => structureOffGrid(structure) ? [{ index, next: movedStructure(structure, structureAnchorM(structure)) }] : [], ); const taken = pipes.filter((pipe) => !offGrid(pipe.chainage_m)).map((pipe) => pipe.chainage_m); const plan: SnapPlan = { structures: moved, pipes: [], skipped: 0 }; [...pipes] .filter((pipe) => offGrid(pipe.chainage_m)) .sort((left, right) => left.chainage_m - right.chainage_m) .forEach((pipe) => { const to = snapStructureStation(pipe.chainage_m); if (taken.some((other) => Math.abs(other - to) < PIPE_MATCH_M)) { plan.skipped += 1; return; } taken.push(to); plan.pipes.push({ from: pipe.chainage_m, to, pipe }); }); return plan; } /** 관 한 건의 지금 속성 그대로 — 폼 [수정]과 같은 꼴(비운 칸 = 지움 규칙에 옛 값이 다 실림). */ export const pipeAttributes = (pipe: PipeFacilityItem): FacilityAttributes => ({ facility: pipe.facility, ...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}), ...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}), options: { ...(pipe.options ?? {}) }, summary_item: pipe.summary_item ?? null, group_items: pipe.group_items ?? null, }); export interface SnapDockContext { /** 패널이 쥔 구조물 배열(참조) — 옮긴 것을 제자리에 갈아 끼움. */ structures: StructureInstance[]; pipes: () => ReadonlyArray; /** 목록이 바뀜 알림(한 번). */ emit: () => void; /** 관 한 건 옮김 — 폼 [수정]과 같은 길. */ movePipe: (from: number, to: number, attributes: FacilityAttributes) => void; /** 열린 폼을 닫음 — 옛 측점을 쥔 폼이 다음 [수정]에 되돌리지 않게. */ closeForm: () => void; } /** [정수 맞춤] 단추를 얹은 목록 뿌리 — 아래 고정 영역에 목록 대신 놓인다. */ export function snapDock(list: HTMLElement, ctx: SnapDockContext): HTMLElement { const button = document.createElement("button"); button.type = "button"; button.className = "b05-route__irregular-btn b05-structure__snap"; button.textContent = "정수 맞춤"; button.title = "소수 기준 측점(관 · 구조물)을 정수 m 로 — 일반 측점 1 m 안이면 그 측점"; button.addEventListener("click", () => void runSnap(ctx)); const head = document.createElement("div"); head.className = "b05-structure__snap-row"; head.append(button); const dock = document.createElement("div"); dock.className = "b05-structure__list-dock"; dock.append(head, list); return dock; } export async function runSnap(ctx: SnapDockContext): Promise { const plan = snapPlan(ctx.structures, ctx.pipes()); const count = plan.structures.length + plan.pipes.length; if (!count) { showToast( plan.skipped ? `소수 측점 관 ${plan.skipped}건은 맞출 자리에 다른 관이 있어 그대로 둡니다.` : "소수 기준 측점이 없습니다.", "info", ); return 0; } const ok = await showConfirmDialog( `소수 기준 측점 ${count}건(관 ${plan.pipes.length} · 구조물 ${plan.structures.length})을 ` + "정수 m(일반 측점 1 m 안이면 그 측점)로 옮깁니다. 계획고 편집도 따라가며, [저장]을 눌러야 남습니다.", "정수 맞춤", ); if (!ok) return 0; ctx.closeForm(); if (plan.structures.length) { plan.structures.forEach(({ index, next }) => (ctx.structures[index] = next)); ctx.emit(); } plan.pipes.forEach(({ from, to, pipe }) => ctx.movePipe(from, to, pipeAttributes(pipe))); showToast( `정수 맞춤 ${count}건` + (plan.skipped ? ` · 자리 겹친 관 ${plan.skipped}건은 그대로` : "") + " — [저장]을 눌러 남기세요.", "success", ); return count; }