From 8737d1eb0a6082aa3d4653867e8e6459b91ec9d0 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Mon, 8 Jun 2026 17:40:10 +0900 Subject: [PATCH] =?UTF-8?q?gis=20=ED=8E=B8=EC=A7=91=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=ED=95=91,=20=EA=BC=AD=EC=A7=80=EC=A0=90=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gisEdit/GisEditPipeSessionPopup.tsx | 5 +- .../map/components/gisEdit/GisEditToolbar.tsx | 163 ++-- .../map/components/gisEdit/gisEditTypes.ts | 1 + .../map/components/gisEdit/useGisEdit.ts | 31 +- .../sinkhole/SinkholeLayerSettingPanel.tsx | 2 +- apps/sn/app/(map-layout)/map/page.tsx | 849 +++++++++++++++++- .../002-qa-SFR-012-003-하수관로편집-통합검증결과.md | 74 ++ .../001-fe-SFR-012-003-하수관로편집-작업카드.md | 9 + .../001-fe-SFR-012-003-하수관로편집-통합구현기록.md | 54 ++ .../ui/GisEditSessionPopupContent.tsx | 1 + 10 files changed, 1115 insertions(+), 74 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx index 01e462338..c41e61ab6 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx @@ -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) => ({ diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx index 07fc557e1..1ec81db2e 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx @@ -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 (
-
-
- 하수관로 편집 +
+
+ 하수관로 편집 +
+
+
+ {TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => { + const isActive = activeTool === tool; + const isToolEnabled = + canUseTools && allowedTools.includes(tool); + if (tool === "VERTEX_EDIT") { + return ( +
+ + +
+ ); + } + return ( + + ); + })} +
- {TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => { - const isActive = activeTool === tool; - const isToolEnabled = - canUseTools && allowedTools.includes(tool); - return ( - - ); - })} - +
+ - + - + +
diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts index 4c627942c..930e48c07 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts @@ -103,6 +103,7 @@ export type GisEditSessionSnapshot = { isEditMode: boolean; selectedLayerName: string; activeTool: GisEditTool | null; + isSnapEnabled: boolean; changes: GisEditChange[]; lastSavedPayload: GisEditSavePayload | null; canUndo: boolean; diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts index 47e91c0b9..fd8e726c3 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts @@ -487,6 +487,8 @@ export const useGisEdit = (treeData: any[]) => { const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl"); // 1차 구현에서는 activeTool과 지도 선택 이벤트를 조합해 변경분을 로컬 세션에 누적한다. const [activeTool, setActiveTool] = useState(null); + // QGIS 유사 스냅핑 on/off는 MOVE, VERTEX_EDIT 상호작용에서 함께 참조한다. + const [isSnapEnabled, setIsSnapEnabled] = useState(false); // 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지한다. const [changes, setChanges] = useState([]); 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, diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx index 5dd35eb37..d9a182009 100644 --- a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx @@ -33,7 +33,7 @@ export default function SinkholeLayerSettingPanel({ }; return ( -
+