gis 편집 스냅핑, 꼭지점 제거 기능 구현

This commit is contained in:
kiminchan
2026-06-08 17:40:10 +09:00
parent a123588e32
commit 8737d1eb0a
10 changed files with 1115 additions and 74 deletions
@@ -238,11 +238,14 @@ export default function GisEditPipeSessionPopup({
const fcltMngNo = String(change.featureRef?.fcltMngNo ?? "").trim();
const mngInstCd = String(change.featureRef?.mngInstCd ?? "").trim();
if (!fcltMngNo || !mngInstCd) return;
if (/^NEW-/i.test(fcltMngNo) || /^NEW-/i.test(mngInstCd)) return;
let isMounted = true;
const fetchPipeInputInfo = async () => {
try {
const result = await getData(`v1/fcltppl/${fcltMngNo}/${mngInstCd}`);
const safeFcltMngNo = encodeURIComponent(fcltMngNo);
const safeMngInstCd = encodeURIComponent(mngInstCd);
const result = await getData(`v1/fcltppl/${safeFcltMngNo}/${safeMngInstCd}`);
if (!isMounted || !result) return;
setInputInfoTableData((previous) => ({
@@ -15,6 +15,7 @@ import type { GisEditTool } from "./gisEditTypes";
type Props = {
isEditMode: boolean;
activeTool: GisEditTool | null;
isSnapEnabled: boolean;
canUseTools: boolean;
canSave: boolean;
isSaving?: boolean;
@@ -23,6 +24,7 @@ type Props = {
allowedTools?: GisEditTool[];
onOpenSession?: () => void;
onClose?: () => void;
onToggleSnap: () => void;
onSelectTool: (tool: GisEditTool) => void;
onSave: () => void;
};
@@ -41,6 +43,7 @@ const TOOL_BUTTONS: Array<{
export default function GisEditToolbar({
isEditMode,
activeTool,
isSnapEnabled,
canUseTools,
canSave,
isSaving = false,
@@ -49,6 +52,7 @@ export default function GisEditToolbar({
allowedTools = [],
onOpenSession,
onClose,
onToggleSnap,
onSelectTool,
onSave,
}: Props) {
@@ -57,70 +61,109 @@ export default function GisEditToolbar({
return (
<div className="absolute left-1/2 top-4 z-[60] flex w-[min(860px,calc(100%-2rem))] -translate-x-1/2 flex-col">
<div className="card card-border flex !bg-white py-[1px]">
<div className="flex w-full flex-wrap items-center gap-2">
<div className="mr-1 flex min-w-[116px] items-center">
<span className="text-[1.3rem] font-bold leading-5 text-[#123b62]">하수관로 편집</span>
<div className="flex w-full items-center gap-2 overflow-hidden">
<div className="flex w-[120px] shrink-0 items-center justify-start">
<span className="whitespace-nowrap text-[1.2rem] font-bold leading-5 text-[#123b62]">하수관로 편집</span>
</div>
<div className="flex min-w-0 flex-1 justify-center">
<div className="flex items-center gap-1">
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
const isActive = activeTool === tool;
const isToolEnabled =
canUseTools && allowedTools.includes(tool);
if (tool === "VERTEX_EDIT") {
return (
<div key="vertex-edit-with-snap" className="flex shrink-0 items-center">
<Button
type="button"
color={activeTool === "VERTEX_EDIT" ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[98px] px-2"
disabled={!isToolEnabled}
onClick={() => onSelectTool("VERTEX_EDIT")}
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Icon className="h-4 w-4" />
<span>{label}</span>
</span>
</Button>
<Button
type="button"
color={isSnapEnabled ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[82px] px-2"
disabled={!canUseTools}
onClick={onToggleSnap}
title="이동과 꼭지점편집에 스냅핑 적용"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Check className="h-4 w-4" />
<span>스냅핑</span>
</span>
</Button>
</div>
);
}
return (
<Button
key={tool}
type="button"
color={isActive ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[74px] shrink-0 px-2"
disabled={!isToolEnabled}
onClick={() => onSelectTool(tool)}
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Icon className="h-4 w-4" />
<span>{label}</span>
</span>
</Button>
);
})}
</div>
</div>
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
const isActive = activeTool === tool;
const isToolEnabled =
canUseTools && allowedTools.includes(tool);
return (
<Button
key={tool}
type="button"
color={isActive ? "primary" : "white"}
size="sm"
className="mx-1 mt-1 min-w-[82px]"
disabled={!isToolEnabled}
onClick={() => onSelectTool(tool)}
>
<span className="flex items-center justify-center gap-2">
<Icon className="h-4 w-4" />
<span>{label}</span>
</span>
</Button>
);
})}
<Button
type="button"
color="white"
size="sm"
className="ml-auto mt-1 min-w-[112px]"
onClick={onOpenSession}
title="GIS 편집 세션 보기"
>
<span className="flex items-center justify-center gap-2">
<Check className="h-4 w-4" />
<span>편집 세션 {sessionChangeCount}</span>
</span>
</Button>
<div className="flex w-[248px] shrink-0 items-center justify-end gap-1">
<Button
type="button"
color="white"
size="sm"
className="mt-1 min-w-[98px] shrink-0 px-2"
onClick={onOpenSession}
title="GIS 편집 세션 보기"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Check className="h-4 w-4" />
<span>편집 세션 {sessionChangeCount}</span>
</span>
</Button>
<Button
type="button"
color="primary"
size="sm"
className="mx-1 mt-1 min-w-[64px]"
disabled={!canSave || isSaving}
onClick={onSave}
>
{isSaving ? "저장 중" : "저장"}
</Button>
<Button
type="button"
color="primary"
size="sm"
className="mt-1 min-w-[58px] shrink-0 px-2"
disabled={!canSave || isSaving}
onClick={onSave}
>
{isSaving ? "저장 중" : "저장"}
</Button>
<Button
type="button"
color="white"
size="sm"
className="mx-1 mt-1 min-w-[68px]"
onClick={onClose}
title="하수관로 편집 닫기"
>
<span className="flex items-center justify-center gap-2">
<X className="h-4 w-4" />
<span>닫기</span>
</span>
</Button>
<Button
type="button"
color="white"
size="sm"
className="mt-1 min-w-[58px] shrink-0 px-2"
onClick={onClose}
title="하수관로 편집 닫기"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<X className="h-4 w-4" />
<span>닫기</span>
</span>
</Button>
</div>
</div>
</div>
</div>
@@ -103,6 +103,7 @@ export type GisEditSessionSnapshot = {
isEditMode: boolean;
selectedLayerName: string;
activeTool: GisEditTool | null;
isSnapEnabled: boolean;
changes: GisEditChange[];
lastSavedPayload: GisEditSavePayload | null;
canUndo: boolean;
@@ -487,6 +487,8 @@ export const useGisEdit = (treeData: any[]) => {
const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl");
// 1차 구현에서는 activeTool과 지도 선택 이벤트를 조합해 변경분을 로컬 세션에 누적한다.
const [activeTool, setActiveTool] = useState<GisEditTool | null>(null);
// QGIS 유사 스냅핑 on/off는 MOVE, VERTEX_EDIT 상호작용에서 함께 참조한다.
const [isSnapEnabled, setIsSnapEnabled] = useState(false);
// 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지한다.
const [changes, setChanges] = useState<GisEditChange[]>([]);
const [statusMessage, setStatusMessage] = useState(
@@ -560,6 +562,7 @@ export const useGisEdit = (treeData: any[]) => {
setActiveTool(null);
setPendingInteraction(null);
setFocusedChangeId(null);
setIsSnapEnabled(false);
latestSelectionRef.current = null;
setStatusMessage(
next
@@ -570,6 +573,18 @@ export const useGisEdit = (treeData: any[]) => {
});
}, []);
const toggleSnapEnabled = useCallback(() => {
setIsSnapEnabled((previous) => {
const next = !previous;
setStatusMessage(
next
? "스냅핑을 켰습니다. 이동과 꼭지점편집 중 기존 하수관로 꼭지점/선분에 맞춰집니다."
: "스냅핑을 껐습니다. 이동과 꼭지점편집이 자유 좌표 기준으로 동작합니다.",
);
return next;
});
}, []);
const selectLayer = useCallback((layerId: string) => {
setSelectedLayerId(layerId);
setActiveTool(null);
@@ -612,10 +627,10 @@ export const useGisEdit = (treeData: any[]) => {
if (tool === "ADD") {
const addGuideMessage =
selectedLayer.geometryType === "Point"
? `${selectedLayer.layerName} 신규 위치를 지도에서 클릭하세요.`
? `${selectedLayer.layerName} 신규 위치를 지도에서 클릭하세요.${isSnapEnabled ? " 스냅핑이 켜져 있으면 기존 관로 꼭지점/선분에 맞춰집니다." : ""}`
: selectedLayer.geometryType === "LineString"
? `${selectedLayer.layerName} 선형을 지도에서 연속 클릭으로 입력한 뒤 더블클릭으로 마무리하세요.`
: `${selectedLayer.layerName} 영역을 지도에서 연속 클릭으로 입력한 뒤 더블클릭으로 마무리하세요.`;
? `${selectedLayer.layerName} 선형을 지도에서 연속 클릭으로 입력한 뒤 더블클릭으로 마무리하세요.${isSnapEnabled ? " 스냅핑이 켜져 있으면 기존 관로 꼭지점/선분에 맞춰집니다." : ""}`
: `${selectedLayer.layerName} 영역을 지도에서 연속 클릭으로 입력한 뒤 더블클릭으로 마무리하세요.${isSnapEnabled ? " 스냅핑이 켜져 있으면 기존 관로 꼭지점/선분에 맞춰집니다." : ""}`;
setStatusMessage(addGuideMessage);
return;
}
@@ -627,13 +642,13 @@ export const useGisEdit = (treeData: any[]) => {
: "삭제";
const vertexGuideMessage =
tool === "VERTEX_EDIT"
? `지도에서 ${selectedLayer.layerName} 대상 객체를 클릭하면 실꼭지점은 주황색, 새 점 삽입용 midpoint는 흰색으로 표시됩니다.`
? `지도에서 ${selectedLayer.layerName} 대상 객체를 클릭하면 실꼭지점은 주황색, 새 점 삽입용 midpoint는 흰색으로 표시됩니다.${isSnapEnabled ? " 스냅핑이 켜져 있으면 기존 관로 선분과 꼭지점에 맞춰집니다." : ""}`
: null;
setStatusMessage(
vertexGuideMessage ??
`지도에서 ${selectedLayer.layerName} 대상 객체를 클릭하면 ${toolLabel} 변경분을 편집 세션에 추가합니다.`,
`지도에서 ${selectedLayer.layerName} 대상 객체를 클릭하면 ${toolLabel} 변경분을 편집 세션에 추가합니다.${tool === "MOVE" && isSnapEnabled ? " 스냅핑이 켜져 있으면 이동 중 기존 관로 꼭지점/선분에 맞춰집니다." : ""}`,
);
}, [appendChange, selectedLayer]);
}, [appendChange, isSnapEnabled, selectedLayer]);
const deactivateTool = useCallback(() => {
setActiveTool(null);
@@ -1052,6 +1067,7 @@ export const useGisEdit = (treeData: any[]) => {
setActiveTool(null);
setPendingInteraction(null);
setFocusedChangeId(null);
setIsSnapEnabled(false);
latestSelectionRef.current = null;
setStatusMessage("편집 세션을 초기화했습니다.");
}, []);
@@ -1063,6 +1079,7 @@ export const useGisEdit = (treeData: any[]) => {
setActiveTool(null);
setPendingInteraction(null);
setFocusedChangeId(null);
setIsSnapEnabled(false);
latestSelectionRef.current = null;
setStatusMessage("하수관로 편집을 종료하고 세션 변경분을 초기화했습니다.");
}, []);
@@ -1150,6 +1167,7 @@ export const useGisEdit = (treeData: any[]) => {
selectedLayer,
selectedLayerId,
activeTool,
isSnapEnabled,
changes,
statusMessage,
canUseTools,
@@ -1161,6 +1179,7 @@ export const useGisEdit = (treeData: any[]) => {
"현재 구현은 FE 로컬 편집 세션, 관로 기본정보 draft, BE 저장 API(`/api/v1/map/gisEditSessions/save`) 최종본 저장까지 포함합니다.",
canUndo: actionableChanges.length > 0,
toggleEditMode,
toggleSnapEnabled,
selectLayer,
activateTool,
deactivateTool,
@@ -33,7 +33,7 @@ export default function SinkholeLayerSettingPanel({
};
return (
<div className="overflow-hidden rounded-sm border border-[#f0d7b0] bg-[#fff8ef]">
<div className="w-[215px] overflow-hidden rounded-sm border border-[#f0d7b0] bg-[#fff8ef]">
<div className="relative flex min-h-[30px] items-center justify-center border-b border-[#f2dfc3] bg-white px-8">
<button
type="button"
File diff suppressed because it is too large Load Diff
@@ -444,6 +444,70 @@
- 테스트 관리번호 `QA-PW-20260526-001/002`는 저장 실패로 `fid/editSn`이 생성되지 않았다. 최종본 삭제 저장 정리 대상은 없었고, FE 로컬 세션은 `총 0건`으로 정리했다.
- `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn`은 실행하지 않았다. `sn` dev server가 `3002`에서 실행 중이므로 AGENTS.md 기준에 따라 빌드 검증은 제외했다.
### 2.14 2026-06-08 스냅핑 토글 / Delete 키 꼭지점 삭제 코드 반영 점검
- 코드 기준 점검
- `GisEditToolbar`에 `스냅핑` on/off 버튼이 추가됐고, 편집 세션 팝업 헤더에도 `스냅핑 ON/OFF` 상태가 보이도록 반영했다.
- `useGisEdit`는 `isSnapEnabled` 상태를 관리하고, `MOVE`, `VERTEX_EDIT` 안내 문구에 스냅핑 상태를 함께 반영한다.
- 초기 반영 직후 `ADD`에는 스냅핑이 연결되지 않아 사용자가 스냅핑 ON 상태로 신규 관로를 그려도 기존 관로에 붙지 않는 문제가 있었다.
- 후속 수정으로 `page.tsx`의 `ADD`, `MOVE`, `VERTEX_EDIT` interaction 진입 시 현재 보이는 하수관로 원본/preview feature를 묶은 `Snap` interaction을 함께 추가한다.
- `VERTEX_EDIT` 중 vertex handle을 클릭하면 선택 상태가 유지되고, 키보드 `Delete`로 해당 꼭지점을 삭제한 뒤 `updateChangeGeometry(..., { keepVertexEditActive: true })` 흐름으로 세션/preview를 다시 갱신한다.
- midpoint는 클릭 시 안내 문구만 내리고 Delete 삭제 대상에서는 제외한다.
- 꼭지점 삭제 유틸은 선형 최소 2점, 폴리곤 최소 3점을 보장하도록 방어 로직이 들어갔다.
- 정적 검증
- 실행 명령
- `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0`
- 결과
- 이번 변경분 관련 신규 타입 오류는 정리했다.
- 아래 기존 저장소 오류는 그대로 남아 있다.
- `app/(map-layout)/map/components/common/index.ts` 타입 불일치
- `app/(map-layout)/map/page.tsx` 기존 null 가능성 경고 1건
- `app/html/(with-base-layout)/layout.tsx` import 누락
- 공통 chart 컴포넌트 implicit any
- 수동 재검증 필요 항목
- 스냅핑 `ON/OFF`에 따라 `ADD`, `MOVE`, `VERTEX_EDIT` 체감이 실제로 달라지는지
- 주황색 실꼭지점 클릭 후 `Delete` 입력이 정상 동작하는지
- 2점만 남은 선형에서 Delete가 차단되는지
- 겹친 handle 묶음에서 Delete가 기대한 범위만 제거하는지
### 2.15 2026-06-08 QGIS 기준 1차 스냅 재구현 점검
- 코드 기준 점검
- 기존 “현재 지도에 이미 올라온 feature를 임시 수집”하던 방식 대신, 현재 화면 BBOX 기준 `tb_ms_swage_ppl` WFS를 직접 다시 읽어오는 전용 pipe snap source를 추가했다.
- `ADD`는 `Draw + Snap(source)` 구조로 다시 연결했고, `VERTEX_EDIT`도 custom vertex handle 위에 같은 pipe snap source를 사용하도록 조정했다.
- 후속 보정으로 `MOVE`도 같은 pipe snap source를 우선 사용하도록 확장했다.
- `VERTEX_EDIT`에서 기존 관로를 편집하는 경우에는 자기 자신 fid를 snap source에서 제외해 원본 자기 geometry에 다시 달라붙는 현상을 줄이도록 보강했다.
- `moveend` 시 pipe snap source를 재조회해 현재 보이는 원본 하수관로를 기준으로 스냅 상태를 갱신한다.
- 정적 검증
- 실행 명령
- `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0`
- 결과
- 이번 1차 재구현으로 인한 신규 타입 오류는 정리했다.
- 남은 오류는 기존 저장소 오류와 기존 `page.tsx` null 가능성 1건이다.
- 수동 재검증 필요 항목
- 기존 오수관 끝점/선분 근처에서 `ADD` 시작점과 끝점이 실제로 붙는지
- 기존 오수관 꼭지점 근처에서 `VERTEX_EDIT` 주황색 실꼭지점이 실제로 붙는지
- 기존 오수관 끝점/선분 근처에서 `MOVE` 결과 geometry가 실제로 붙는지
- 지도 이동 후 다른 위치에서도 snap source가 새로 갱신되어 계속 붙는지
### 2.16 2026-06-08 MOVE grip 기준 재보정 및 입력정보 조회 오류 점검
- 코드 기준 점검
- `MOVE`는 이제 관로 선택 순간 클릭 위치에서 가장 가까운 관로 꼭지점 meta를 `move grip`으로 기억한다.
- 드래그 시작/중/종료 시 원본 geometry snapshot을 다시 만들고, `move grip`이 하수관로 원본 snap source에 붙도록 전체 geometry를 재정렬한다.
- 따라서 `MOVE`도 `ADD`, `VERTEX_EDIT`처럼 “사용자가 조작하는 한 점이 스냅 기준”이 되도록 맞췄다.
- 관로 상세 팝업의 입력정보 조회는 `fcltMngNo`, `mngInstCd`를 `encodeURIComponent(...)` 처리하고, `NEW-*` 임시값 조회를 차단하도록 보강했다.
- 정적 검증
- 실행 명령
- `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0`
- 결과
- 이번 보정으로 인한 신규 타입 오류는 정리했다.
- 저장소 기존 오류만 남아 있다.
- 수동 재검증 필요 항목
- 관로 끝점 근처를 잡고 `MOVE`할 때 grip 기준으로 자연스럽게 붙는지
- 관로 중간을 잡고 `MOVE`했을 때도 가장 가까운 관로 꼭지점을 기준으로 결과가 일관적인지
- 이동 중 관로 상세 팝업이 열려도 `fetchPipeInputInfo` 500이 재발하지 않는지
## 3. 현재 검증 상태
### 3.1 통과
@@ -452,6 +516,10 @@
- 레이어별 도구 활성 규칙
- 세션 `undo`, `reset`
- `MOVE`의 기본 preview drag
- `스냅핑` 토글 상태의 툴바/세션 스냅샷 반영
- `VERTEX_EDIT` 실꼭지점 선택 후 `Delete` 키 처리 코드 경로
- `ADD`, `VERTEX_EDIT`의 하수관로 전용 WFS snap source 연결
- `MOVE`의 grip vertex 기준 snap 보정 코드 경로
- 하수관로 `ADD/MOVE/DELETE` 최종본/이력 DB 저장
### 3.2 진행 중
@@ -459,7 +527,11 @@
- `ADD` geometry family별 직접 캡처
- 2026-05-26 Playwright 재검증 기준 `ADD` 최종 저장 API `500 E999` 원인 분석 및 재검증
- `VERTEX_EDIT`의 QGIS 기준 사용자 경험 재정렬
- `ADD`, `VERTEX_EDIT`의 실제 QGIS 체감 스냅 수동 재검증
- `MOVE`, `VERTEX_EDIT` 실조작 수동 재검증
- `MOVE` grip 기준 snap 체감 수동 재검증
- `스냅핑 ON/OFF` 체감과 실제 snap 대상 범위 수동 재검증
- `Delete` 꼭지점 삭제 후 남은 선형 연결 상태 수동 재검증
- 2026-05-26 재검증에서 확인된 `이동`, `꼭지점편집`, `삭제` 선택 피드백/조작 피드백 개선
### 3.3 보류
@@ -481,6 +553,8 @@
- `GIS 편집 세션`이 popup에 의존하면 팝업 차단, 포커스 이동, 별도 창 위치 문제로 사용자가 변경목록을 놓칠 수 있다.
- 저장 검증 메시지와 화면 필드명이 불일치하면 신규 관로 저장 실패 원인을 사용자가 잘못 이해할 수 있다.
- 지도 초기 로딩 오류가 남아 있으면 GIS 편집 결함과 공통 지도/코드/레이어 설정 오류를 분리하기 어렵다.
- 스냅핑은 현재 source에 로드된 하수관로 원본/preview만 대상으로 하므로, 화면 밖 객체나 미로딩 객체까지는 맞추지 못할 수 있다.
- 겹친 handle을 하나의 묶음으로 보여주는 현재 구조상, 같은 좌표를 공유하는 raw vertex는 Delete 시 함께 제거될 수 있다.
## 4-1. 관측 증적 요약
@@ -16,6 +16,15 @@
- `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면설계서_v1_1.png`
## 1-2. 최근 진행 갱신
- 2026-06-08
- `MOVE`와 `꼭지점편집` 사이에 `스냅핑` on/off 버튼을 추가하고, 편집 세션/상태 스냅샷에도 `ON/OFF`가 보이도록 연결했다.
- 기존 임시 스냅 수집 방식으로는 원본 오수관에 안정적으로 붙지 않는다고 판단해, 1차 재구현 범위를 `ADD`, `VERTEX_EDIT`로 줄였다.
- `ADD`, `VERTEX_EDIT`는 현재 화면 BBOX 기준으로 읽어온 `tb_ms_swage_ppl` 원본 WFS geometry를 전용 snap source로 사용하도록 재구성했다.
- 후속 보정으로 `MOVE`도 같은 원본 하수관로 snap source를 사용하도록 넓혔고, 이동 기준점은 사용자가 처음 집은 관로 꼭지점(grip vertex)으로 다시 고정했다.
- `MOVE` 중 관로 입력정보 조회에서 `500`이 발생하는 케이스를 줄이기 위해 상세 팝업의 path segment를 인코딩하고 `NEW-*` 임시값 조회를 차단했다.
- `VERTEX_EDIT` 중 주황색 실꼭지점을 클릭해 선택한 뒤 키보드 `Delete`로 삭제할 수 있게 했고, 하수관로 최소 2점 유지 규칙을 넣었다.
- 흰색 midpoint는 새 점 삽입 전용으로 유지하고, Delete 삭제 대상에서는 제외했다.
- 수동 브라우저 재검증은 아직 남아 있고, 정적 타입체크는 저장소 기존 오류와 별개로 이번 변경분 신규 오류는 정리했다.
- 2026-05-21
- 실제 구현 코드를 다시 점검한 결과, 현재 GIS편집은 geometry 세션만 관리하고 관로 기본정보 세션은 없는 상태로 정리했다.
- `tb_ms_swage_ppl` geometry와 `tb_sn_swage_ppl` 기본정보 저장 구조가 분리돼 있어, 최종 저장 payload는 `featureRef + basicInfo + geometryPatch` 기준으로 재설계했다.
@@ -187,6 +187,60 @@
- 공통 `GisSelect`에 `canSelectFeature` 가드를 연결해, 하수관로 편집 중에는 비하수관로 피처를 아예 선택 단계에서 차단하도록 확장했다.
- 차단된 경우에는 지도 줌 이동이나 상세정보 전환 없이 상태문구 `하수관로를 선택해주세요.`만 남기도록 정리했다.
### 4.16 2026-06-08 스냅핑 토글 및 Delete 키 꼭지점 삭제
- 사용자 요구사항 기준
- `이동`과 `꼭지점편집` 사이에 QGIS 유사 `스냅핑` on/off 토글을 두고 실제 프론트 interaction에서 사용 가능해야 한다.
- `꼭지점편집` 중 주황색 실꼭지점을 클릭한 뒤 키보드 `Delete`를 누르면 해당 꼭지점을 삭제하고, 남은 꼭지점끼리 다시 연결돼야 한다.
- 이번 라운드 반영
- `useGisEdit`에 `isSnapEnabled` 상태와 `toggleSnapEnabled`를 추가하고, 툴바와 세션 팝업 스냅샷에 함께 노출했다.
- `GisEditToolbar`에 `꼭지점편집` 오른쪽 `스냅핑` 버튼을 추가했다.
- 처음 구현은 `MOVE`, `VERTEX_EDIT`에만 스냅을 붙여 `ADD`에서는 스냅핑이 동작하지 않는 문제가 있었다.
- 후속 보정으로 `ADD`, `MOVE`, `VERTEX_EDIT` 모두 현재 보이는 `tb_ms_swage_ppl` 원본 feature와 다른 preview feature를 수집해 OpenLayers `Snap` interaction을 함께 부착하도록 수정했다.
- `VERTEX_EDIT` handle feature에 선택 키를 부여해, 사용자가 주황색 실꼭지점을 클릭하면 선택 상태를 유지하도록 보강했다.
- 전역 `keydown`에서 `Delete`를 감지하되 `input`, `textarea`, `select`, contentEditable 포커스 중에는 무시하도록 방어했다.
- `Delete` 대상은 주황색 실꼭지점만 허용하고, 흰색 midpoint는 “새 점 삽입 전용”으로 유지했다.
- `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon` 기준 삭제 유틸을 추가했지만, 현재 운영 범위상 핵심 대상은 하수관로 선형이다.
- 선형 최소 꼭지점 규칙을 넣어 하수관로가 2점 미만이 되는 삭제는 차단했다.
- 남긴 판단
- 현재 스냅 대상은 지도 source에 로드된 하수관로 원본/preview만 사용하므로 화면 밖 또는 비로딩 객체까지 보장하지는 않는다.
- 겹쳐 있는 handle은 기존 구현처럼 하나의 묶음으로 표현되므로, 같은 좌표를 공유하는 raw vertex는 Delete 시 함께 제거될 수 있다.
### 4.17 2026-06-08 QGIS 기준 1차 스냅 재구현
- 사용자 재피드백 기준 이슈
- 스냅핑 `ON` 상태에서도 `ADD`가 기존 오수관에 붙지 않았다.
- `MOVE`, `VERTEX_EDIT`도 원본 하수관로를 기준으로 붙는 체감이 약했다.
- 원인 재정리
- 기존 구현은 “지도에 이미 feature로 올라와 있는 객체”를 즉석에서 수집해 snap 대상으로 쓰는 구조였다.
- 하수관로 원본이 WMS로만 보이는 경우에는 브라우저 메모리에 snap 기준 feature가 충분히 없을 수 있어, 사용자는 관로가 보여도 실제로는 스냅 기준 geometry가 없는 상태가 생긴다.
- 이번 1차 재구현 반영
- 범위를 `ADD`, `VERTEX_EDIT` 우선으로 줄여 시작했지만, 후속 보정으로 `MOVE`도 같은 원본 하수관로 snap source를 사용하도록 확장했다.
- 현재 화면 extent를 기준으로 `tb_ms_swage_ppl` WFS를 직접 다시 조회해 전용 `pipe snap source`를 만든다.
- `ADD`는 `Draw + Snap(source)` 구조로, `VERTEX_EDIT`는 custom vertex handle + `Snap(source)` 구조로 다시 연결했다.
- `MOVE`도 preview 수집 기반 fallback보다 원본 하수관로 WFS snap source를 우선 사용하도록 보강했다.
- `VERTEX_EDIT`에서 기존 관로를 편집하는 경우 자기 자신 원본 fid는 snap source에서 제외해 자기 geometry에 다시 달라붙는 문제를 줄였다.
- 지도 `moveend` 시 snap source를 다시 조회해 현재 보이는 하수관로 기준을 유지하도록 보강했다.
- 현재 한계
- 이번 1차는 `MOVE`를 아직 QGIS 기준으로 다시 고정하지 않았다.
- 수동 브라우저 검증 전이라 실제 체감과 시각 피드백은 아직 최종 확정 상태가 아니다.
### 4.18 2026-06-08 MOVE 기준점 재정렬 및 입력정보 조회 방어
- 사용자 재피드백 기준 이슈
- `ADD`, `VERTEX_EDIT`는 비교적 잘 붙는데 `MOVE`는 붙는 위치와 안 붙는 위치가 섞여 보였다.
- 이동 중 관로 상세 팝업이 열릴 때 `fetchPipeInputInfo`에서 `500`이 간헐적으로 발생했다.
- 원인 정리
- 기존 `MOVE`는 드래그 후 커서 좌표나 임의의 가장 가까운 꼭지점 기준으로 보정해, 사용자가 실제로 잡아 끈 관로 끝점과 snap 기준점이 달라질 수 있었다.
- 관로 입력정보 조회는 `fcltMngNo`, `mngInstCd`를 path에 그대로 넣고 있어 특수문자/임시값 케이스에 취약했다.
- 이번 라운드 반영
- `MOVE`는 관로 선택 순간 클릭 위치에서 가장 가까운 관로 꼭지점 meta를 `move grip`으로 저장한다.
- 드래그 시작/중/종료 모두 원본 geometry snapshot을 기준으로 다시 계산하고, 선택된 `move grip`이 원본 하수관로 snap source에 붙도록 전체 geometry를 재정렬한다.
- 즉 `MOVE`도 `ADD`, `VERTEX_EDIT`처럼 “명확한 한 점을 기준으로 붙는다”는 QGIS 관점에 더 가깝게 재해석했다.
- `GisEditPipeSessionPopup`의 입력정보 조회는 path segment를 `encodeURIComponent(...)` 처리하고, `NEW-*` 임시값은 조회하지 않도록 방어했다.
- 남긴 판단
- `MOVE` 체감이 여전히 어색하면 다음 단계는 grip point를 지도 위에 직접 시각화해 사용자가 “지금 어떤 점을 잡고 이동 중인지”를 보이게 해야 한다.
## 5. 기능별 현재 상태
### 5.1 `ADD`
@@ -14,6 +14,7 @@ const EMPTY_SNAPSHOT: GisEditSessionSnapshot = {
isEditMode: false,
selectedLayerName: "",
activeTool: null,
isSnapEnabled: false,
changes: [],
lastSavedPayload: null,
canUndo: false,