Files
Aislo/B05_Profile/B05_Profile_UI_Structures_Snap.ts
T
eomsangdonandClaude Opus 5.5 994b91636c feat(B05·B06): 구조물 · 관 기준 측점 정수 m · 일반 측점 1 m 안 붙기 · [정수 맞춤] 단추(234-1)
- 맞춤 규칙 한 벌 — common_util_structure_station(파이썬 · TS 짝 · 거울 시험)
- 입력 — 좌측 폼 칸(1 m 단계 · 친 값만) · 끌기 · 우클릭 · 방향키(1 m 걸음) · 배수유역도 관 · B06 관 옮김 · 추가
- 구간형 시작 · 끝도 정수 m · 일반 측점 = 횡단 상세를 읽을 때 등록
- 서버 자동 배치 · 노선 재투영 자리도 정수 m(확정본은 단추로만)
- [정수 맞춤] — 구조물 목록 머리 · 확인 창 · 관은 하나씩 폼 [수정] 길 · 계획고 편집 따라감 · 결과 n건 알림
- 찾는 폭 한 값 — 손으로 적은 0.51 을 PIPE_MATCH_M 으로
- 시험 — test_234_1 새로 · 1 m 걸음 · 정수 입력에 맞춘 기대값 고침(219_1 · station_roundtrip) · 헬퍼 대역 보탬

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 19:05:52 +09:00

161 lines
7.1 KiB
TypeScript

/* =============================================================================
* 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<T extends { chainage_m: number }>(
station: T,
born: ReadonlyArray<T>,
): 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<StructureInstance>,
pipes: ReadonlyArray<PipeFacilityItem>,
): 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<PipeFacilityItem>;
/** 목록이 바뀜 알림(한 번). */
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<number> {
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;
}