From 6d0461eaef3549079d13f02f8766f708528a6055 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Fri, 15 May 2026 15:03:00 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=98=EC=88=98=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=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 --- .../analysisMenu/AnalysisPopupMenu.tsx | 28 +- .../gisEdit/GisEditSessionPanel.tsx | 142 ++ .../map/components/gisEdit/GisEditToolbar.tsx | 119 ++ .../map/components/gisEdit/gisEditTypes.ts | 62 + .../map/components/gisEdit/useGisEdit.ts | 710 +++++++++ apps/sn/app/(map-layout)/map/page.tsx | 1331 ++++++++++++++++- apps/sn/docs/기준문서/005-상태관리정책.md | 15 + apps/sn/docs/기준문서/006-검증정책.md | 12 +- apps/sn/docs/기준문서/008-설계정책.md | 14 + apps/sn/docs/기준문서/014-문서관리정책.md | 155 ++ .../docs/기준문서/015-하네스엔지니어링정책.md | 126 ++ .../001-qa-SFR-012-003-하수관로편집-통합검증계획.md | 157 ++ .../002-qa-SFR-012-003-하수관로편집-통합검증결과.md | 165 ++ .../001-fe-SFR-012-003-하수관로편집-작업카드.md | 216 +++ .../001-fe-SFR-012-003-하수관로편집-통합구현기록.md | 227 +++ apps/sn/package.json | 1 + .../windowPopup/model/popupDefinitions.ts | 6 + .../src/features/windowPopup/model/types.ts | 5 + apps/sn/src/features/windowPopup/registry.tsx | 5 + .../ui/GisEditSessionPopupContent.tsx | 74 + apps/sn/src/types/lucide-react.d.ts | 44 + .../business_components/src/ui/map/Gis.js | 2 +- .../src/ui/map/GisSelect.js | 16 +- 23 files changed, 3621 insertions(+), 11 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts create mode 100644 apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts create mode 100644 apps/sn/docs/기준문서/014-문서관리정책.md create mode 100644 apps/sn/docs/기준문서/015-하네스엔지니어링정책.md create mode 100644 apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md create mode 100644 apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md create mode 100644 apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md create mode 100644 apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md create mode 100644 apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx create mode 100644 apps/sn/src/types/lucide-react.d.ts diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index 53cf75946..b7686f4b0 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { ChartColumnBig, ChevronLeft, @@ -18,6 +18,8 @@ import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitio type Props = { onOpenPopup: (popupKey: WindowPopupKey) => void; onOpenInfoSearch: () => void; + onToggleGisEdit?: () => void; + isGisEditActive?: boolean; }; const ANALYSIS_POPUP_ITEMS: Array<{ @@ -34,10 +36,22 @@ const ANALYSIS_POPUP_ITEMS: Array<{ { key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert }, ]; -export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Props) { +export default function AnalysisPopupMenu({ + onOpenPopup, + onOpenInfoSearch, + onToggleGisEdit, + isGisEditActive = false, +}: Props) { const [isOpen, setIsOpen] = useState(true); const [activeKey, setActiveKey] = useState(null); + // GIS 편집 모드가 꺼지면 메뉴 강조 상태도 함께 정리해 사용자가 현재 모드를 오해하지 않게 한다. + useEffect(() => { + if (!isGisEditActive && activeKey === "gisEditPopup") { + setActiveKey(null); + } + }, [activeKey, isGisEditActive]); + return (
@@ -53,7 +67,9 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
{ANALYSIS_POPUP_ITEMS.map((item) => { const Icon = item.icon; - const isActive = activeKey === item.key; + const isActive = item.key === "gisEditPopup" + ? isGisEditActive + : activeKey === item.key; return ( + ))} + +); + +export default function GisEditSessionPanel({ + isEditMode, + selectedLayerName, + activeTool, + changes, + lastSavedPayload, + canUndo, + focusedChangeId, + onRemoveLastChange, + onClearChanges, + onFocusChange, + className = "", +}: Props) { + return ( +
+
+
+
편집 세션
+
+ 레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"} +
+
+ {isEditMode ? ( +
+ FE 로컬 세션 +
+ ) : ( +
+ 지도 미리보기 유지 +
+ )} +
+ +
+ + +
+ 총 {changes.length}건 +
+
+ +
+ {changes.length === 0 ? ( +
+ 저장할 편집 변경분이 없습니다. +
+ ) : ( +
+
+ +
+
+ )} +
+ + {lastSavedPayload ? ( +
+
저장 payload 미리보기
+
+            {JSON.stringify(lastSavedPayload, null, 2)}
+          
+
+ ) : null} +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx new file mode 100644 index 000000000..e1d4b6930 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { + Move, + PenTool, + Plus, + Trash2, +} from "lucide-react"; + +import type { GisEditLayerInfo, GisEditTool } from "./gisEditTypes"; + +type Props = { + isEditMode: boolean; + editableLayers: GisEditLayerInfo[]; + selectedLayerId: string; + selectedLayer: GisEditLayerInfo | null; + activeTool: GisEditTool | null; + canUseTools: boolean; + canSave: boolean; + statusMessage: string; + onSelectLayer: (layerId: string) => void; + onSelectTool: (tool: GisEditTool) => void; + onSave: () => void; +}; + +const TOOL_BUTTONS: Array<{ + tool: GisEditTool; + label: string; + icon: typeof Plus; +}> = [ + { tool: "ADD", label: "추가", icon: Plus }, + { tool: "MOVE", label: "이동", icon: Move }, + { tool: "VERTEX_EDIT", label: "꼭지점편집", icon: PenTool }, + { tool: "DELETE", label: "삭제", icon: Trash2 }, +]; + +export default function GisEditToolbar({ + isEditMode, + editableLayers, + selectedLayerId, + selectedLayer, + activeTool, + canUseTools, + canSave, + statusMessage, + onSelectLayer, + onSelectTool, + onSave, +}: Props) { + if (!isEditMode) return null; + + return ( +
+
+
+ + + {TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => { + const isActive = activeTool === tool; + const isToolEnabled = + canUseTools && Boolean(selectedLayer?.allowedTools.includes(tool)); + return ( + + ); + })} + + +
+
+ +
+ {statusMessage} +
+ {activeTool === "MOVE" ? ( +
+ 이동 대상 선택 후 파란 미리보기 객체를 드래그해 위치를 조정하세요. +
+ ) : null} + {activeTool === "VERTEX_EDIT" ? ( +
+ 편집 대상 선택 후 주황색 꼭지점 handle을 드래그해 형상을 조정하세요. +
+ ) : null} +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts new file mode 100644 index 000000000..34e61e77c --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts @@ -0,0 +1,62 @@ +export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE"; + +export type GisEditGeometryType = "Point" | "LineString" | "Polygon"; + +export type GisEditPreviewColors = { + pointColor: string; + lineColor: string; + fillColor: string; +}; + +export type GisEditLayerInfo = { + layerId: string; + layerName: string; + geometryType: GisEditGeometryType; + editableYn: "Y" | "N"; + allowedTools: GisEditTool[]; + previewColors: GisEditPreviewColors; + disabledReason?: string; +}; + +export type GisEditChange = { + changeId: string; + editType: GisEditTool; + layerId: string; + layerName: string; + objectId?: string; + objectLabel: string; + beforeGeometry?: Record | null; + afterGeometry?: Record | null; + summary: string; + createdAt: string; + source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW"; +}; + +export type GisEditSavePayload = { + screenId: string; + layerId: string; + layerName: string; + saveTargetType: "VERSION"; + view: { + srs: string; + bbox: string | null; + }; + changes: Array<{ + editType: GisEditTool; + objectId?: string; + clientTempId?: string; + beforeGeometry?: Record | null; + afterGeometry?: Record | null; + geometry?: Record | null; + }>; +}; + +export type GisEditSessionSnapshot = { + isEditMode: boolean; + selectedLayerName: string; + activeTool: GisEditTool | null; + changes: GisEditChange[]; + lastSavedPayload: GisEditSavePayload | null; + canUndo: boolean; + focusedChangeId: string | null; +}; diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts new file mode 100644 index 000000000..e99c7c4d9 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts @@ -0,0 +1,710 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; +import GeoJSON from "ol/format/GeoJSON.js"; + +import type { + GisEditChange, + GisEditGeometryType, + GisEditLayerInfo, + GisEditPreviewColors, + GisEditSavePayload, + GisEditTool, +} from "./gisEditTypes"; + +type GisEditInteractionRequest = { + changeId: string; + editType: "MOVE" | "VERTEX_EDIT"; +}; + +const buildPreviewFocusMessage = ( + change: Pick, +) => { + if (change.editType === "MOVE") { + return `${change.layerName} ${change.objectLabel} 미리보기 객체를 드래그해 위치를 조정하세요.`; + } + if (change.editType === "VERTEX_EDIT") { + return `${change.layerName} ${change.objectLabel}의 주황색 실꼭지점을 드래그하거나, 선분 위 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`; + } + if (change.editType === "DELETE") { + return `${change.layerName} ${change.objectLabel} 삭제 미리보기를 유지 중입니다.`; + } + return `${change.layerName} ${change.objectLabel} 신규 객체 미리보기를 확인하세요.`; +}; + +const EDITABLE_LAYER_RULES: Record< + string, + Omit +> = { + tb_ms_swage_ppl: { + layerName: "하수관로", + geometryType: "LineString", + allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"], + previewColors: { + pointColor: "rgba(22, 163, 74, 0.92)", + lineColor: "rgba(22, 163, 74, 0.95)", + fillColor: "rgba(22, 163, 74, 0.18)", + }, + }, + tb_ms_swage_mhl: { + layerName: "하수맨홀", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(5, 150, 105, 0.92)", + lineColor: "rgba(5, 150, 105, 0.95)", + fillColor: "rgba(5, 150, 105, 0.18)", + }, + }, + tb_ms_swage_pmstn: { + layerName: "하수펌프장", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(59, 130, 246, 0.92)", + lineColor: "rgba(59, 130, 246, 0.95)", + fillColor: "rgba(59, 130, 246, 0.18)", + }, + }, + tb_il_swage_prcs_fclt: { + layerName: "하수처리시설", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(168, 85, 247, 0.92)", + lineColor: "rgba(168, 85, 247, 0.95)", + fillColor: "rgba(168, 85, 247, 0.18)", + }, + }, + tb_ms_swage_prcs_zone: { + layerName: "하수처리구역", + geometryType: "Polygon", + allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"], + previewColors: { + pointColor: "rgba(249, 115, 22, 0.92)", + lineColor: "rgba(249, 115, 22, 0.95)", + fillColor: "rgba(249, 115, 22, 0.22)", + }, + }, + tb_ms_stofcbr: { + layerName: "우수토실", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(234, 88, 12, 0.92)", + lineColor: "rgba(234, 88, 12, 0.95)", + fillColor: "rgba(234, 88, 12, 0.18)", + }, + }, + om_dt_untrt_swg_rain: { + layerName: "중점우수토실", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(220, 38, 38, 0.92)", + lineColor: "rgba(220, 38, 38, 0.95)", + fillColor: "rgba(220, 38, 38, 0.18)", + }, + }, +}; + +const FLOW_DIRECTION_SRS = "EPSG:5179"; +const SCREEN_ID = "SN_MN010301"; + +const normalizeLayerIdCandidate = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + + const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw; + return withoutNamespace.split(".")[0] ?? withoutNamespace; +}; + +// 현재 레이어 트리 구조 안에서 실제 편집 대상으로 풀 수 있는 레이어만 추출한다. +const getLayerTypeName = (layerInfo: any) => + String(layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "").trim(); + +const getLayerName = (layerInfo: any) => + String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim(); + +const normalizeGeometryType = (value: unknown): GisEditGeometryType => { + const normalized = String(value ?? "").toLowerCase(); + if (normalized.includes("polygon")) return "Polygon"; + if (normalized.includes("line")) return "LineString"; + return "Point"; +}; + +const getColorString = (value: unknown) => { + const resolved = String(value ?? "").trim(); + return resolved || null; +}; + +const withAlpha = (color: string | null | undefined, alpha: number) => { + const resolvedColor = String(color ?? "").trim(); + if (!resolvedColor) return null; + + const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i); + if (rgbaMatch) { + const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "") + .split(",") + .map((part) => part.trim()); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + + const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hexMatch) { + const hex = hexMatch[1] ?? ""; + const normalized = hex.length === 3 + ? hex.split("").map((char) => char + char).join("") + : hex; + const red = parseInt(normalized.slice(0, 2), 16); + const green = parseInt(normalized.slice(2, 4), 16); + const blue = parseInt(normalized.slice(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + + return resolvedColor; +}; + +const resolvePreviewColors = ( + layerInfo: any, + fallback: GisEditPreviewColors, +): GisEditPreviewColors => { + const lineColor = + withAlpha( + getColorString( + layerInfo?.lineColr ?? + layerInfo?.line_colr ?? + layerInfo?.lineColor ?? + layerInfo?.fontInsdColr ?? + layerInfo?.font_insd_colr, + ), + 0.95, + ) ?? fallback.lineColor; + const pointColor = + withAlpha( + getColorString( + layerInfo?.lineColr ?? + layerInfo?.line_colr ?? + layerInfo?.fontInsdColr ?? + layerInfo?.font_insd_colr ?? + layerInfo?.fontExtnlColr ?? + layerInfo?.font_extnl_colr, + ), + 0.92, + ) ?? fallback.pointColor; + const fillColor = + withAlpha( + getColorString( + layerInfo?.spacColr ?? + layerInfo?.spac_colr ?? + layerInfo?.spaceColor ?? + layerInfo?.lineColr ?? + layerInfo?.line_colr, + ), + 0.22, + ) ?? fallback.fillColor; + + return { + pointColor, + lineColor, + fillColor, + }; +}; + +// FE 1차 구현은 시설 레이어 주요 7종을 기본 편집 후보로 열고, treeData에 없더라도 fallback 후보를 제공한다. +const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => { + const results = new Map(); + + const visit = (items: any[]) => { + items.forEach((item) => { + const data = item?.data ?? item; + const layerTypeName = getLayerTypeName(data); + const layerRule = EDITABLE_LAYER_RULES[layerTypeName]; + + if (layerRule && !results.has(layerTypeName)) { + results.set(layerTypeName, { + layerId: layerTypeName, + layerName: layerRule.layerName, + geometryType: layerRule.geometryType, + editableYn: "Y", + allowedTools: layerRule.allowedTools, + previewColors: resolvePreviewColors(data, layerRule.previewColors), + }); + } + + if (Array.isArray(item?.children)) { + visit(item.children); + } + if (Array.isArray(data?.children)) { + visit(data.children); + } + }); + }; + + visit(Array.isArray(treeData) ? treeData : []); + + if (results.size === 0) { + Object.entries(EDITABLE_LAYER_RULES).forEach(([layerId, layerRule]) => { + results.set(layerId, { + layerId, + layerName: layerRule.layerName, + geometryType: layerRule.geometryType, + editableYn: "Y", + allowedTools: layerRule.allowedTools, + previewColors: layerRule.previewColors, + }); + }); + } + + return Array.from(results.values()); +}; + +// 지도에서 선택한 OpenLayers Feature의 현재 geometry를 저장 payload 형태로 스냅샷한다. +const toGeometryObject = (featureData: any) => { + if (!featureData || typeof featureData.getGeometry !== "function") return null; + + try { + return new GeoJSON().writeGeometryObject(featureData.getGeometry(), { + featureProjection: FLOW_DIRECTION_SRS, + dataProjection: FLOW_DIRECTION_SRS, + }) as Record; + } catch (error) { + console.error("GIS 편집 geometry 변환 실패:", error); + return null; + } +}; + +// 저장 시점의 지도 범위를 함께 남겨 BE가 편집 세션 컨텍스트를 해석할 수 있게 한다. +const buildBbox = (map: any) => { + try { + const size = map?.getSize?.(); + const view = map?.getView?.(); + if (!size || !view?.calculateExtent) return null; + return view.calculateExtent(size).map((value: number) => value.toFixed(3)).join(","); + } catch (error) { + console.error("GIS 편집 BBOX 계산 실패:", error); + return null; + } +}; + +const getFeatureLayerId = (featureData: any) => { + if (typeof featureData?.get === "function") { + const previewLayerId = normalizeLayerIdCandidate(featureData.get("layerId")); + if (previewLayerId) return previewLayerId; + } + if (typeof featureData?.getId === "function") { + return normalizeLayerIdCandidate(featureData.getId()); + } + if (typeof featureData?.id_ === "string") { + return normalizeLayerIdCandidate(featureData.id_); + } + return normalizeLayerIdCandidate(featureData?.lyrTypeNm ?? featureData?.lyrTyNm ?? ""); +}; + +const getFeatureObjectId = (featureData: any) => + String( + featureData?.get?.("objectId") ?? + featureData?.values_?.fclt_mng_no ?? + featureData?.values_?.swtrmt_fclt_cd ?? + featureData?.fcltMngNo ?? + featureData?.fid ?? + "", + ).trim(); + +const createChangeId = (prefix: string, sequence: number) => { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${prefix}-${crypto.randomUUID()}`; + } + return `${prefix}-${Date.now()}-${sequence}`; +}; + +export const useGisEdit = (treeData: any[]) => { + const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]); + // GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다. + const [isEditMode, setIsEditMode] = useState(false); + // 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다. + const [selectedLayerId, setSelectedLayerId] = useState(""); + // 1차 구현에서는 activeTool과 지도 선택 이벤트를 조합해 변경분을 로컬 세션에 누적한다. + const [activeTool, setActiveTool] = useState(null); + // 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지하고 BE API 연결 전 payload 검토 근거로 사용한다. + const [changes, setChanges] = useState([]); + const [statusMessage, setStatusMessage] = useState( + "GIS편집 버튼을 켜면 레이어를 선택하고 편집 도구를 사용할 수 있습니다.", + ); + // 실제 저장 대신 마지막으로 조립한 payload를 세션 패널에서 미리보기로 노출한다. + const [lastSavedPayload, setLastSavedPayload] = useState(null); + const [pendingInteraction, setPendingInteraction] = useState(null); + const [focusedChangeId, setFocusedChangeId] = useState(null); + const changeSequenceRef = useRef(0); + const latestSelectionRef = useRef<{ signature: string; timestamp: number } | null>(null); + + const selectedLayer = useMemo( + () => editableLayers.find((layer) => layer.layerId === selectedLayerId) ?? null, + [editableLayers, selectedLayerId], + ); + + const canUseTools = Boolean(selectedLayer); + const canSave = changes.length > 0 && Boolean(selectedLayer); + + const appendChange = useCallback((change: GisEditChange) => { + setLastSavedPayload(null); + setFocusedChangeId(change.changeId); + setChanges((previous) => [change, ...previous]); + }, []); + + const focusChange = useCallback((changeId: string) => { + const targetChange = changes.find((change) => change.changeId === changeId); + if (!targetChange) return; + + setFocusedChangeId(changeId); + if (targetChange.editType === "MOVE" || targetChange.editType === "VERTEX_EDIT") { + setPendingInteraction({ changeId, editType: targetChange.editType }); + } else { + setPendingInteraction(null); + } + setStatusMessage(buildPreviewFocusMessage(targetChange)); + }, [changes]); + + const findExistingObjectChange = useCallback(( + editType: GisEditTool, + layerId: string, + objectId: string, + ) => + changes.find( + (change) => + change.editType === editType && + change.layerId === layerId && + String(change.objectId ?? "") === objectId, + ) ?? null, + [changes]); + + // 편집 모드를 끄더라도 작업 중 세션을 잃지 않도록 변경분은 유지하고 도구 상태만 정리한다. + const toggleEditMode = useCallback(() => { + setIsEditMode((previous) => { + const next = !previous; + setActiveTool(null); + setPendingInteraction(null); + setFocusedChangeId(null); + latestSelectionRef.current = null; + setStatusMessage( + next + ? "편집 대상 레이어를 선택한 뒤 지도에서 편집 작업을 진행하세요." + : "GIS 편집 모드를 종료했습니다. 편집 세션은 유지됩니다.", + ); + return next; + }); + }, []); + + const selectLayer = useCallback((layerId: string) => { + setSelectedLayerId(layerId); + setActiveTool(null); + setPendingInteraction(null); + setFocusedChangeId(null); + latestSelectionRef.current = null; + setLastSavedPayload(null); + if (!layerId) { + setStatusMessage("편집 대상 레이어를 선택하세요."); + return; + } + + const layer = editableLayers.find((item) => item.layerId === layerId); + if (!layer) { + setStatusMessage("선택한 레이어 정보를 찾을 수 없습니다."); + return; + } + + setStatusMessage( + `${layer.layerName} 레이어를 편집 대상으로 설정했습니다. 추가 또는 기존 객체 선택 기반 편집을 진행하세요.`, + ); + }, [editableLayers]); + + // ADD는 즉시 로컬 초안을 만들고, 나머지 도구는 다음 지도 선택을 편집 세션 추가 이벤트로 해석한다. + const activateTool = useCallback((tool: GisEditTool, map: any) => { + if (!selectedLayer) { + setStatusMessage("레이어를 먼저 선택해야 편집 도구를 사용할 수 있습니다."); + return; + } + + if (!selectedLayer.allowedTools.includes(tool)) { + setStatusMessage(`${selectedLayer.layerName} 레이어에서는 ${tool} 도구를 사용할 수 없습니다.`); + return; + } + + setActiveTool(tool); + setPendingInteraction(null); + latestSelectionRef.current = null; + + if (tool === "ADD") { + const addGuideMessage = + selectedLayer.geometryType === "Point" + ? `${selectedLayer.layerName} 신규 위치를 지도에서 클릭하세요.` + : selectedLayer.geometryType === "LineString" + ? `${selectedLayer.layerName} 선형을 지도에서 연속 클릭으로 입력한 뒤 더블클릭으로 마무리하세요.` + : `${selectedLayer.layerName} 영역을 지도에서 연속 클릭으로 입력한 뒤 더블클릭으로 마무리하세요.`; + setStatusMessage(addGuideMessage); + return; + } + + const toolLabel = tool === "MOVE" + ? "이동" + : tool === "VERTEX_EDIT" + ? "꼭지점편집" + : "삭제"; + const vertexGuideMessage = + tool === "VERTEX_EDIT" + ? `지도에서 ${selectedLayer.layerName} 대상 객체를 클릭하면 실꼭지점은 주황색, 새 점 삽입용 midpoint는 흰색으로 표시됩니다.` + : null; + setStatusMessage( + vertexGuideMessage ?? + `지도에서 ${selectedLayer.layerName} 대상 객체를 클릭하면 ${toolLabel} 변경분을 편집 세션에 추가합니다.`, + ); + }, [appendChange, selectedLayer]); + + const registerAddGeometry = useCallback((geometry: Record | null) => { + if (!selectedLayer || !geometry) { + setStatusMessage("추가 geometry를 확인하지 못했습니다."); + return null; + } + + changeSequenceRef.current += 1; + const clientTempId = `NEW-${changeSequenceRef.current}`; + const changeId = createChangeId(`ADD-${selectedLayer.layerId}`, changeSequenceRef.current); + + appendChange({ + changeId, + editType: "ADD", + layerId: selectedLayer.layerId, + layerName: selectedLayer.layerName, + objectId: clientTempId, + objectLabel: clientTempId, + beforeGeometry: null, + afterGeometry: geometry, + summary: `${selectedLayer.layerName} 신규 객체를 지도에서 직접 입력했습니다.`, + createdAt: new Date().toISOString(), + source: "MAP_DRAW", + }); + setStatusMessage( + `${selectedLayer.layerName} 신규 객체를 추가했습니다. 계속 추가하려면 지도에서 다음 형상을 입력하세요.`, + ); + return changeId; + }, [appendChange, selectedLayer]); + + // 편집 모드 중 지도에서 선택한 객체를 현재 activeTool 기준 변경분으로 변환한다. + const registerFeatureSelection = useCallback((featureData: any) => { + if (!isEditMode) return { handled: false }; + + const previewChangeId = String(featureData?.get?.("changeId") ?? "").trim(); + if (previewChangeId) { + const previewChange = changes.find((change) => change.changeId === previewChangeId); + if (!previewChange) return { handled: true }; + + focusChange(previewChangeId); + return { + handled: true, + changeId: previewChangeId, + editType: previewChange.editType, + }; + } + + if (!activeTool || !selectedLayer) return { handled: false }; + if (activeTool === "ADD") return { handled: true }; + + const featureLayerId = getFeatureLayerId(featureData); + if (featureLayerId !== selectedLayer.layerId) { + setStatusMessage(`현재 선택 레이어는 ${selectedLayer.layerName}입니다. 같은 레이어의 객체를 선택하세요.`); + return { handled: true }; + } + + const objectId = getFeatureObjectId(featureData); + if (!objectId) { + setStatusMessage("선택 객체의 관리번호를 찾을 수 없어 편집 세션에 추가하지 못했습니다."); + return { handled: true }; + } + + const selectionSignature = `${activeTool}:${selectedLayer.layerId}:${objectId}`; + const now = Date.now(); + if ( + latestSelectionRef.current?.signature === selectionSignature && + now - latestSelectionRef.current.timestamp < 400 + ) { + return { handled: true }; + } + latestSelectionRef.current = { signature: selectionSignature, timestamp: now }; + + const existingChange = findExistingObjectChange(activeTool, selectedLayer.layerId, objectId); + if (existingChange) { + focusChange(existingChange.changeId); + return { + handled: true, + changeId: existingChange.changeId, + editType: existingChange.editType, + }; + } + + const geometryObject = toGeometryObject(featureData); + const geometryType = normalizeGeometryType(geometryObject?.type); + const nextGeometry = + activeTool === "MOVE" + ? geometryObject + : activeTool === "VERTEX_EDIT" + ? geometryObject + : activeTool === "DELETE" + ? null + : geometryObject; + const summaryMap: Record = { + MOVE: `${selectedLayer.layerName} ${objectId} 객체를 선택했습니다. 지도에서 드래그해 위치를 조정하세요.`, + VERTEX_EDIT: `${selectedLayer.layerName} ${objectId} 객체를 선택했습니다. 주황색 실꼭지점을 드래그하거나 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`, + DELETE: `${selectedLayer.layerName} ${objectId} 객체를 임시 삭제 대상으로 표시했습니다.`, + ADD: `${selectedLayer.layerName} ${objectId} 객체를 추가했습니다.`, + }; + changeSequenceRef.current += 1; + const changeId = createChangeId(`${activeTool}-${objectId}`, changeSequenceRef.current); + + appendChange({ + changeId, + editType: activeTool, + layerId: selectedLayer.layerId, + layerName: selectedLayer.layerName, + objectId, + objectLabel: objectId, + beforeGeometry: geometryObject, + afterGeometry: nextGeometry, + summary: + activeTool === "VERTEX_EDIT" && geometryType === "Point" + ? `${selectedLayer.layerName} ${objectId}는 점형 객체여서 꼭지점편집 대신 geometry 검토 대상으로 기록했습니다.` + : summaryMap[activeTool], + createdAt: new Date().toISOString(), + source: "MAP_SELECTION", + }); + setStatusMessage(summaryMap[activeTool]); + setFocusedChangeId(changeId); + if (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") { + if (activeTool === "VERTEX_EDIT" && geometryType === "Point") { + setPendingInteraction(null); + } else { + setPendingInteraction({ changeId, editType: activeTool }); + } + } else { + setPendingInteraction(null); + } + return { handled: true, changeId, editType: activeTool }; + }, [activeTool, appendChange, findExistingObjectChange, focusChange, isEditMode, selectedLayer]); + + // 저장 API가 붙기 전까지는 실제 전송 대신 최종 payload를 조립해 검증 패널에 남긴다. + const save = useCallback((map: any) => { + if (!selectedLayer || changes.length === 0) { + setStatusMessage("저장할 편집 변경분이 없습니다."); + return null; + } + + const payload: GisEditSavePayload = { + screenId: SCREEN_ID, + layerId: selectedLayer.layerId, + layerName: selectedLayer.layerName, + saveTargetType: "VERSION", + view: { + srs: FLOW_DIRECTION_SRS, + bbox: buildBbox(map), + }, + changes: changes.map((change) => ({ + editType: change.editType, + objectId: change.editType === "ADD" ? undefined : change.objectId, + clientTempId: change.editType === "ADD" ? change.objectId : undefined, + beforeGeometry: change.beforeGeometry, + afterGeometry: change.editType === "DELETE" ? null : change.afterGeometry, + geometry: change.editType === "ADD" ? change.afterGeometry : undefined, + })), + }; + + setLastSavedPayload(payload); + setStatusMessage("백엔드 저장 API가 아직 없어 payload 미리보기만 생성했습니다. 이 구조를 기준으로 BE와 연동하면 됩니다."); + return payload; + }, [changes, selectedLayer]); + + const removeLastChange = useCallback(() => { + setLastSavedPayload(null); + setChanges((previous) => { + if (previous.length === 0) return previous; + const [removedChange, ...rest] = previous; + if (!removedChange) return previous; + if (focusedChangeId === removedChange.changeId) { + setFocusedChangeId(rest[0]?.changeId ?? null); + } + if (pendingInteraction?.changeId === removedChange.changeId) { + setPendingInteraction(null); + } + latestSelectionRef.current = null; + return rest; + }); + setStatusMessage("가장 최근 편집 변경분을 세션에서 제거했습니다."); + }, [focusedChangeId, pendingInteraction]); + + const clearChanges = useCallback(() => { + setLastSavedPayload(null); + setChanges([]); + setActiveTool(null); + setPendingInteraction(null); + setFocusedChangeId(null); + latestSelectionRef.current = null; + setStatusMessage("편집 세션을 초기화했습니다."); + }, []); + + const updateChangeGeometry = useCallback(( + changeId: string, + afterGeometry: Record | null, + summary: string, + ) => { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === changeId + ? { + ...change, + afterGeometry, + summary, + } + : change, + ), + ); + setFocusedChangeId(changeId); + setStatusMessage(summary); + }, []); + + const clearPendingInteraction = useCallback(() => { + setPendingInteraction(null); + }, []); + + const announceStatus = useCallback((message: string) => { + setStatusMessage(message); + }, []); + + return { + isEditMode, + editableLayers, + selectedLayer, + selectedLayerId, + activeTool, + changes, + statusMessage, + canUseTools, + canSave, + lastSavedPayload, + pendingInteraction, + focusedChangeId, + pendingApiMessage: + "현재 구현은 FE 로컬 편집 세션과 저장 payload 미리보기까지 포함합니다. BE 저장 API(`/api/v1/map/gisEditSessions/save`) 연결 전까지는 실제 저장 대신 payload를 검토합니다.", + canUndo: changes.length > 0, + toggleEditMode, + selectLayer, + activateTool, + registerAddGeometry, + registerFeatureSelection, + save, + removeLastChange, + clearChanges, + updateChangeGeometry, + clearPendingInteraction, + focusChange, + announceStatus, + }; +}; diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 23a6eefc7..2a2b6f0a0 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -21,13 +21,20 @@ import dynamic from "next/dynamic"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePathname } from "next/navigation"; +import Collection from "ol/Collection.js"; +import Feature from "ol/Feature.js"; import GeoJSON from "ol/format/GeoJSON.js"; +import Draw from "ol/interaction/Draw.js"; +import Translate from "ol/interaction/Translate.js"; +import Fill from "ol/style/Fill.js"; import Point from "ol/geom/Point.js"; import VectorLayer from "ol/layer/Vector.js"; import { unByKey } from "ol/Observable.js"; import VectorSource from "ol/source/Vector.js"; +import CircleStyle from "ol/style/Circle.js"; import Stroke from "ol/style/Stroke.js"; import Style from "ol/style/Style.js"; +import Text from "ol/style/Text.js"; import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; @@ -42,6 +49,9 @@ import useSearchModalStore from "@/store/useSearchModalStore"; import useStore from "@/store/useStore"; import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel"; +import GisEditToolbar from "./components/gisEdit/GisEditToolbar"; +import { useGisEdit } from "./components/gisEdit/useGisEdit"; +import type { GisEditSessionSnapshot } from "./components/gisEdit/gisEditTypes"; import { PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, type PipeAnalysisDetailRow, @@ -92,6 +102,8 @@ const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13; const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)"; const FLOW_DIRECTION_NETWORK_CORRECTION_COLOR = "rgba(0, 168, 132, 0.95)"; const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35; +const GIS_EDIT_PREVIEW_LAYER_ID = "SN_GIS_EDIT_PREVIEW"; +const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES"; type FlowDirectionUsageStyle = { color: string; @@ -206,6 +218,625 @@ const getFlowDirectionFeatureCacheKey = (feature: any) => { return `geom:${String(usageCode).trim()}:${geometryKey}`; }; +const getGeometryExtentCenterPoint = (geometry: any) => { + const extent = geometry?.getExtent?.(); + if (!extent || extent.length < 4) return null; + + const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent; + return new Point([(minX + maxX) / 2, (minY + maxY) / 2]); +}; + +// MultiPolygon/MultiLineString preview 라벨이 각 조각마다 반복되지 않도록 대표 지점 1곳에만 텍스트를 배치한다. +const getGisEditPreviewLabelGeometry = (geometry: any) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return null; + + if (geometryType === "Point") { + return geometry; + } + + if (geometryType === "Polygon") { + return geometry.getInteriorPoint?.() ?? getGeometryExtentCenterPoint(geometry); + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getPolygons?.() ?? []; + if (polygons.length > 0) { + const representativePolygon = polygons.reduce((largest: any, current: any) => + (current?.getArea?.() ?? 0) > (largest?.getArea?.() ?? 0) ? current : largest, + polygons[0]); + return representativePolygon?.getInteriorPoint?.() ?? getGeometryExtentCenterPoint(representativePolygon); + } + return getGeometryExtentCenterPoint(geometry); + } + + return getGeometryExtentCenterPoint(geometry); +}; + +const createGisEditPreviewStyle = (feature: any) => { + const editType = String(feature.get("editType") ?? ""); + const geometry = feature.getGeometry?.(); + const geometryType = geometry?.getType?.(); + const previewLabel = String(feature.get("previewLabel") ?? "").trim(); + const isInteractionTarget = Boolean(feature.get("isInteractionTarget")); + const isDragging = Boolean(feature.get("isDragging")); + const layerPointColor = String(feature.get("layerPointColor") ?? "").trim(); + const layerLineColor = String(feature.get("layerLineColor") ?? "").trim(); + const layerFillColor = String(feature.get("layerFillColor") ?? "").trim(); + const pointColor = editType === "DELETE" + ? "rgba(220, 38, 38, 0.85)" + : editType === "VERTEX_EDIT" + ? "rgba(245, 158, 11, 0.92)" + : editType === "MOVE" + ? layerPointColor || "rgba(37, 99, 235, 0.92)" + : layerPointColor || "rgba(22, 163, 74, 0.92)"; + const lineColor = editType === "DELETE" + ? "rgba(220, 38, 38, 0.9)" + : editType === "VERTEX_EDIT" + ? "rgba(245, 158, 11, 0.95)" + : editType === "MOVE" + ? layerLineColor || "rgba(37, 99, 235, 0.95)" + : layerLineColor || "rgba(22, 163, 74, 0.95)"; + const fillColor = editType === "DELETE" + ? "rgba(220, 38, 38, 0.18)" + : editType === "VERTEX_EDIT" + ? "rgba(245, 158, 11, 0.2)" + : editType === "MOVE" + ? layerFillColor || "rgba(37, 99, 235, 0.18)" + : layerFillColor || "rgba(22, 163, 74, 0.18)"; + const highlightColor = isDragging + ? "rgba(14, 116, 144, 0.98)" + : isInteractionTarget + ? "rgba(15, 23, 42, 0.9)" + : lineColor; + const emphasisWidth = isDragging ? 7 : isInteractionTarget ? 6 : 4; + const textStyle = previewLabel + ? new Text({ + text: previewLabel, + font: "600 12px sans-serif", + fill: new Fill({ color: "#ffffff" }), + backgroundFill: new Fill({ color: highlightColor }), + padding: [3, 6, 3, 6], + offsetY: geometryType === "Point" ? -18 : -12, + overflow: true, + }) + : undefined; + const labelGeometry = previewLabel ? getGisEditPreviewLabelGeometry(geometry) : null; + + if (geometryType === "Point") { + return new Style({ + image: new CircleStyle({ + radius: isInteractionTarget ? 10 : 8, + fill: new Fill({ color: pointColor }), + stroke: new Stroke({ + color: isDragging ? "rgba(255,255,255,1)" : "rgba(255,255,255,0.95)", + width: isInteractionTarget ? 3 : 2, + }), + }), + text: textStyle, + zIndex: 10001, + }); + } + + if (geometryType === "Polygon") { + const polygonStyles: Style[] = [ + new Style({ + fill: new Fill({ + color: isInteractionTarget && editType !== "DELETE" + ? fillColor.replace(/0\.\d+\)$/, "0.28)") + : fillColor, + }), + stroke: new Stroke({ + color: highlightColor, + width: emphasisWidth, + lineDash: editType === "DELETE" ? [8, 6] : undefined, + }), + zIndex: 10001, + }), + ]; + + if (textStyle && labelGeometry) { + polygonStyles.push( + new Style({ + geometry: labelGeometry, + text: textStyle, + zIndex: 10002, + }), + ); + } + + return polygonStyles; + } + + const lineStyles: Style[] = [ + new Style({ + stroke: new Stroke({ + color: highlightColor, + width: emphasisWidth, + lineDash: editType === "DELETE" ? [10, 6] : undefined, + }), + zIndex: 10001, + }), + ]; + + if (textStyle && labelGeometry) { + lineStyles.push( + new Style({ + geometry: labelGeometry, + text: textStyle, + zIndex: 10002, + }), + ); + } + + return lineStyles; +}; + +const GIS_EDIT_VERTEX_HANDLE_STYLE = new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ color: "rgba(245, 158, 11, 0.98)" }), + stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }), + }), + zIndex: 10005, +}); + +const GIS_EDIT_VERTEX_MIDPOINT_STYLE = new Style({ + image: new CircleStyle({ + radius: 6, + fill: new Fill({ color: "rgba(255, 255, 255, 0.98)" }), + stroke: new Stroke({ color: "rgba(245, 158, 11, 0.98)", width: 2.5 }), + }), + zIndex: 10004, +}); + +const GIS_EDIT_VERTEX_DRAGGING_STYLE = new Style({ + image: new CircleStyle({ + radius: 11, + fill: new Fill({ color: "rgba(59, 130, 246, 0.95)" }), + stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }), + }), + zIndex: 10006, +}); + +const geometryObjectToKey = (geometry: Record | null) => + geometry ? JSON.stringify(geometry) : ""; + +type VertexHandleMeta = { + kind: "vertex" | "midpoint"; + geometryType: string; + partIndex?: number; + polygonIndex?: number; + ringIndex?: number; + coordIndex: number; +}; + +type VertexHandleCandidate = { + coordinate: number[]; + meta: VertexHandleMeta; +}; + +const cloneCoordinates = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +const getMidpoint = (start: number[], end: number[]) => [ + (Number(start?.[0] ?? 0) + Number(end?.[0] ?? 0)) / 2, + (Number(start?.[1] ?? 0) + Number(end?.[1] ?? 0)) / 2, +]; + +const buildVertexCoordinateKey = (coordinate: number[]) => + `${Number(coordinate?.[0] ?? 0)}:${Number(coordinate?.[1] ?? 0)}`; + +const getSquaredPixelDistance = ( + map: any, + startCoordinate: number[], + endCoordinate: number[], +) => { + const [startX = 0, startY = 0] = map?.getPixelFromCoordinate?.(startCoordinate) ?? []; + const [endX = 0, endY = 0] = map?.getPixelFromCoordinate?.(endCoordinate) ?? []; + return (endX - startX) ** 2 + (endY - startY) ** 2; +}; + +const getPixelDistanceToSegment = ( + map: any, + coordinate: number[], + startCoordinate: number[], + endCoordinate: number[], +) => { + const [pointX = 0, pointY = 0] = map?.getPixelFromCoordinate?.(coordinate) ?? []; + const [startX = 0, startY = 0] = map?.getPixelFromCoordinate?.(startCoordinate) ?? []; + const [endX = 0, endY = 0] = map?.getPixelFromCoordinate?.(endCoordinate) ?? []; + const deltaX = endX - startX; + const deltaY = endY - startY; + const segmentLengthSquared = deltaX ** 2 + deltaY ** 2; + + if (segmentLengthSquared === 0) { + return Math.hypot(pointX - startX, pointY - startY); + } + + const ratio = Math.max( + 0, + Math.min( + 1, + ((pointX - startX) * deltaX + (pointY - startY) * deltaY) / segmentLengthSquared, + ), + ); + const projectedX = startX + deltaX * ratio; + const projectedY = startY + deltaY * ratio; + return Math.hypot(pointX - projectedX, pointY - projectedY); +}; + +const createVertexHandleFeature = ( + coordinate: number[], + meta: VertexHandleMeta, + metaList: VertexHandleMeta[] = [meta], +) => { + const handleFeature = new Feature({ + geometry: new Point(coordinate), + }); + handleFeature.set("handleKind", meta.kind); + handleFeature.set("handleMeta", meta); + handleFeature.set("handleMetaList", metaList); + handleFeature.setStyle( + meta.kind === "midpoint" ? GIS_EDIT_VERTEX_MIDPOINT_STYLE : GIS_EDIT_VERTEX_HANDLE_STYLE, + ); + return handleFeature; +}; + +const collectLineVertexHandleCandidates = ( + coordinates: number[][], + geometryType: string, + partIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + coordinates.forEach((coordinate, coordIndex) => { + candidates.push({ + coordinate, + meta: { + kind: "vertex", + geometryType, + partIndex, + coordIndex, + }, + }); + }); + return candidates; +}; + +const collectPolygonRingVertexHandleCandidates = ( + coordinates: number[][], + geometryType: string, + polygonIndex?: number, + ringIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + const logicalVertexCount = Math.max(0, coordinates.length - 1); + for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) { + const coordinate = coordinates[coordIndex]; + const nextCoordinate = coordinates[(coordIndex + 1) % logicalVertexCount] ?? coordinate; + if (!coordinate || !nextCoordinate) continue; + candidates.push({ + coordinate, + meta: { + kind: "vertex", + geometryType, + polygonIndex, + ringIndex, + coordIndex, + }, + }); + } + return candidates; +}; + +const collectVertexHandleCandidates = (geometry: any) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return []; + + if (geometryType === "LineString") { + return collectLineVertexHandleCandidates(geometry.getCoordinates?.() ?? [], geometryType); + } + + if (geometryType === "MultiLineString") { + const parts = geometry.getCoordinates?.() ?? []; + return parts.flatMap((part: number[][], partIndex: number) => + collectLineVertexHandleCandidates(part, geometryType, partIndex), + ); + } + + if (geometryType === "Polygon") { + const rings = geometry.getCoordinates?.() ?? []; + return rings.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingVertexHandleCandidates(ring, geometryType, undefined, ringIndex), + ); + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getCoordinates?.() ?? []; + return polygons.flatMap((polygon: number[][][], polygonIndex: number) => + polygon.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingVertexHandleCandidates(ring, geometryType, polygonIndex, ringIndex), + ), + ); + } + + return []; +}; + +const buildVertexHandleFeatures = (geometry: any): Feature[] => { + const groupedCandidates = new globalThis.Map(); + + collectVertexHandleCandidates(geometry).forEach((candidate: VertexHandleCandidate) => { + const key = buildVertexCoordinateKey(candidate.coordinate); + const existing = groupedCandidates.get(key); + if (existing) { + existing.metaList.push(candidate.meta); + return; + } + groupedCandidates.set(key, { + coordinate: candidate.coordinate, + metaList: [candidate.meta], + }); + }); + + return Array.from(groupedCandidates.values()).map(({ coordinate, metaList }: { coordinate: number[]; metaList: VertexHandleMeta[] }) => + createVertexHandleFeature(coordinate, metaList[0]!, metaList), + ); +}; + +const collectLineMidpointCandidates = ( + coordinates: number[][], + geometryType: string, + partIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + for (let coordIndex = 0; coordIndex < coordinates.length - 1; coordIndex += 1) { + const startCoordinate = coordinates[coordIndex]; + const endCoordinate = coordinates[coordIndex + 1]; + if (!startCoordinate || !endCoordinate) continue; + candidates.push({ + coordinate: getMidpoint(startCoordinate, endCoordinate), + meta: { + kind: "midpoint", + geometryType, + partIndex, + coordIndex, + }, + }); + } + return candidates; +}; + +const collectPolygonRingMidpointCandidates = ( + coordinates: number[][], + geometryType: string, + polygonIndex?: number, + ringIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + const logicalVertexCount = Math.max(0, coordinates.length - 1); + for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) { + const startCoordinate = coordinates[coordIndex]; + const endCoordinate = coordinates[(coordIndex + 1) % logicalVertexCount]; + if (!startCoordinate || !endCoordinate) continue; + candidates.push({ + coordinate: getMidpoint(startCoordinate, endCoordinate), + meta: { + kind: "midpoint", + geometryType, + polygonIndex, + ringIndex, + coordIndex, + }, + }); + } + return candidates; +}; + +const collectMidpointCandidates = (geometry: any) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return []; + + if (geometryType === "LineString") { + return collectLineMidpointCandidates(geometry.getCoordinates?.() ?? [], geometryType); + } + + if (geometryType === "MultiLineString") { + const parts = geometry.getCoordinates?.() ?? []; + return parts.flatMap((part: number[][], partIndex: number) => + collectLineMidpointCandidates(part, geometryType, partIndex), + ); + } + + if (geometryType === "Polygon") { + const rings = geometry.getCoordinates?.() ?? []; + return rings.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingMidpointCandidates(ring, geometryType, undefined, ringIndex), + ); + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getCoordinates?.() ?? []; + return polygons.flatMap((polygon: number[][][], polygonIndex: number) => + polygon.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingMidpointCandidates(ring, geometryType, polygonIndex, ringIndex), + ), + ); + } + + return []; +}; + +const getNearestVertexPixelDistance = ( + map: any, + geometry: any, + coordinate: number[], +): number => { + return buildVertexHandleFeatures(geometry).reduce((minimumDistance: number, feature: Feature) => { + const vertexCoordinate = (feature.getGeometry?.() as any)?.getCoordinates?.(); + if (!Array.isArray(vertexCoordinate)) return minimumDistance; + return Math.min( + minimumDistance, + Math.sqrt(getSquaredPixelDistance(map, coordinate, vertexCoordinate)), + ); + }, Number.POSITIVE_INFINITY); +}; + +const getSegmentCoordinatesFromMeta = ( + geometry: any, + meta: VertexHandleMeta, +): [number[], number[]] | null => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return null; + + if (geometryType === "LineString") { + const coordinates = geometry.getCoordinates?.() ?? []; + return [coordinates[meta.coordIndex], coordinates[meta.coordIndex + 1]]; + } + + if (geometryType === "MultiLineString") { + const parts = geometry.getCoordinates?.() ?? []; + const targetPart = parts[meta.partIndex ?? 0] ?? []; + return [targetPart[meta.coordIndex], targetPart[meta.coordIndex + 1]]; + } + + if (geometryType === "Polygon") { + const rings = geometry.getCoordinates?.() ?? []; + const targetRing = rings[meta.ringIndex ?? 0] ?? []; + const logicalVertexCount = Math.max(0, targetRing.length - 1); + if (logicalVertexCount === 0) return null; + return [targetRing[meta.coordIndex], targetRing[(meta.coordIndex + 1) % logicalVertexCount]]; + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getCoordinates?.() ?? []; + const targetPolygon = polygons[meta.polygonIndex ?? 0] ?? []; + const targetRing = targetPolygon[meta.ringIndex ?? 0] ?? []; + const logicalVertexCount = Math.max(0, targetRing.length - 1); + if (logicalVertexCount === 0) return null; + return [targetRing[meta.coordIndex], targetRing[(meta.coordIndex + 1) % logicalVertexCount]]; + } + + return null; +}; + +const findHoveredMidpointCandidate = ( + map: any, + geometry: any, + coordinate: number[], + tolerancePx = 18, +): VertexHandleCandidate | null => { + const nearestVertexDistance = getNearestVertexPixelDistance(map, geometry, coordinate); + if (nearestVertexDistance <= 14) { + return null; + } + + const midpointCandidates = collectMidpointCandidates(geometry); + let bestCandidate: VertexHandleCandidate | null = null; + let minimumDistance = Number.POSITIVE_INFINITY; + + midpointCandidates.forEach((candidate: VertexHandleCandidate) => { + const segmentCoordinates = getSegmentCoordinatesFromMeta(geometry, candidate.meta); + if (!segmentCoordinates) return; + const [startCoordinate, endCoordinate] = segmentCoordinates; + if (!Array.isArray(startCoordinate) || !Array.isArray(endCoordinate)) return; + + const segmentDistance = getPixelDistanceToSegment( + map, + coordinate, + startCoordinate, + endCoordinate, + ); + if (segmentDistance < minimumDistance) { + minimumDistance = segmentDistance; + bestCandidate = candidate; + } + }); + + return minimumDistance <= tolerancePx ? bestCandidate : null; +}; + +const updateLineCoordinates = ( + coordinates: number[][], + meta: VertexHandleMeta, + nextCoordinate: number[], +) => { + const nextCoordinates = cloneCoordinates(coordinates); + if (meta.kind === "vertex") { + nextCoordinates[meta.coordIndex] = nextCoordinate; + } else { + nextCoordinates.splice(meta.coordIndex + 1, 0, nextCoordinate); + } + return nextCoordinates; +}; + +const updatePolygonRingCoordinates = ( + coordinates: number[][], + meta: VertexHandleMeta, + nextCoordinate: number[], +) => { + const nextCoordinates = cloneCoordinates(coordinates); + const logicalVertexCount = Math.max(0, nextCoordinates.length - 1); + if (logicalVertexCount === 0) return nextCoordinates; + + if (meta.kind === "vertex") { + nextCoordinates[meta.coordIndex] = nextCoordinate; + if (meta.coordIndex === 0) { + nextCoordinates[logicalVertexCount] = nextCoordinate; + } + } else { + const insertIndex = meta.coordIndex === logicalVertexCount - 1 + ? logicalVertexCount + : meta.coordIndex + 1; + nextCoordinates.splice(insertIndex, 0, nextCoordinate); + } + + return nextCoordinates; +}; + +const applyVertexHandleMove = ( + geometry: any, + meta: VertexHandleMeta, + nextCoordinate: number[], +) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return; + + if (geometryType === "LineString") { + geometry.setCoordinates( + updateLineCoordinates(geometry.getCoordinates?.() ?? [], meta, nextCoordinate), + ); + return; + } + + if (geometryType === "MultiLineString") { + const parts = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const targetPartIndex = meta.partIndex ?? 0; + parts[targetPartIndex] = updateLineCoordinates(parts[targetPartIndex] ?? [], meta, nextCoordinate); + geometry.setCoordinates(parts); + return; + } + + if (geometryType === "Polygon") { + const rings = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const targetRingIndex = meta.ringIndex ?? 0; + rings[targetRingIndex] = updatePolygonRingCoordinates(rings[targetRingIndex] ?? [], meta, nextCoordinate); + geometry.setCoordinates(rings); + return; + } + + if (geometryType === "MultiPolygon") { + const polygons = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const targetPolygonIndex = meta.polygonIndex ?? 0; + const targetRingIndex = meta.ringIndex ?? 0; + polygons[targetPolygonIndex][targetRingIndex] = updatePolygonRingCoordinates( + polygons[targetPolygonIndex][targetRingIndex] ?? [], + meta, + nextCoordinate, + ); + geometry.setCoordinates(polygons); + } +}; + // 관종별 원본 레이어 색을 유하방향 선/화살표에 재사용할 때 투명도만 조정한다. const withAlpha = (color: string | null | undefined, alpha: number) => { const resolvedColor = String(color ?? "").trim(); @@ -573,9 +1204,12 @@ const Map = () => { const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false); const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = useState(""); const { ctpv, sgg } = useStore(); + // GIS 편집은 map/page.tsx 내부 기존 지도 상태를 재사용하되, 세부 상태와 payload 조립은 별도 hook으로 분리한다. + const gisEdit = useGisEdit(treeData); const shouldFocusAgedPipeOnNextSelectRef = useRef(false); const agedPipePopupWindowRef = useRef(null); - const pipeAnalysisPopupWindowRef = useRef(null); + const pipeAnalysisPopupWindowRef = useRef(null); + const gisEditSessionPopupWindowRef = useRef(null); // 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다. const [isFlowDirectionChecked, setIsFlowDirectionChecked] = useState(false); // 체크된 유하방향 트리 노드의 레이어 설정을 보관해 API 조회와 vector layer 스타일에 사용한다. @@ -604,6 +1238,53 @@ const Map = () => { const flowDirectionAbortControllerRef = useRef(null); // 같은 장애 상황에서 alert가 반복 노출되지 않도록 실패 안내 여부를 기억한다. const flowDirectionFailureAlertedRef = useRef(false); + const gisEditPreviewLayerRef = useRef(null); + const gisEditPreviewSourceRef = useRef(null); + const gisEditVertexLayerRef = useRef | null>(null); + const gisEditVertexSourceRef = useRef(null); + const gisEditDrawSourceRef = useRef(null); + const gisEditDrawRef = useRef(null); + const gisEditTranslateRef = useRef(null); + const gisEditModifyRef = useRef(null); + const gisEditSessionSnapshot = useMemo(() => ({ + isEditMode: gisEdit.isEditMode, + selectedLayerName: gisEdit.selectedLayer?.layerName ?? "", + activeTool: gisEdit.activeTool, + changes: gisEdit.changes, + lastSavedPayload: gisEdit.lastSavedPayload, + canUndo: gisEdit.canUndo, + focusedChangeId: gisEdit.focusedChangeId, + }), [ + gisEdit.activeTool, + gisEdit.canUndo, + gisEdit.changes, + gisEdit.focusedChangeId, + gisEdit.isEditMode, + gisEdit.lastSavedPayload, + gisEdit.selectedLayer, + ]); + const syncGisEditSessionPopup = useCallback(() => { + const popup = gisEditSessionPopupWindowRef.current; + if (!popup || popup.closed) { + gisEditSessionPopupWindowRef.current = null; + return; + } + + popup.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SYNC, + payload: gisEditSessionSnapshot, + }, + window.location.origin, + ); + }, [gisEditSessionSnapshot]); + + const setGisEditPreviewCursor = useCallback((cursor: string) => { + const targetElement = gisMaps.current?.getTargetElement?.(); + if (targetElement?.style) { + targetElement.style.cursor = cursor; + } + }, []); useEffect(() => { fetchData(); @@ -656,6 +1337,69 @@ const Map = () => { }; }, []); + useEffect(() => { + const shouldKeepPopupOpen = gisEdit.isEditMode || gisEdit.changes.length > 0; + if (shouldKeepPopupOpen) return; + + const popup = gisEditSessionPopupWindowRef.current; + if (!popup || popup.closed) { + gisEditSessionPopupWindowRef.current = null; + return; + } + + popup.close(); + gisEditSessionPopupWindowRef.current = null; + }, [gisEdit.changes.length, gisEdit.isEditMode]); + + useEffect(() => { + return () => { + const popup = gisEditSessionPopupWindowRef.current; + if (popup && !popup.closed) { + popup.close(); + } + gisEditSessionPopupWindowRef.current = null; + }; + }, []); + + useEffect(() => { + if (!(gisEdit.isEditMode || gisEdit.changes.length > 0)) return; + syncGisEditSessionPopup(); + }, [gisEdit.changes.length, gisEdit.isEditMode, syncGisEditSessionPopup]); + + useEffect(() => { + const handleGisEditSessionPopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + const data = event.data; + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_READY) { + syncGisEditSessionPopup(); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_FOCUS_CHANGE) { + const changeId = String(data.payload?.changeId ?? "").trim(); + if (changeId) { + gisEdit.focusChange(changeId); + } + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE) { + gisEdit.removeLastChange(); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_CLEAR_CHANGES) { + gisEdit.clearChanges(); + } + }; + + window.addEventListener("message", handleGisEditSessionPopupMessage); + return () => { + window.removeEventListener("message", handleGisEditSessionPopupMessage); + }; + }, [gisEdit, syncGisEditSessionPopup]); + const fetchData = async () => { try { const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); @@ -943,6 +1687,19 @@ const Map = () => { // SearchModal, GisContent 등에서 호출 const onFeatureSelect = (data: any) => { + // 편집 모드 + activeTool 조합일 때는 상세 팝업 대신 편집 세션 누적을 우선한다. + const editSelection = gisEdit.registerFeatureSelection(data); + if (editSelection.handled) { + // 편집 대상으로 넘긴 뒤에는 기존 상세 팝업/원본 선택 강조를 정리해 + // 실제 편집 대상인 preview feature가 시각적으로 가려지지 않게 한다. + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + let map = gisMaps.current; if (!map) { console.warn("지도가 초기화되지 않았습니다."); @@ -1353,6 +2110,218 @@ const Map = () => { } }, []); + const openGisEditSessionPopup = useCallback(() => { + const existingPopup = gisEditSessionPopupWindowRef.current; + if (existingPopup && !existingPopup.closed) { + existingPopup.focus(); + window.focus?.(); + return existingPopup; + } + + const popup = openWindowPopup("gisEditSessionPopup"); + if (!popup) return null; + + gisEditSessionPopupWindowRef.current = popup; + window.focus?.(); + return popup; + }, []); + + const ensureGisEditPreviewLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (gisEditPreviewLayerRef.current && gisEditPreviewSourceRef.current) { + return gisEditPreviewSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 10001, + updateWhileAnimating: true, + updateWhileInteracting: true, + }); + + layer.set("id", GIS_EDIT_PREVIEW_LAYER_ID); + layer.set("lyrNm", "GIS 편집 미리보기"); + layer.set("lyrTyNm", GIS_EDIT_PREVIEW_LAYER_ID); + // MOVE/VERTEX_EDIT는 preview 객체를 실제 상호작용 대상으로 써야 하므로 클릭 가능 상태를 유지한다. + // 이전에 N으로 바꿨다가 점/선/면 시설물 전반에서 이동 드래그가 끊기는 회귀가 발생했다. + layer.set("clickPsblYn", "Y"); + + const core = GisApp.Module.core as any; + core?.deleteLayer?.(GIS_EDIT_PREVIEW_LAYER_ID); + map.addLayer(layer); + gisEditPreviewLayerRef.current = layer; + gisEditPreviewSourceRef.current = source; + return source; + }, []); + + const clearGisEditPreviewLayer = useCallback(() => { + gisEditPreviewSourceRef.current?.clear(); + setGisEditPreviewCursor(""); + }, [setGisEditPreviewCursor]); + + const ensureGisEditVertexLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (gisEditVertexLayerRef.current && gisEditVertexSourceRef.current) { + return gisEditVertexSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 10005, + updateWhileAnimating: true, + updateWhileInteracting: true, + }); + + layer.set("id", GIS_EDIT_VERTEX_LAYER_ID); + layer.set("lyrNm", "GIS 편집 꼭지점"); + layer.set("lyrTyNm", GIS_EDIT_VERTEX_LAYER_ID); + layer.set("clickPsblYn", "N"); + + const core = GisApp.Module.core as any; + core?.deleteLayer?.(GIS_EDIT_VERTEX_LAYER_ID); + map.addLayer(layer); + gisEditVertexLayerRef.current = layer; + gisEditVertexSourceRef.current = source; + return source; + }, []); + + const clearGisEditVertexLayer = useCallback(() => { + gisEditVertexSourceRef.current?.clear(); + }, []); + + const ensureGisEditDrawSource = useCallback(() => { + if (gisEditDrawSourceRef.current) { + return gisEditDrawSourceRef.current; + } + + gisEditDrawSourceRef.current = new VectorSource(); + return gisEditDrawSourceRef.current; + }, []); + + const removeGisEditDrawInteraction = useCallback(() => { + const map = gisMaps.current; + if (!map) return; + + if (gisEditDrawRef.current) { + map.removeInteraction(gisEditDrawRef.current); + gisEditDrawRef.current = null; + } + gisEditDrawSourceRef.current?.clear(); + }, []); + + const removeGisEditInteractions = useCallback(() => { + const map = gisMaps.current; + if (!map) return; + + if (gisEditTranslateRef.current) { + map.removeInteraction(gisEditTranslateRef.current); + gisEditTranslateRef.current = null; + } + if (gisEditModifyRef.current) { + const modifyInteractions = Array.isArray(gisEditModifyRef.current) + ? gisEditModifyRef.current + : [gisEditModifyRef.current]; + modifyInteractions.forEach((interaction: any) => { + if (interaction) { + map.removeInteraction(interaction); + } + }); + gisEditModifyRef.current = null; + } + clearGisEditVertexLayer(); + setGisEditPreviewCursor(""); + }, [clearGisEditVertexLayer, setGisEditPreviewCursor]); + + // GIS 편집은 map 화면의 로컬 편집 모드를 토글하면서 세션 상태는 별도 popup 창에 동기화한다. + const handleToggleGisEdit = useCallback(() => { + if (!gisEdit.isEditMode) { + const popup = openGisEditSessionPopup(); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + } + } + gisEdit.toggleEditMode(); + setIsOpen(false); + }, [gisEdit, openGisEditSessionPopup]); + + // 툴바 버튼은 현재 지도 중심 또는 다음 피처 선택을 기준으로 로컬 변경분을 만든다. + const handleSelectGisEditTool = useCallback((tool: "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE") => { + gisEdit.activateTool(tool, gisMaps.current); + setIsOpen(false); + }, [gisEdit]); + + // 1차 구현은 저장 API 호출 대신 payload 미리보기만 만든다. + const handleSaveGisEdit = useCallback(() => { + gisEdit.save(gisMaps.current); + }, [gisEdit]); + + useEffect(() => { + if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return; + + const map = gisMaps.current; + + if (gisEdit.activeTool !== "ADD" || !gisEdit.selectedLayer) { + removeGisEditDrawInteraction(); + return; + } + + removeGisEditInteractions(); + removeGisEditDrawInteraction(); + + const drawSource = ensureGisEditDrawSource(); + const drawType = + gisEdit.selectedLayer.geometryType === "Point" + ? "Point" + : gisEdit.selectedLayer.geometryType === "Polygon" + ? "Polygon" + : "LineString"; + + const draw = new Draw({ + source: drawSource, + type: drawType, + }); + + draw.on("drawstart", () => { + setGisEditPreviewCursor("crosshair"); + }); + + draw.on("drawend", (event: any) => { + try { + const geometryObject = new GeoJSON().writeGeometryObject(event.feature.getGeometry(), { + featureProjection: GisApp.TO_PROJ, + dataProjection: FLOW_DIRECTION_SRS, + }) as Record; + gisEdit.registerAddGeometry(geometryObject); + } catch (error) { + console.error("GIS 편집 ADD geometry 변환 실패:", error); + gisEdit.announceStatus("신규 geometry를 추가하지 못했습니다. 다시 시도해 주세요."); + } finally { + drawSource.clear(); + setGisEditPreviewCursor("crosshair"); + } + }); + + map.addInteraction(draw); + gisEditDrawRef.current = draw; + + return () => { + removeGisEditDrawInteraction(); + }; + }, [ + ensureGisEditDrawSource, + gisEdit, + mapReady, + removeGisEditDrawInteraction, + removeGisEditInteractions, + setGisEditPreviewCursor, + ]); + useEffect(() => { if (!mapReady || !flowDirectionLayerInfo) return; @@ -1388,6 +2357,351 @@ const Map = () => { }; }, [loadFlowDirectionLayer, mapReady]); + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + + const source = ensureGisEditPreviewLayer(); + if (!source) return; + + if (gisEdit.changes.length === 0) { + clearGisEditPreviewLayer(); + return; + } + + const geoJson = new GeoJSON(); + const layerPreviewColorMap = new globalThis.Map( + gisEdit.editableLayers.map((layer) => [layer.layerId, layer.previewColors] as const), + ); + const existingFeatureMap = new globalThis.Map( + source.getFeatures().map((feature: any) => [String(feature.get("changeId") ?? ""), feature] as const), + ); + const nextChangeIdSet = new Set(); + + gisEdit.changes.forEach((change) => { + const geometry = change.editType === "DELETE" ? change.beforeGeometry : change.afterGeometry; + if (!geometry) return; + + const previewLabel = + gisEdit.focusedChangeId === change.changeId && change.editType === "MOVE" + ? "드래그 이동" + : gisEdit.focusedChangeId === change.changeId && change.editType === "VERTEX_EDIT" + ? "꼭지점 편집" + : change.editType === "DELETE" + ? "삭제 예정" + : change.editType === "MOVE" + ? "이동 예정" + : change.editType === "VERTEX_EDIT" + ? "편집 예정" + : "신규 추가"; + + let feature = existingFeatureMap.get(change.changeId); + const previewColors = layerPreviewColorMap.get(change.layerId); + if (!feature) { + feature = geoJson.readFeature( + { + type: "Feature", + geometry, + properties: { + editType: change.editType, + objectId: change.objectId ?? change.objectLabel, + layerId: change.layerId, + }, + }, + { + dataProjection: FLOW_DIRECTION_SRS, + featureProjection: GisApp.TO_PROJ, + }, + ); + feature.setId(`gis-edit:${change.changeId}`); + source.addFeature(feature); + } else { + feature.setGeometry( + geoJson.readGeometry(geometry, { + dataProjection: FLOW_DIRECTION_SRS, + featureProjection: GisApp.TO_PROJ, + }), + ); + } + + feature.set("editType", change.editType); + feature.set("objectId", change.objectId ?? change.objectLabel); + feature.set("layerId", change.layerId); + feature.set("changeId", change.changeId); + feature.set("layerPointColor", previewColors?.pointColor ?? ""); + feature.set("layerLineColor", previewColors?.lineColor ?? ""); + feature.set("layerFillColor", previewColors?.fillColor ?? ""); + feature.set("isInteractionTarget", gisEdit.focusedChangeId === change.changeId); + feature.set("isDragging", Boolean(feature.get("isDragging"))); + feature.set("previewLabel", previewLabel); + feature.setStyle(createGisEditPreviewStyle(feature)); + nextChangeIdSet.add(change.changeId); + }); + + existingFeatureMap.forEach((feature: any, changeId: string) => { + if (!nextChangeIdSet.has(changeId)) { + source.removeFeature(feature); + } + }); + + }, [ + clearGisEditPreviewLayer, + ensureGisEditPreviewLayer, + gisEdit.changes, + gisEdit.editableLayers, + gisEdit.focusedChangeId, + gisEdit.isEditMode, + mapReady, + ]); + + useEffect(() => { + if (!mapReady || !gisMaps.current || !gisEdit.isEditMode || !gisEdit.pendingInteraction) return; + + const source = ensureGisEditPreviewLayer(); + const map = gisMaps.current; + if (!source || !map) return; + + const targetFeature = source + .getFeatures() + .find((feature: any) => feature.get("changeId") === gisEdit.pendingInteraction?.changeId); + if (!targetFeature) { + gisEdit.clearPendingInteraction(); + return; + } + + removeGisEditInteractions(); + const features = new Collection([targetFeature]); + let cleanupPointerMove: (() => void) | undefined; + const writeGeometryObject = () => { + const geometry = targetFeature.getGeometry(); + if (!geometry) return null; + return new GeoJSON().writeGeometryObject(geometry, { + featureProjection: GisApp.TO_PROJ, + dataProjection: FLOW_DIRECTION_SRS, + }) as Record; + }; + + if (gisEdit.pendingInteraction.editType === "MOVE") { + const translate = new Translate({ features, hitTolerance: 10 }); + translate.on("translatestart", () => { + targetFeature.set("isDragging", true); + targetFeature.set("previewLabel", "이동 중"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("grabbing"); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + translate.on("translating", () => { + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + translate.on("translateend", () => { + const objectId = String(targetFeature.get("objectId") ?? ""); + const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어"; + targetFeature.set("isDragging", false); + targetFeature.set("previewLabel", "드래그 이동"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("grab"); + gisEdit.updateChangeGeometry( + gisEdit.pendingInteraction!.changeId, + writeGeometryObject(), + `${layerName} ${objectId} 객체 위치를 지도에서 이동했습니다.`, + ); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + map.addInteraction(translate); + gisEditTranslateRef.current = translate; + setGisEditPreviewCursor("grab"); + map.renderSync?.(); + } + + if (gisEdit.pendingInteraction.editType === "VERTEX_EDIT") { + const geometry = targetFeature.getGeometry?.(); + const geometryType = geometry?.getType?.(); + const objectId = String(targetFeature.get("objectId") ?? ""); + const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어"; + + if (!geometry || !geometryType || geometryType === "Point" || geometryType === "MultiPoint") { + gisEdit.announceStatus(`${layerName} ${objectId} 객체는 꼭지점편집 대상 geometry가 아닙니다.`); + gisEdit.clearPendingInteraction(); + return; + } + + const vertexSource = ensureGisEditVertexLayer(); + const vertexLayer = gisEditVertexLayerRef.current; + if (!vertexSource || !vertexLayer) { + gisEdit.announceStatus("꼭지점 편집 레이어를 준비하지 못했습니다."); + gisEdit.clearPendingInteraction(); + return; + } + + const renderVertexHandles = (hoveredMidpoint: VertexHandleCandidate | null = null) => { + vertexSource.clear(); + buildVertexHandleFeatures(targetFeature.getGeometry?.()).forEach((handleFeature: Feature) => { + vertexSource.addFeature(handleFeature); + }); + if (hoveredMidpoint) { + vertexSource.addFeature( + createVertexHandleFeature(hoveredMidpoint.coordinate, hoveredMidpoint.meta), + ); + } + vertexSource.changed?.(); + }; + + let geometryBeforeModifyKey = geometryObjectToKey(writeGeometryObject()); + let activeMidpointKey = ""; + let isDraggingHandle = false; + let originalGeometrySnapshot = targetFeature.getGeometry?.()?.clone?.() ?? null; + renderVertexHandles(); + targetFeature.set("previewLabel", "꼭지점 편집"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("crosshair"); + + const pointerMoveKey = map.on("pointermove", (event: any) => { + if (isDraggingHandle || event?.dragging) return; + + const hoveredMidpoint = findHoveredMidpointCandidate( + map, + targetFeature.getGeometry?.(), + event.coordinate, + ); + const nextMidpointKey = hoveredMidpoint + ? `${hoveredMidpoint.meta.geometryType}:${hoveredMidpoint.meta.partIndex ?? -1}:${hoveredMidpoint.meta.polygonIndex ?? -1}:${hoveredMidpoint.meta.ringIndex ?? -1}:${hoveredMidpoint.meta.coordIndex}` + : ""; + + if (nextMidpointKey === activeMidpointKey) return; + + activeMidpointKey = nextMidpointKey; + renderVertexHandles(hoveredMidpoint); + setGisEditPreviewCursor(hoveredMidpoint ? "copy" : "crosshair"); + map.renderSync?.(); + }); + + const vertexTranslate = new Translate({ + layers: [vertexLayer], + hitTolerance: 14, + }); + + vertexTranslate.on("translatestart", (event: any) => { + isDraggingHandle = true; + geometryBeforeModifyKey = geometryObjectToKey(writeGeometryObject()); + originalGeometrySnapshot = targetFeature.getGeometry?.()?.clone?.() ?? null; + const handleFeature = event.features?.item?.(0); + if (handleFeature) { + handleFeature.setStyle(GIS_EDIT_VERTEX_DRAGGING_STYLE); + } + targetFeature.set("previewLabel", "꼭지점 편집 중"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("grabbing"); + map.renderSync?.(); + }); + + vertexTranslate.on("translating", (event: any) => { + const handleFeature = event.features?.item?.(0); + const handleGeometry = handleFeature?.getGeometry?.(); + const nextCoordinate = handleGeometry?.getCoordinates?.(); + const handleMeta = handleFeature?.get?.("handleMeta") as VertexHandleMeta | undefined; + const handleMetaList = + (handleFeature?.get?.("handleMetaList") as VertexHandleMeta[] | undefined) ?? + (handleMeta ? [handleMeta] : []); + + if (handleMetaList.length === 0 || !Array.isArray(nextCoordinate) || !originalGeometrySnapshot?.clone) { + return; + } + + targetFeature.setGeometry(originalGeometrySnapshot.clone()); + handleMetaList.forEach((meta) => { + applyVertexHandleMove(targetFeature.getGeometry?.(), meta, nextCoordinate); + }); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + + vertexTranslate.on("translateend", (event: any) => { + isDraggingHandle = false; + const handleFeature = event.features?.item?.(0); + const handleGeometry = handleFeature?.getGeometry?.(); + const nextCoordinate = handleGeometry?.getCoordinates?.(); + const handleMeta = handleFeature?.get?.("handleMeta") as VertexHandleMeta | undefined; + const handleMetaList = + (handleFeature?.get?.("handleMetaList") as VertexHandleMeta[] | undefined) ?? + (handleMeta ? [handleMeta] : []); + + targetFeature.set("previewLabel", "꼭지점 편집"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("crosshair"); + activeMidpointKey = ""; + + if (handleMetaList.length === 0 || !Array.isArray(nextCoordinate)) { + renderVertexHandles(); + map.renderSync?.(); + return; + } + + handleMetaList.forEach((meta) => { + applyVertexHandleMove(targetFeature.getGeometry?.(), meta, nextCoordinate); + }); + const nextGeometry = writeGeometryObject(); + const geometryChanged = geometryBeforeModifyKey !== geometryObjectToKey(nextGeometry); + renderVertexHandles(); + if (!geometryChanged) { + gisEdit.announceStatus( + `${layerName} ${objectId} 객체는 아직 형상이 바뀌지 않았습니다. 주황색 꼭지점 또는 가상 꼭지점을 직접 드래그해 주세요.`, + ); + map.renderSync?.(); + return; + } + + gisEdit.updateChangeGeometry( + gisEdit.pendingInteraction!.changeId, + nextGeometry, + `${layerName} ${objectId} 객체 꼭지점을 지도에서 조정했습니다.`, + ); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + + map.addInteraction(vertexTranslate); + gisEditModifyRef.current = vertexTranslate; + gisMaps.current?.renderSync?.(); + cleanupPointerMove = () => { + unByKey(pointerMoveKey); + }; + } + + gisEdit.clearPendingInteraction(); + return cleanupPointerMove; + }, [ + clearGisEditVertexLayer, + ensureGisEditVertexLayer, + ensureGisEditPreviewLayer, + gisEdit.pendingInteraction, + gisEdit.selectedLayer, + gisEdit.updateChangeGeometry, + gisEdit.announceStatus, + gisEdit.clearPendingInteraction, + gisEdit.isEditMode, + mapReady, + removeGisEditInteractions, + ]); + + useEffect(() => { + if (!gisEdit.isEditMode || (gisEdit.activeTool !== "MOVE" && gisEdit.activeTool !== "VERTEX_EDIT")) { + removeGisEditInteractions(); + } + }, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditInteractions]); + + useEffect(() => { + if (!gisEdit.isEditMode || gisEdit.activeTool !== "ADD") { + removeGisEditDrawInteraction(); + } + }, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditDrawInteraction]); + return ( <> @@ -1405,6 +2719,21 @@ const Map = () => { setIsSearchModalOpen(true)} + onToggleGisEdit={handleToggleGisEdit} + isGisEditActive={gisEdit.isEditMode} + /> + /` +- 파일 + - `001-de-<기능명>-설계.md` +- 규칙 + - 설계는 기능당 1개만 유지한다. + - 후속 설계 변경, 상태 재검토, QGIS 반영, API 계약 보완도 기존 설계 문서에 새 헤더로 누적한다. + +### 3.2 `sn` 작업내용 문서 + +- 경로 + - `FE_APP_DOCS_ROOT/작업내용/<기능폴더>/` +- 파일 + - `001-fe-<기능명>-통합구현기록.md` +- 규칙 + - 구현 이력, 라운드별 변경, 결정사항, 리스크, BE 공유사항을 한 문서에 누적한다. + - 기존 `001`, `002`, `003` 개별 구현 문서는 통합 문서에 흡수 후 정리한다. + +### 3.3 `sn` 작업검증 문서 + +- 경로 + - `FE_APP_DOCS_ROOT/작업검증/<기능폴더>/` +- 파일 + - `001-qa-<기능명>-통합검증계획.md` + - `002-qa-<기능명>-통합검증결과.md` +- 규칙 + - 검증 계획은 `통합검증계획` 1개에만 누적한다. + - 검증 결과는 `통합검증결과` 1개에만 누적한다. + - 회귀 검증, 후속 검증, 도구별 검증도 새 파일이 아니라 각 문서 내부 헤더로 구분한다. + +## 4. 문서 내부 헤더 규칙 + +### 4.1 설계 문서 + +- 권장 섹션 + - 목적 + - 입력 근거 + - 현재 확인 상태 + - 기능 범위 + - 사용자 흐름 + - 화면/UI 계약 + - API 계약 + - 검증 기준 + - 남은 리스크 +- 하네스 정보 배치 + - 도구 상태 전이표 + - 지도 상호작용 관측 포인트 + - 자동검증 가능 범위와 수동 검증 경계 +- 후속 반영은 아래 형식 중 하나로 누적한다. + - `## 2-1. 외부 참조 기준` + - `### 6.6 도구별 세부 계약` + - `### 6.8 지도 상호작용 관측 포인트` + - `### 7.5 도구 상태 전이표` + - `## YYYY-MM-DD 설계 변경 메모` + +### 4.2 통합 구현 기록 + +- 권장 섹션 + - 문서 목적 + - 입력 근거 + - 공통 계약 + - 구현 라운드 요약 + - 기능별 현재 상태 + - BE 공유 사항 + - 남은 리스크 + - 흡수한 기존 문서 +- 하네스 정보 배치 + - 자동화 차단 요인 + - 관측 가능성 확보를 위해 추가한 상태/메시지/표식 + - 하네스 친화화 부채 +- 라운드별 구현은 `### 4.x` 식으로 누적한다. + +### 4.3 통합 검증 계획 + +- 권장 섹션 + - 문서 목적 + - 검증 범위 + - 검증 기준 + - 라운드별 검증 계획 + - 다음 검증 순서 + - 흡수한 기존 문서 +- 하네스 정보 배치 + - 브라우저 검증 선행조건 + - 관측 포인트 표 + - 자동검증 항목과 수동검증 항목 구분 + - 차단 사유 분류 기준 + +### 4.4 통합 검증 결과 + +- 권장 섹션 + - 문서 목적 + - 수행한 검증 + - 라운드별 검증 결과 + - 현재 검증 상태 + - 보류/리스크 + - 흡수한 기존 문서 +- 하네스 정보 배치 + - 실제 관측 증적 요약 + - 차단 사유 분류 + - 자동화 실패와 기능 실패 구분 결과 + +## 5. 기존 개별 문서 흡수 규칙 + +- 기존 개별 문서를 유지할 필요가 없으면 다음 순서로 정리한다. + 1. 통합 문서에 핵심 내용을 요약/흡수 + 2. 통합 문서 `흡수한 기존 문서` 섹션에 파일명을 기록 + 3. 기존 개별 문서를 정리한다 +- 흡수 시 원래 문서의 모든 문장을 그대로 복사하기보다, 결정사항과 검증 결과를 구조화해서 재작성한다. + +## 6. 새 기능 시작 시 문서 생성 순서 + +1. 공통 설계 문서 1개 생성 +2. `sn` 작업카드 1개 생성 +3. `sn` 통합 구현 기록 1개 생성 +4. `sn` 통합 검증 계획 1개 생성 +5. `sn` 통합 검증 결과 1개 생성 +6. 이후부터는 기존 5개 문서만 계속 갱신 + +## 7. 금지 사항 + +- 구현 라운드마다 `002-fe-...`, `003-fe-...`, `004-qa-...`를 계속 추가하지 않는다. +- 같은 기능에서 `후속검증계획.md`, `후속검증결과.md`, `MOVE실드래그검증계획.md`, `VERTEX_EDIT실조작검증결과.md` 같은 파생 검증 파일을 반복 증식시키지 않는다. +- `상태재검토`, `후속정리`, `보완메모`를 별도 파일로 분리하지 않는다. + +## 8. 예외 처리 + +- 사용자 요청으로 개별 문서 분리가 명시된 경우 +- 외부 전달용 산출물이라 통합 문서 내부 헤더로 대체할 수 없는 경우 +- 법적/감사/계약상 독립 파일 보관이 필요한 경우 + +예외 상황에서도 먼저 통합 문서에 “왜 별도 파일이 필요한지”를 기록한다. diff --git a/apps/sn/docs/기준문서/015-하네스엔지니어링정책.md b/apps/sn/docs/기준문서/015-하네스엔지니어링정책.md new file mode 100644 index 000000000..2499f94ff --- /dev/null +++ b/apps/sn/docs/기준문서/015-하네스엔지니어링정책.md @@ -0,0 +1,126 @@ +# 015. 하네스엔지니어링정책 + +## 1. 목적 + +- `sn` 앱 기능을 브라우저 자동검증, 수동 회귀 검증, FE-BE 연동 관찰이 가능한 구조로 문서화하는 기준을 통일한다. +- 구현 코드 구조를 바로 바꾸기 전에도, 문서 단계에서 관측 포인트와 차단 요인을 먼저 드러내어 하네스 친화성을 높인다. +- 기능별 문서에 하네스 정보가 중복되거나 누락되지 않도록 공통 배치 기준을 제공한다. + +## 2. 적용 범위 + +- `FE_APP_DOCS_ROOT/기준문서/**` +- `FE_APP_DOCS_ROOT/작업계획/**` +- `FE_APP_DOCS_ROOT/작업내용/**` +- `FE_APP_DOCS_ROOT/작업검증/**` +- `COMMON_DOCS_ROOT/작업계획/**` 중 `sn` 화면/상호작용 계약을 담는 설계 문서 + +## 3. 기본 원칙 + +- 하네스 정보는 기능별로 새 md를 늘리기보다 기존 4문서 체계 안에 배치한다. +- 자동검증 가능 여부와 무관하게, 사용자가 실제로 무엇을 보고 성공/실패를 판단하는지 먼저 문서화한다. +- 브라우저 자동화 한계, 인증 차단, 외부 시스템 장애는 기능 결함과 분리해 기록한다. +- 지도 상호작용 기능은 도구 상태, 선택 상태, 세션 상태, 네트워크 상태를 따로 관측할 수 있어야 한다. +- 코드 구조가 과밀해도 문서에서는 책임 경계, 상태 전이, 관측 포인트를 먼저 고정한다. + +## 4. 자동검증 선행조건 + +- 접속 URL +- 로그인 경로와 권한 +- 필수 쿠키 또는 토큰 존재 여부 +- 서버 기동 상태와 대상 API base URL +- 테스트용 레이어, 객체 ID, BBOX, 샘플 데이터 +- 브라우저 자동화 가능 범위와 수동 검증 전환 조건 + +인증 또는 외부 연동 문제로 위 조건이 충족되지 않으면, 이를 기능 실패로 단정하지 말고 `환경 차단`, `인증 차단`, `데이터 차단`, `자동화 한계` 중 하나로 분류한다. + +## 5. 관측 포인트 표준 + +### 5.1 화면 관측 + +- 버튼 활성/비활성 +- 툴바 노출/숨김 +- 상태 메시지 텍스트 +- 선택 레이어명 +- 변경 목록 행 수 +- 오류 또는 안내 메시지 + +### 5.2 지도 상호작용 관측 + +- 현재 활성 도구 +- 선택 객체 존재 여부 +- preview feature 존재 여부 +- vertex marker 또는 modify handle 노출 여부 +- drag 또는 modify 종료 후 geometry 변경 여부 +- 삭제 대상 임시 스타일 적용 여부 + +### 5.3 네트워크 관측 + +- 요청 URL, method, query/body +- 응답 status +- 편집 세션 payload shape +- 인증 실패 또는 권한 실패 응답 + +### 5.4 세션 관측 + +- 로컬 변경분 누적 여부 +- undo/reset 가능 여부 +- save 가능 여부 +- save pending/save error 상태 + +## 6. 자동검증과 수동검증 경계 + +- DOM, 텍스트, 버튼 상태, 네트워크 호출, payload 구조는 자동검증 우선 대상으로 본다. +- canvas 기반 drag, vertex 이동, 지도 좌표 조작은 자동화가 불안정하면 수동 검증으로 전환할 수 있다. +- 수동 전환 시에는 이유를 `자동화 한계`로 명시하고, 어떤 관측 포인트를 사람 손으로 확인했는지 남긴다. + +## 7. 인증·세션 기준 + +- 인증 장애는 기능 결함과 분리해 기록한다. +- 로컬 브라우저 검증이 인증 때문에 막히면 `COMMON_DOCS_ROOT/작업내용/공통-로그인/001-de-공통-로그인-로컬임시로그인-검토계획.md`를 우선 참조한다. +- 기능 문서에는 해당 기능이 요구하는 최소 권한, 로그인 여부, 세션 유지 시간, 재로그인 필요 조건만 요약한다. + +## 8. 기능 문서 배치 규칙 + +### 8.1 설계 문서 + +- 도구 상태 전이표 +- 지도 상호작용 관측 포인트 +- 자동검증 가능 범위와 수동 검증 경계 + +### 8.2 작업카드 + +- 브라우저 검증 선행조건 +- 테스트 데이터와 샘플 진입 조건 +- 인증 차단 시 대체 경로 + +### 8.3 통합 구현 기록 + +- 현재 자동화 차단 요인 +- 하네스 친화화 부채 +- 관측 가능성 확보를 위해 추가된 상태/메시지/표식 + +### 8.4 통합 검증 계획 + +- 관측 포인트 표 +- 자동검증 항목과 수동검증 항목 구분 +- 차단 사유 분류 기준 + +### 8.5 통합 검증 결과 + +- 실제 관측 증적 요약 +- 차단 사유 분류 +- 자동화 실패와 기능 실패의 구분 결과 + +## 9. 차단 사유 분류 + +- `기능 결함`: 화면 동작, 상태 전이, payload, 응답 처리 자체가 기대와 다름 +- `인증 차단`: 로그인, 토큰, 권한 부족으로 기능 진입 불가 +- `환경 차단`: 서버 미기동, 잘못된 env, CORS, 프록시, 네트워크 장애 +- `데이터 차단`: 테스트 데이터, 레이어 메타, 객체 샘플 부족 +- `자동화 한계`: 브라우저 자동화 도구로 canvas drag, vertex 조작이 안정적으로 재현되지 않음 + +## 10. 금지 사항 + +- 같은 주제라고 해서 `브라우저자동검증기준.md`, `인증검증기준.md`, `상태머신.md`를 기능마다 별도 생성하지 않는다. +- 관측 포인트 없이 `수동 확인 필요`만 적고 끝내지 않는다. +- 인증 실패, 환경 실패, 자동화 한계를 모두 기능 결함으로 뭉뚱그려 기록하지 않는다. diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md new file mode 100644 index 000000000..be0225469 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md @@ -0,0 +1,157 @@ +# SFR-012-003 하수관로편집 통합 검증 계획 + +## 1. 문서 목적 + +- `SFR-012-003 하수관로편집`의 검증 계획을 기능당 1개 문서로 누적 관리한다. +- 기존 `001`, `003`, `005`, `007`, `009`, `011` 계획 문서를 이 문서로 흡수한다. +- 이후 라운드부터는 새 검증 계획 파일을 추가하지 않고 이 문서 내부 헤더로만 구분한다. + +## 2. 검증 기준 요약 + +- 레이어 선택 전 편집 도구 비활성 +- `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE` 변경분은 저장 전까지 로컬 세션 유지 +- `MOVE` + - preview drag 후 geometry 반영 +- `VERTEX_EDIT` + - vertex marker 표시 + - 양 끝점 이동 가능 + - geometry 반영 +- 저장 시 원본 직접 수정 금지 + +## 3. 라운드별 검증 계획 요약 + +### 3.1 초기/후속 검증 + +- `GIS 편집` 버튼 진입/종료, 메뉴 강조 잔류 재확인 +- 기본 툴바 노출과 상태 메시지 확인 + +### 3.2 세션도구 확장 검증 + +- 7개 편집 레이어 노출 확인 +- 레이어별 도구 활성 규칙 확인 +- `undo`, `reset` 확인 + +### 3.3 실제지도조작 확장 검증 + +- `MOVE`, `VERTEX_EDIT`, `DELETE`가 실제 지도 조작 구조를 받을 수 있는지 코드/브라우저 기준으로 점검 +- 자동 브라우저 drag는 한계가 있어 수동 검증 필요로 기록 + +### 3.4 `MOVE` + +- preview drag 구조와 세션 반영 흐름은 상대적으로 안정적 +- 유지 기준으로 본다 + +### 3.5 `VERTEX_EDIT` + +- 가장 많은 변동이 있었고 현재도 재검증 필요 +- 핵심 검증 기준 + - 선택 직후 vertex marker 표시 + - 양 끝점 이동 가능 + - 이동 후 geometry가 실제로 변경 + +### 3.6 2026-05-15 세션 유지 / `MOVE` / `VERTEX_EDIT` 회귀 검증 + +- 사용자 피드백 기준으로 아래 항목을 우선 재검증한다. + - `ADD`, `MOVE`, `VERTEX_EDIT` 후 세션 기록과 지도 preview가 바로 사라지지 않는지 + - 신규 추가 preview가 레이어별로 다른 색상으로 보여 구분 가능한지 + - 편집 세션 기본 패널이 최근 5건 정도 높이만 보이고, 아래 이력은 패널 내부 스크롤로 바로 확인 가능한지 + - `MOVE` 대상 선택 후 preview drag가 실제로 반응하는지 + - `VERTEX_EDIT` 대상 선택 후 모든 실꼭지점 marker와 midpoint handle이 드러나는지 + - `VERTEX_EDIT` 양 끝점 drag가 실제로 반응하는지 + - `VERTEX_EDIT` midpoint drag 후 새 vertex가 삽입되는지 + - `VERTEX_EDIT` 드래그 후 geometry 변경이 세션 요약과 preview에 반영되는지 + - `하수처리시설`이 point 기준으로 `VERTEX_EDIT` 비대상으로 처리되는지 + +### 3.7 2026-05-15 QGIS 유사 `VERTEX_EDIT` 재구현 검증 + +- QGIS 비교 기준으로 아래 항목을 별도 확인한다. + - 실꼭지점은 항상 보이되, midpoint는 모든 segment에 상시 표시되지 않고 hover segment 기준으로만 나타나는지 + - endpoint와 중간 vertex가 동일한 주황색 handle 규칙으로 이동되는지 + - midpoint drag가 새 vertex 삽입으로 반영되는지 + - feature 선택이 유지된 상태에서 연속 꼭지점 편집이 가능한지 + - `MOVE` 도구 회귀 없이 preview drag가 기존처럼 유지되는지 + +### 3.8 2026-05-15 편집 세션 팝업 전환 및 도구 아이콘 검증 + +- 편집 세션 고정 패널이 더 이상 지도 위에 남지 않는지 +- GIS 편집 진입 시 `GIS 편집 세션` window popup이 열리는지 +- 팝업에서 변경 목록, 마지막 변경 취소, 세션 초기화, 항목 focus가 메인 지도와 동기화되는지 +- 세션 안내 문구 영역이 제거됐는지 +- `추가`, `이동`, `꼭지점편집`, `삭제` 버튼에 `lucide-react` 아이콘이 표시되는지 + +## 4. 현재 검증 범위 + +### 4.1 진행 중 + +- `ADD` geometry family별 직접 캡처 +- `VERTEX_EDIT`의 QGIS 기준 사용자 경험 재정렬 + +### 4.2 이후 범위 + +- 실제 저장 API 연결 이후 통합 저장 검증 +- 원본 보호와 편집본 참조 갱신 검증 + +## 5. 현재 테스트 메모 + +- dev 서버가 켜져 있으면 `next build`는 수행하지 않는다. +- `next lint`는 환경 이슈로 안정적인 회귀 수단이 되지 못하고 있다. +- 지도 캔버스 기반 drag/vertex 테스트는 수동 브라우저 검증이 핵심이다. + +## 5-1. 브라우저 검증 선행조건 + +- `/map` 진입 가능한 로그인 상태 또는 로컬 임시 로그인 경로가 준비되어 있어야 한다. +- FE dev 서버와 관련 BE API가 모두 응답해야 한다. +- 편집 대상 레이어와 샘플 객체를 재현 가능한 테스트 데이터로 확보해야 한다. +- 인증 또는 외부 연동 문제로 진입이 막히면 `기능 결함`이 아니라 `인증 차단` 또는 `환경 차단`으로 분류한다. +- 로컬 인증 우회 필요 시 `COMMON_DOCS_ROOT/작업내용/공통-로그인/001-de-공통-로그인-로컬임시로그인-검토계획.md`를 참조한다. + +## 5-2. 브라우저 자동검증 관측 포인트 + +| 구분 | 관측 포인트 | 기대 결과 | 검증 방식 | +| --- | --- | --- | --- | +| 공통 | `GIS편집` 버튼 | 편집 모드 진입 시 강조, 종료 시 해제 | 자동 | +| 공통 | 편집 툴바 | 편집 모드에서만 노출 | 자동 | +| 공통 | 레이어 선택 | 미선택 시 도구 비활성, 선택 시 활성 | 자동 | +| 세션 | 변경 목록 | 조작 후 항목 누적 | 자동 | +| 세션 | `undo`, `reset` | 상태 복구 또는 초기화 | 자동 | +| `ADD` | 상태 메시지/신규 항목 | geometry family별 신규 추가 반영 | 자동 중심 | +| `MOVE` | preview label, 목록 요약 | drag 후 geometry 변경 반영 | 자동+수동 | +| `VERTEX_EDIT` | vertex marker 또는 modify handle | 선택 직후 노출, 이동 후 geometry 변경 | 수동 중심 | +| `DELETE` | 임시 삭제 스타일 | 저장 전까지 유지 | 자동+수동 | +| `SAVE` | payload/응답 처리 | 성공 또는 실패 메시지와 세션 상태 반영 | 자동 | + +## 5-3. 차단 사유 분류 기준 + +- `기능 결함`: 기대한 상태 전이 또는 응답 처리가 깨짐 +- `인증 차단`: 로그인, 토큰, 권한 문제로 진입 또는 호출이 막힘 +- `환경 차단`: 서버 미기동, 프록시/네트워크 문제 +- `데이터 차단`: 레이어 메타, 샘플 객체, 테스트 BBOX 부족 +- `자동화 한계`: canvas drag, vertex 조작을 도구가 안정적으로 재현하지 못함 + +## 6. 다음 검증 순서 + +1. `ADD` + - 점형 클릭 추가 + - 선형 연속 클릭 추가 + - 면형 폐합 추가 + - 편집 모드 종료 후에도 세션/preview 유지 확인 +2. `MOVE` + - preview drag 회귀 없는지 확인 + - drag 종료 후 요약/geometry 반영 확인 +3. `VERTEX_EDIT` + - 실꼭지점 marker 표시 + - hover segment midpoint 표시 + - 양 끝점 이동 + - 중간 vertex 이동 + - 드래그 후 geometry/요약 반영 확인 +4. `DELETE` + - 임시 삭제 스타일 유지 + +## 7. 흡수한 기존 문서 + +- `001-qa-SFR-012-003-하수관로편집-검증계획.md` +- `003-qa-SFR-012-003-하수관로편집-후속검증계획.md` +- `005-qa-SFR-012-003-하수관로편집-세션도구확장검증계획.md` +- `007-qa-SFR-012-003-하수관로편집-실제지도조작검증계획.md` +- `009-qa-SFR-012-003-하수관로편집-MOVE실드래그검증계획.md` +- `011-qa-SFR-012-003-하수관로편집-VERTEX_EDIT실조작검증계획.md` diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md new file mode 100644 index 000000000..3fd2b6a96 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md @@ -0,0 +1,165 @@ +# SFR-012-003 하수관로편집 통합 검증 결과 + +## 1. 문서 목적 + +- `SFR-012-003 하수관로편집`의 검증 결과를 기능당 1개 문서로 누적 관리한다. +- 기존 `002`, `004`, `006`, `008`, `010`, `012` 결과 문서를 이 문서로 흡수한다. +- 이후 라운드부터는 새 검증 결과 파일을 추가하지 않고 이 문서 내부 헤더로만 구분한다. + +## 2. 수행한 검증 요약 + +### 2.1 초기/후속 검증 + +- `GIS 편집` 모드 진입/종료 +- 메뉴 강조 잔류 재확인 + +### 2.2 세션도구 확장 검증 + +- 7개 편집 레이어 노출 +- 도구 활성 규칙 +- `undo`, `reset` + +### 2.3 실제지도조작 확장 검증 + +- `MOVE`, `VERTEX_EDIT`, `DELETE` 구조 점검 +- 자동 브라우저 drag 한계 확인 + +### 2.4 `MOVE` + +- preview drag 구조와 세션 반영 흐름은 상대적으로 안정적 + +### 2.5 `VERTEX_EDIT` + +- 여러 차례 보강했지만 현재는 재검증 필요 상태 + +### 2.6 2026-05-15 세션 유지 / 상호작용 회귀 대응 점검 + +- 코드 기준 점검 + - `GIS편집` 모드 종료 후에도 변경분이 있으면 세션 패널과 지도 preview를 유지하도록 수정했다. + - 신규 추가 preview는 레이어별 색상을 우선 사용하도록 보강했다. + - 편집 세션 기본 패널은 최근 5건 정도 높이만 보이도록 고정하고, 아래 이력은 패널 내부 스크롤로 바로 확인하도록 바꿨다. + - 추가 조정으로 세션 패널 높이를 더 줄이고, 하단 안내 문구는 제거했다. + - preview layer는 상호작용 중 redraw되도록 `updateWhileInteracting` 보강이 들어갔다. + - `MOVE`, `VERTEX_EDIT` interaction effect는 `gisEdit` 전체 객체 의존 대신 실제 pending interaction 중심으로 좁혔다. + - 원본 피처 선택 시 layerId 정규화를 추가해 namespace 또는 feature id 형식 차이로 편집 진입이 막히지 않게 했다. + - `하수처리시설(tb_il_swage_prcs_fclt)`은 point 기준으로 정정해 `VERTEX_EDIT` 대상에서 빠지도록 맞췄다. +- 정적 검증 + - `node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false` + - 결과: 기존 저장소 오류로 실패 + - 확인된 기존 오류 예시 + - `components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx` 타입 변환 경고 + - 공통 chart 컴포넌트 implicit any +- 로컬 라우트 응답 확인 + - `http://127.0.0.1:3002/map` 응답 200 + - `http://127.0.0.1:3002/auth/login` 응답 200 +- 자동 브라우저 검증 + - Playwright headless 실행 시 `spawn EPERM`으로 실패해 실제 canvas drag 자동검증은 수행하지 못했다. + - 따라서 `MOVE`, `VERTEX_EDIT`의 실조작 확인은 수동 브라우저 재검증이 남아 있다. + +### 2.7 2026-05-15 QGIS 유사 `VERTEX_EDIT` 재구현 점검 + +- 코드 기준 점검 + - `VERTEX_EDIT`는 custom vertex handle layer를 유지하되, 실꼭지점 상시 표시와 hover segment midpoint 1건 표시 구조로 단순화했다. + - midpoint 후보는 `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon` 모두 계산 가능하도록 분리했다. + - vertex 근처에서는 midpoint를 숨기고, segment 근처에서만 midpoint를 띄우도록 pixel tolerance를 적용했다. + - endpoint와 일반 vertex는 같은 drag 규칙을 사용하고, midpoint drag는 새 좌표 삽입으로 처리한다. + - `MOVE` preview layer의 `clickPsblYn="Y"` 기준은 유지했고, vertex layer는 별도 레이어로 계속 분리했다. +- 정적 검증 + - `pnpm exec tsc --noEmit --incremental false --pretty false` + - 결과: 이번 변경분 관련 신규 타입 오류는 정리했고, 저장소 기존 오류만 남았다. + - 남은 기존 오류 예시 + - `components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고 + - 공통 chart 컴포넌트 implicit any +- 로컬 환경 확인 + - `http://127.0.0.1:3002/map` 응답 200 + - `http://127.0.0.1:3002/auth/login` 응답 200 + - `http://127.0.0.1:3002/popup/gisEditSessionPopup` 응답 200 + - 현재 세션에서는 브라우저 전용 자동화 도구를 확보하지 못해 canvas 기반 midpoint hover/drag 실조작은 수동 재검증 필요로 남긴다. + +### 2.8 2026-05-15 편집 세션 팝업 전환 및 도구 아이콘 점검 + +- 코드 기준 점검 + - 지도 좌하단 고정 세션 패널 렌더링은 제거했다. + - `GIS 편집 세션` popup definition, registry, popup content를 추가해 기존 window popup 체계에 맞췄다. + - 메인 `/map`은 GIS 편집 진입 시 세션 팝업을 열고, postMessage로 세션 snapshot을 동기화하도록 바꿨다. + - 팝업에서 `마지막 변경 취소`, `세션 초기화`, `항목 focus`를 메인 지도에 다시 전달하도록 연결했다. + - 세션 패널의 안내 문구 영역은 제거했다. + - `추가`, `이동`, `꼭지점편집`, `삭제` 버튼은 `lucide-react` 아이콘을 사용하도록 바꿨다. +- 정적 검증 + - `pnpm exec tsc --noEmit --incremental false --pretty false` + - 결과: 여전히 저장소 기존 오류로 실패 + - 기존 오류 범주 + - 기존 공통 타입 불일치 + - 기존 chart implicit any +- 수동 재검증 필요 항목 + - 브라우저 팝업 차단 여부 + - 세션 팝업과 메인 지도 간 메시지 동기화 + - 도구 아이콘 실제 렌더링 + +## 3. 현재 검증 상태 + +### 3.1 통과 + +- `GIS 편집` 모드 진입/종료 +- 레이어별 도구 활성 규칙 +- 세션 `undo`, `reset` +- `MOVE`의 기본 preview drag + +### 3.2 진행 중 + +- `ADD` geometry family별 직접 캡처 +- `VERTEX_EDIT`의 QGIS 기준 사용자 경험 재정렬 +- `MOVE`, `VERTEX_EDIT` 실조작 수동 재검증 + +### 3.3 보류 + +- 실제 저장 API 연결 이후 통합 저장 검증 +- 원본 보호와 편집본 참조 갱신 검증 + +## 4. 보류/리스크 + +- dev 서버가 켜져 있으면 `next build`를 함께 돌리기 어렵다. +- `next lint`는 환경 이슈로 안정적인 회귀 수단이 되지 못하고 있다. +- 지도 캔버스 기반 drag/vertex 테스트는 수동 브라우저 검증 의존도가 높다. +- `VERTEX_EDIT`는 하수관로 `LineString`/`MultiLineString` 처리와 vertex marker UX를 다시 고정해야 한다. +- 현재 저장소의 기존 TypeScript 오류 때문에 변경분만 분리한 정적 통과 여부를 보장하기 어렵다. + +## 4-1. 관측 증적 요약 + +- 자동으로 확인된 항목 + - `GIS편집` 모드 진입/종료 + - 레이어별 도구 활성 규칙 + - 세션 `undo`, `reset` + - `MOVE`의 기본 preview drag 준비 구조 +- 수동 또는 후속 확인이 필요한 항목 + - 실제 canvas drag 정확도 + - `VERTEX_EDIT`의 vertex marker 노출과 꼭지점 직접 이동 + - 저장 API 연결 후 응답 처리와 원본 보호 +- 간접 관측 표식 + - 상태 메시지 + - 변경 목록 + - preview label + - 임시 삭제 스타일 + +## 4-2. 차단 사유 분류 + +- `자동화 한계` + - 지도 캔버스 기반 drag/vertex 조작은 자동 브라우저 재현 안정성이 낮다. + - 이번 세션에서는 Playwright 또는 브라우저 플러그인 기반 canvas 자동화 경로를 확보하지 못했다. +- `인증 차단` + - 실제 브라우저 통합 검증은 로그인/세션 준비 여부에 따라 막힐 수 있다. +- `환경 차단` + - dev 서버 실행 중 build 병행이 어렵고, lint도 안정적 회귀 수단이 아니다. +- `기능 결함 또는 미완료` + - `VERTEX_EDIT`는 아직 요구사항 기준으로 다시 고정이 필요하다. + +## 5. 흡수한 기존 문서 + +- `002-qa-SFR-012-003-하수관로편집-검증결과.md` +- `004-qa-SFR-012-003-하수관로편집-후속검증결과.md` +- `006-qa-SFR-012-003-하수관로편집-세션도구확장검증결과.md` +- `008-qa-SFR-012-003-하수관로편집-실제지도조작검증결과.md` +- `010-qa-SFR-012-003-하수관로편집-MOVE실드래그검증결과.md` +- `012-qa-SFR-012-003-하수관로편집-VERTEX_EDIT실조작검증결과.md` diff --git a/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md b/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md new file mode 100644 index 000000000..c3aec0802 --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md @@ -0,0 +1,216 @@ +# SFR-012-003 하수관로편집 작업카드 + +## 1. 작업 개요 +- 작업 ID: SFR-012-003-FE +- 작업명: 하수관망도 GIS편집 프론트엔드 구현 계획 +- 상태: 진행 +- 작성일: 2026-05-14 +- 담당 역할: FE +- 관련 역할: DE, BE, QA +- 담당 범위: `apps/sn` 지도형 화면에서 GIS편집 진입, 레이어 선택, 추가/이동/꼭지점편집/삭제, 저장 payload 준비까지 구현 +- 근거 문서: + - `COMMON_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-de-SFR-012-003-하수관로편집-설계.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_요구사항명세서_v1_1.docx` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면시나리오_v1_1.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면_v1_1.html` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면설계서_v1_1.png` + +## 1-2. 최근 진행 갱신 +- 2026-05-14 + - 7개 편집 레이어 확장, 레이어별 허용 도구 규칙, 로컬 세션 undo/reset, `MOVE`/`VERTEX_EDIT`/`DELETE` 임시 변경분 누적까지 반영했다. + - 이번 라운드에서 `MOVE`를 preview feature 기반 실제 drag 흐름으로 올리고, 지도 위 preview 강조와 재선택 UX를 보강했다. + - `VERTEX_EDIT` 구현은 QGIS 기준 사용자 경험과 어긋나는 구간이 확인되어 재설계가 필요하다. + - 재구현 기준은 `ADD=geometry family별 직접 캡처`, `MOVE=현 방식 유지`, `VERTEX_EDIT=양 끝점 포함 vertex marker 조작`으로 다시 고정한다. + - 다음 핵심 잔여 범위는 설계 문서 기준 재정렬 후 `ADD`, `VERTEX_EDIT`, `DELETE`를 순서대로 안정화하는 것이다. + +## 1-3. 브라우저 검증 선행조건 +- 접속 URL: `sn` 지도 메인 화면 진입이 가능해야 한다. +- 로그인/권한: `/map` 진입과 지도 API 호출이 가능한 계정 또는 로컬 임시 로그인 경로가 준비되어야 한다. +- 서버 조건: FE dev 서버, BE API, 프록시 또는 gateway 경로가 응답해야 한다. +- 데이터 조건: 편집 대상 레이어, 샘플 객체, 테스트 BBOX를 확보해야 한다. +- 인증 차단 시 대체 경로: `COMMON_DOCS_ROOT/작업내용/공통-로그인/001-de-공통-로그인-로컬임시로그인-검토계획.md` 기준으로 로컬 로그인 가능 여부를 먼저 확인한다. +- 자동화 한계 예상 항목: canvas drag, vertex 직접 조작은 수동 검증 전환 가능 항목으로 본다. + +## 1-1. 공통 동기화 확인 +- 공통 작업현황판 확인 여부: 확인 +- 파일점유현황 확인 여부: 확인 +- 결정로그 확인 여부: 확인 +- 기존 진행 중 작업과의 관계: 지도 메인 화면 `map/page.tsx`는 최근 유하방향표출 작업 이력이 있어 동일 파일 충돌 주의가 필요하다. + +## 2. 작업 목적 +- 기존 하수관망도 지도 화면에 `GIS편집` 기능을 추가해 사용자가 편집 대상 레이어를 고른 뒤 객체 추가, 이동, 꼭지점편집, 삭제, 저장까지 한 흐름에서 수행할 수 있게 한다. +- 프론트는 저장 전까지 편집 변경분을 로컬 세션으로 관리하고, 저장 시 BE가 제공할 편집 저장 API에 맞춰 payload를 전달한다. +- 백엔드 개발자가 바로 이어받을 수 있도록 필요한 API, 화면 상태, 예외 흐름을 FE 기준으로 구체화한다. +- 실제 API 구현은 범위 밖이며, 백엔드 공유용 요청 사항은 `작업내용` 문서로 분리해 관리한다. + +## 3. 수정 대상 파일 +| 구분 | 파일 경로 | 수정 여부 | 근거 | +| --- | --- | --- | --- | +| 화면 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 예정 | 지도 메인 화면에서 우측 기능 버튼, 편집 모드 상태, OpenLayers interaction 연결 필요 | +| 컴포넌트 | `FE_APP_ROOT/app/(map-layout)/map/components/gisEdit/*` | 예정 | 툴바, 세션 패널, 편집 훅 분리 후보 | +| 팝업/선택 | `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` | 가능성 있음 | 편집 모드 중 기존 상세 팝업 충돌 차단 필요 | +| API 호출 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 또는 `components/gisEdit/*` | 부분 완료 | 현재는 저장 payload 미리보기까지 구현, 실제 API 호출은 후속 | +| 공통 비즈니스 컴포넌트 | `FE_BC_ROOT/**` | 기본 제외 | 초기 구현은 `apps/sn` 내부 해결 우선 | + +## 3-1. 쓰기 범위와 충돌 주의 +| 구분 | 경로 | 담당 역할 | 처리 기준 | +| --- | --- | --- | --- | +| 쓰기 허용 | `apps/sn/**` | FE | 지도 화면, 편집 상태, API 호출 작업 | +| 쓰기 허용 | `packages/common/business_components/**` | FE | 꼭 필요한 경우에만 최소 범위 수정 | +| 쓰기 금지 | `packages/common/business_components` 외 공통 패키지 | FE | 문제 원인과 영향 범위만 기록 | +| 쓰기 금지 | 타 앱 `apps/*` | FE | 문제 원인과 영향 범위만 기록 | + +- 충돌 주의 파일: + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` +- 인수인계 메모: + - `map/page.tsx`는 이미 레이어 트리, 검색, 유하방향표출까지 담당 중이라 편집 기능을 그대로 누적하면 과도하게 비대해질 수 있다. + - 가능하면 `components/gisEdit` 하위로 상태/뷰를 나눠야 한다. +- 파일점유 등록 여부: 공통 파일점유현황에 계획 작업 반영 + +## 4. 화면 흐름 +- 진입 경로: `관망도편집 > 하수관망도 편집`의 지도형 메인 화면 +- 사용자 조작: + - 우측 `GIS편집` 클릭 + - 편집 대상 레이어 선택 + - `추가`, `이동`, `꼭지점편집`, `삭제` 중 선택 + - 저장 클릭 +- 정상 흐름: + - 편집 모드 진입 + - 레이어 선택 전 도구 비활성 + - 레이어 선택 후 도구 활성 + - `추가`는 geometry family별로 사용자가 지도에서 직접 형상을 캡처 + - `이동`은 preview drag로 위치 조정 + - `꼭지점편집`은 선택 객체의 vertex marker를 노출하고 양 끝점을 포함해 이동 + - 변경분이 로컬 편집 세션에 누적 + - 저장 시 원본과 분리된 편집본 저장 요청 +- 빈 결과 흐름: + - 변경분이 없으면 저장 비활성 또는 안내 메시지 표시 +- 오류 흐름: + - 편집 대상 미선택 + - 미지원 레이어 선택 + - 객체 미선택 + - geometry 오류 + - 저장 실패 시 임시 변경분 유지 + +## 5. API 연동 +| 기능 | Method | URL | 요청 | 응답 | 확인 상태 | +| --- | --- | --- | --- | --- | --- | +| 기존 레이어 트리 조회 | `GET` | `/api/v1/map/lyrStngs/SN_NETWORK` | 기존 지도 레이어 그룹 | 레이어 트리 데이터 | 기존 사용 중 | +| 편집 가능 레이어 메타 조회 | `GET` | `/api/v1/map/gisEditLayers` | `lyrGrp`, `screenId` | 편집 가능 레이어, geometry 타입, 허용 도구 | BE 신규 필요 | +| 편집 저장 | `POST` | `/api/v1/map/gisEditSessions/save` | 레이어 정보, view, `changes[]` | `sessionId`, `editFileId`, `versionNo`, `savedCount` | BE 신규 필요 | + +- FE 메모: + - 저장 전까지 변경분은 FE 로컬 상태로 보관한다. + - 현재 구현은 실제 호출 대신 payload 미리보기만 생성한다. + - BE 요청 사항은 `FE_APP_DOCS_ROOT/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-프론트공유API요청.md`에 별도 정리한다. + +## 6. 영향 범위 +- `apps/sn` 화면 영향: + - 지도 메인 화면 상단 툴바 + - 우측 기능 버튼 + - 편집 세션 패널 + - 기존 피처 클릭 상세와의 충돌 제어 +- `business_components` 영향: + - 초기 계획상 없음 + - `Gis` 공통 확장이 꼭 필요해지면 별도 승인/영향분석 후 검토 +- 백엔드 API 영향: + - 편집 가능 레이어 메타 API + - 저장 API + - 원본 분리 저장 및 참조 갱신 로직 +- 다른 앱 영향 가능성: + - 현재 계획상 없음 + - `apps/sn` 내부 구현 우선 + +## 6-1. 멀티에이전트 작업 분리 +- FE 담당 산출물: + - 편집 모드 토글 + - 툴바 및 세션 패널 + - OpenLayers 편집 interaction + - 저장 payload 조립 +- BE 담당 산출물: + - 편집 가능 레이어 메타 조회 + - 저장 API + - 원본 분리 저장, 편집본 참조 갱신 +- QA 담당 산출물: + - 편집 대상 미선택/객체 미선택/geometry 오류/저장 실패 검증 + - 저장 후 원본 보호 검증 +- 병렬 진행 가능 항목: + - FE 로컬 상태/툴바 UI 설계 + - BE API 명세 확정 +- 순차 진행 필요 항목: + - 저장 payload 최종 필드 확정 후 FE-BE 연동 검증 + +## 6-2. 채팅방 간 충돌 방지 +- 같은 기능을 다른 채팅방에서 진행 중인지: 현재 공통 작업현황판에는 신규 기능 구현 행이 아직 없다. 이번 문서 작성으로 최초 등록한다. +- 겹치는 파일 또는 폴더: + - `map/page.tsx` + - `featureClickEventPopup.tsx` + - `FE_APP_DOCS_ROOT/작업계획/**`는 문서 재정리 흔적이 있어 경로 유지 주의 +- 점유 충돌 시 처리 방식: + - 문서와 실제 코드 작업을 분리하고, 코드 착수 전에 파일점유현황을 다시 갱신한다. + +## 7. 검증 방법 +- 빌드 명령: + - 개발 서버 미실행 확인 후 `pnpm --filter sn run build` + - 필요 시 `pnpm turbo run build --filter=sn` +- 브라우저 URL: + - 기본 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 진입 화면 +- 사용자 조작: + - `GIS편집` 클릭 + - 레이어 선택 전/후 도구 활성 상태 확인 + - `추가`, `이동`, `꼭지점편집`, `삭제` 각각 1회 수행 + - 저장 클릭 +- 기대 결과: + - 레이어 선택 전 편집 도구 비활성 + - 변경분 누적 시 저장 활성 + - 저장 실패 시 임시 변경분 유지 + - 저장 성공 시 원본 보존 메시지 표시 +- 네트워크 응답 확인 기준: + - `gisEditLayers` 응답에 editable 여부와 도구 목록 포함 + - `gisEditSessions/save` 요청에 `changes[]`가 변경 유형별로 정확히 담김 + - 저장 성공 응답에 편집본 식별자와 버전 정보 포함 + +## 7-1. 하네스 관측 포인트 +- 화면 관측 + - `GIS편집` 버튼 강조 여부 + - 툴바 노출 여부 + - 선택 레이어명 + - 상태 메시지 + - 변경 목록 행 수 +- 지도 상호작용 관측 + - `ADD` 완료 후 신규 항목 생성 + - `MOVE` drag 종료 후 preview label과 목록 요약 변화 + - `VERTEX_EDIT` 시 vertex marker 또는 modify handle 노출 + - `DELETE` 시 삭제 예정 스타일 유지 +- 세션 관측 + - `undo`, `reset`, `canSave` + - 저장 실패 시 임시 변경분 유지 +- 차단 사유 분류 + - 기능 결함 + - 인증 차단 + - 환경 차단 + - 데이터 차단 + - 자동화 한계 + +## 8. 롤백 방법 +- `GIS편집` 토글과 편집 툴바 UI 제거 +- `components/gisEdit/*` 신규 파일 제거 +- 편집 저장 API 호출 분기 제거 +- 기존 지도 조회/레이어 트리/피처 상세 흐름만 남기기 + +## 9. 예상 위험 +- `map/page.tsx`가 이미 크기와 책임이 커서 기능 누적 시 회귀 위험이 높다. +- 점형/선형 객체의 편집 interaction이 달라 공통 훅 설계가 어색해질 수 있다. +- 편집 가능 레이어/geometry 타입/저장 대상이 확정되지 않으면 FE 구현이 임의 가정을 하게 될 수 있다. +- 편집 모드 중 기존 피처 클릭 상세 팝업이 뜨면 객체 선택/이동과 충돌할 수 있다. + +## 10. 완료 기준 +- `GIS편집` 버튼으로 편집 모드 on/off 가능 +- 레이어 선택 전 편집 도구 비활성 +- 선택 레이어 기준으로 `추가`, `이동`, `꼭지점편집`, `삭제` 최소 동작 가능 +- 변경분이 로컬 편집 세션에 누적되고 목록으로 확인 가능 +- 저장 payload가 설계 문서 계약과 맞게 구성됨 +- BE 공유용 API 요청 문서가 별도 정리됨 +- 브라우저에서 정상/예외 시나리오를 확인할 수 있음 +- 결과 보고 시 작업 목적, 수정 대상 파일, 영향 범위, 검증 방법, 롤백, 리스크를 정리할 수 있음 diff --git a/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md new file mode 100644 index 000000000..2a99ad3aa --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md @@ -0,0 +1,227 @@ +# SFR-012-003 하수관로편집 통합 구현 기록 + +## 1. 문서 목적 + +- `SFR-012-003 하수관로편집`의 FE 구현 이력, 결정사항, 남은 리스크를 기능당 1개 문서로 누적 관리한다. +- 기존 `001~006` 구현 문서를 이 문서로 흡수한다. +- 이후 라운드부터는 새 파일을 추가하지 않고 이 문서 내부 헤더로만 구분한다. + +## 2. 입력 근거 + +- `COMMON_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-de-SFR-012-003-하수관로편집-설계.md` +- `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면시나리오_v1_1.md` +- `FE_APP_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md` + +## 3. 공통 계약 + +- 편집 결과는 원본 GIS를 직접 수정하지 않고 FE 로컬 세션에 우선 누적한다. +- `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE`는 동시에 활성화하지 않는다. +- `ADD` + - geometry family별 직접 캡처 방식으로 구현한다. + - `POINT`는 클릭 1회, `LINE`은 연속 클릭 + 종료, `POLYGON`은 연속 클릭 + 폐합 종료를 따른다. +- `MOVE` + - preview feature drag 기반으로 유지한다. +- `VERTEX_EDIT` + - QGIS vertex tool 기준으로 vertex marker를 노출하고, 양 끝점 포함 vertex 이동이 가능해야 한다. +- `DELETE` + - 원본을 즉시 지우지 않고 임시 삭제 상태로만 유지한다. + +## 3-1. 편집도구 아이콘 기준 + +현재 구현과 문서 표기는 화면 시안 기준 편집도구 아이콘 의미를 따른다. + +| 도구 | 아이콘 | 메모 | +| --- | --- | --- | +| 추가 | | geometry family별 직접 캡처 대상 | +| 이동 | | preview drag 유지 기준 | +| 꼭지점편집 | | 양 끝점 포함 vertex 조정 목표 | +| 삭제 | | 임시 삭제 상태 유지 | +| 저장 | | 원본 분리 저장 계약 | + +## 4. 구현 라운드 요약 + +### 4.1 초기 범위 정리 + +- FE 로컬 세션, 툴바, 저장 payload 미리보기 구조를 준비했다. +- 백엔드 공유용 편집 메타/저장 API 초안을 정리했다. + +### 4.2 레이어/도구 확장 + +- 편집 후보 레이어를 7종으로 확장했다. +- 레이어별 허용 도구 활성 규칙을 추가했다. +- 세션 `undo`, `reset`을 추가했다. + +### 4.3 `MOVE` + +- preview feature 기반 `Translate` interaction을 연결했다. +- 같은 객체 재선택 시 중복 change 누적을 줄이는 focus 흐름을 추가했다. +- 현재 `MOVE`는 유지 기준으로 본다. + +### 4.4 `VERTEX_EDIT` + +- 여러 차례 interaction 보강을 시도했으나, 요구사항과 다른 구현 흔적이 남았다. +- 현재 핵심 재정렬 기준: + - vertex marker가 보일 것 + - 양 끝점 이동 가능 + - `MOVE`와 서로 회귀를 일으키지 않을 것 + +### 4.5 현재 라운드 `ADD` 재구현 시작 + +- 기존 지도 중심 임시 생성 방식을 폐기했다. +- `ADD` 도구 선택 시 geometry type별 `Draw` interaction을 연결하는 방향으로 재구현 중이다. + +### 4.6 2026-05-15 세션 유지 및 상호작용 회귀 대응 + +- 사용자 피드백 기준 이슈 + - `ADD`, `MOVE`, `VERTEX_EDIT` 변경분이 화면에서 바로 사라져 세션 추적이 어렵다. + - `MOVE` 선택 후 드래그가 실제로 움직이지 않는다. + - `VERTEX_EDIT`는 주황색 점이 기대보다 빈약하게 보이고 드래그 편집이 체감되지 않는다. +- 이번 라운드 반영 + - `GIS편집` 모드를 꺼도 변경분이 있으면 세션 패널과 지도 preview를 유지하도록 조정했다. + - preview layer를 `updateWhileInteracting=true`로 바꾸고, interaction start/end 시 `renderSync`와 source 변경 반영을 보강했다. + - 원본 피처 선택 시 layerId 비교가 namespace/feature id 형식 차이로 깨지지 않도록 layerId 정규화를 추가했다. + - interaction effect가 `gisEdit` 전체 객체 변경에 과민하게 반응하지 않도록 의존성을 좁혀 `MOVE`, `VERTEX_EDIT` 재연결 흐름을 안정화했다. + - 세션 패널은 편집 모드 종료 후에도 변경분이 남아 있으면 계속 노출되도록 바꿨다. + - 신규 추가 preview는 레이어별 색상을 우선 사용하도록 보강해 같은 화면에서 여러 시설을 편집해도 사용자가 구분할 수 있게 했다. + - `하수처리시설(tb_il_swage_prcs_fclt)`은 실제 레이어 설정 기준에 맞춰 `Point`로 정정하고 `VERTEX_EDIT` 대상에서 제외했다. + +### 4.7 2026-05-15 편집 세션 패널 축소/확장 UX 보강 + +- 편집 세션이 길어질 때 지도를 과도하게 가리는 문제가 있어 기본 패널은 최근 5건만 표시하도록 줄였다. +- 기본 패널에는 `총 N건` 배지와 `전체 보기` 버튼을 추가했다. +- `전체 보기`는 드래그 가능한 팝업으로 분리해 사용자가 지도 위에서 위치를 옮기며 확인할 수 있게 했다. +- 편집 모드를 종료한 뒤에도 세션이 남아 있으면 compact 패널과 전체 보기 팝업에서 계속 확인할 수 있다. + +### 4.8 2026-05-15 편집 세션 패널 단순화 + +- 사용자 피드백에 따라 `전체 보기` 팝업은 제거했다. +- 대신 기본 패널 높이를 최근 5건 정도만 보이는 크기로 고정하고, 패널 내부 스크롤로 아래 이력을 바로 확인할 수 있게 바꿨다. +- 결과적으로 지도 맥락은 유지하면서도 추가 팝업 조작 없이 세션 전체를 훑을 수 있는 구조로 단순화했다. +- 후속 조정으로 패널 높이를 한 번 더 줄이고, 하단 안내 문구 영역도 제거해 화면 점유를 더 낮췄다. + +### 4.9 2026-05-15 QGIS Vertex Tool 기준 재설계 착수 + +- QGIS 공식 문서 기준 확인 결과, Vertex Tool은 “모든 실꼭지점 표시”, “끝점 이동”, “segment 중간 가상점 추가”, “selection persistent”를 기본으로 제공한다. +- 기존 구현은 OpenLayers `Modify` 기본 overlay 위주여서 marker 표시와 endpoint drag 체감이 QGIS와 크게 달랐다. +- 이번 라운드부터 `VERTEX_EDIT`는 아래 구조로 재구성한다. + - preview geometry와 분리된 vertex handle 전용 레이어 + - 실꼭지점 handle + - segment 중간 가상 handle + - handle drag 후 geometry 재조립 및 세션 반영 + +### 4.10 2026-05-15 `VERTEX_EDIT` QGIS 유사화 재구현 + +- 이번 라운드 결정 + - `ADD`, `MOVE`는 기준선으로 유지하고 `VERTEX_EDIT`만 재구현 대상으로 제한했다. + - OpenLayers `Modify` 기본 overlay로 되돌리지 않고, 기존 custom handle layer 방향을 유지하되 실험 코드를 단순화했다. + - “모든 midpoint 상시 표시” 방식은 버리고 “실꼭지점 상시 + hover segment midpoint 1건” 구조로 바꿨다. +- 코드 반영 + - 선택 preview feature의 실제 vertex만 항상 렌더링하도록 handle 생성 함수를 분리했다. + - `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon`에서 segment 후보를 계산하는 midpoint candidate 수집 유틸을 추가했다. + - pointer move 기준으로 가장 가까운 segment 1건만 midpoint handle을 노출하고, vertex 근처에서는 midpoint를 숨기도록 pixel tolerance 로직을 넣었다. + - midpoint drag 시 해당 segment 사이에 새 좌표를 삽입하고, vertex drag 시 기존 좌표만 이동하도록 geometry 재조립 흐름을 유지했다. + - interaction 시작/종료 중 midpoint hover가 섞여 드래그가 흔들리지 않도록 dragging flag와 pointer move cleanup을 분리했다. + - `MOVE` preview layer의 `clickPsblYn="Y"` 기준은 그대로 유지했고, vertex layer는 계속 별도 레이어로 유지했다. +- 사용자 체감 개선 목표 + - 어떤 점이 실꼭지점인지 즉시 보인다. + - midpoint는 필요한 segment 근처에서만 나타나 화면이 덜 산개된다. + - endpoint도 일반 vertex와 같은 방식으로 조작된다. + +### 4.11 2026-05-15 편집 세션창 팝업 전환 및 도구 아이콘 정리 + +- 사용자 추가 요청 기준 반영 + - 편집 세션창의 “현재 구현 ... 검토합니다” 안내 영역을 제거했다. + - 지도 좌하단 고정 세션 패널은 제거하고, 기존 `window popup` 체계의 `GIS 편집 세션` 팝업으로 전환했다. + - 팝업 기본 크기는 세션 목록과 payload 미리보기를 함께 볼 수 있게 `520 x 820`으로 잡았다. + - `추가`, `이동`, `꼭지점편집`, `삭제` 버튼은 분석도구와 같은 `lucide-react` 아이콘을 붙였다. +- 구현 방식 + - 메인 `/map` 화면은 GIS 편집 모드 진입 시 세션 팝업을 열고, postMessage로 세션 snapshot을 동기화한다. + - 팝업에서 `마지막 변경 취소`, `세션 초기화`, `항목 focus`를 누르면 다시 메인 창으로 메시지를 보내 실제 편집 세션을 제어한다. + - 기존 `gisEditPopup` placeholder는 유지하고, 세션 전용 팝업은 `gisEditSessionPopup`으로 별도 등록했다. + +### 4.12 2026-05-15 중복 endpoint handle 통합 + +- 사용자 피드백 기준 이슈 + - 관로 시점/종점 주변에 같은 위치의 주황색 꼭지점이 2개씩 겹쳐 보이는 경우가 있었다. +- 이번 라운드 반영 + - vertex handle 생성 시 raw vertex를 그대로 전부 노출하지 않고, 같은 좌표를 공유하는 vertex들은 화면에서 handle 1개로 묶었다. + - 단순 표시만 합친 것이 아니라, 묶인 handle을 드래그하면 같은 좌표를 공유하던 raw vertex들을 함께 이동시키도록 처리했다. + - 내부 `MultiLineString`/분리 part 구조는 유지하고, 표시와 드래그 동작만 통합했다. + +### 4.13 2026-05-15 외부 제출용 단일 산출물 생성 + +- 사용자 요청으로 오늘 작업 내역, 설계 변경, 검증 계획, 검증 결과를 묶은 제출용 단일 `md` 파일을 별도 생성한다. +- 이는 통합 문서 체계 예외 산출물이며, 기능 운영 문서는 기존 canonical 4문서 체계를 계속 기준으로 유지한다. + +## 5. 기능별 현재 상태 + +### 5.1 `ADD` + +- 요구사항 + - 사용자가 지도 위에서 직접 점/선/면을 캡처해야 한다. +- 이전 상태 + - 지도 중심 기준 임시 geometry 자동 생성 +- 현재 상태 + - `Draw` interaction 기반으로 전환 중 +- 남은 과제 + - 선형/면형 종료 UX + - 캡처 취소 UX + - 세션 summary와 preview label 정교화 + - 신규 추가 색상은 레이어 메타 우선, 없으면 FE fallback 색으로 처리 + +### 5.2 `MOVE` + +- preview drag 방식으로 동작 확인 +- 유지 기준 + - 선택 객체 1건 기준 preview 생성 + - drag 종료 후 세션 geometry 갱신 +- 2026-05-15 보강 + - preview layer redraw와 interaction 재바인딩 타이밍을 안정화했다. + - 실제 브라우저 수동 drag 재확인은 아직 남아 있다. + +### 5.3 `VERTEX_EDIT` + +- 요구사항 + - 선택 객체의 vertex marker가 표시되어야 함 + - 양 끝점 이동 가능 + - 중간 vertex 이동 가능 +- 현재 리스크 + - 하수관로 geometry가 `LineString`/`MultiLineString`으로 섞여 들어온다. + - interaction 구현이 흔들리면 `MOVE`까지 회귀할 수 있다. +- 다음 기준 + - 하수관로를 기준으로 QGIS형 vertex handle 흐름부터 고정 +- 2026-05-15 보강 + - preview geometry와 별도인 vertex handle 레이어를 유지하되, 실꼭지점만 상시 표시하고 midpoint는 hover segment에 대해서만 노출하도록 재구현했다. + - endpoint drag, midpoint drag 후 vertex 삽입, geometry 재조립 흐름을 하나의 custom handle 규칙으로 통일했다. + - `하수처리시설`은 point라서 `VERTEX_EDIT` 대상이 아니라는 기준을 코드와 문서에 함께 맞췄다. + +### 5.4 `DELETE` + +- 임시 삭제 세션 흐름은 존재 +- 삭제 대상 시각 구분 강화는 후속 + +## 6. BE 공유 사항 + +- `GET /api/v1/map/gisEditLayers` + - geometry type/family + - editable 여부 + - allowed tools +- `POST /api/v1/map/gisEditSessions/save` + - 변경 유형별 geometry payload + - 원본 분리 저장 + +## 7. 남은 리스크 + +- `map/page.tsx`에 interaction 책임이 많이 몰려 있어 회귀 위험이 높다. +- `VERTEX_EDIT`는 하수관로 기준 단순화가 먼저 필요하다. +- `ADD`가 QGIS capture 경험과 완전히 같아지려면 종료/취소 UX를 더 다듬어야 한다. +- canvas 기반 `MOVE`, `VERTEX_EDIT`는 자동화 브라우저 검증이 제한적이라 실제 드래그 체감은 수동 확인이 남아 있다. + +## 8. 흡수한 기존 문서 + +- `001-fe-SFR-012-003-하수관로편집-프론트공유API요청.md` +- `002-fe-SFR-012-003-하수관로편집-GIS편집상태재검토.md` +- `003-fe-SFR-012-003-하수관로편집-세션도구확장.md` +- `004-fe-SFR-012-003-하수관로편집-실제지도조작확장.md` +- `005-fe-SFR-012-003-하수관로편집-MOVE실드래그구현.md` +- `006-fe-SFR-012-003-하수관로편집-VERTEX_EDIT실조작구현.md` diff --git a/apps/sn/package.json b/apps/sn/package.json index 20aa6581d..fd74ab346 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -28,6 +28,7 @@ "js-cookie": "^3.0.5", "moment": "^2.30.1", "next": "15.3.8", + "lucide-react": "^0.474.0", "react-chartjs-2": "^5.3.0", "react-hot-toast": "^2.5.2", "react-dnd": "^16.0.1", diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index fe2ee66ba..6e88666cb 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -103,6 +103,12 @@ export const windowPopupDefinitions = { width: 1080, height: 760, }, + gisEditSessionPopup: { + key: "gisEditSessionPopup", + title: "GIS 편집 세션", + width: 520, + height: 820, + }, gisManagementPopup: { key: "gisManagementPopup", title: "GIS 관리", diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts index 8387e03c1..f46e747d6 100644 --- a/apps/sn/src/features/windowPopup/model/types.ts +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -31,6 +31,11 @@ export const WINDOW_POPUP_MESSAGE_TYPE = { FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted", MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature", MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple", + GIS_EDIT_SESSION_READY: "sn:popup:gisEditSessionReady", + GIS_EDIT_SESSION_SYNC: "sn:popup:gisEditSessionSync", + GIS_EDIT_SESSION_FOCUS_CHANGE: "sn:popup:gisEditSessionFocusChange", + GIS_EDIT_SESSION_REMOVE_LAST_CHANGE: "sn:popup:gisEditSessionRemoveLastChange", + GIS_EDIT_SESSION_CLEAR_CHANGES: "sn:popup:gisEditSessionClearChanges", } as const; export type WindowPopupMessageType = diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index c76d9064f..cffa5a5c6 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -13,6 +13,7 @@ import SearchModalPopupContent from "./ui/SearchModalPopupContent"; import SscDetailPopupContent from "./ui/SscDetailPopupContent"; import type { WindowPopupContentProps } from "./model/types"; import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent"; +import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent"; import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent"; import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent"; @@ -107,6 +108,10 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.gisEditPopup, component: GisEditPopupContent, }, + gisEditSessionPopup: { + ...windowPopupDefinitions.gisEditSessionPopup, + component: GisEditSessionPopupContent, + }, gisManagementPopup: { ...windowPopupDefinitions.gisManagementPopup, component: GisManagementPopupContent, diff --git a/apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx new file mode 100644 index 000000000..54b035702 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import GisEditSessionPanel from "@/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel"; +import type { GisEditSessionSnapshot } from "@/app/(map-layout)/map/components/gisEdit/gisEditTypes"; +import { + WINDOW_POPUP_MESSAGE_TYPE, + type WindowPopupContentProps, +} from "../model/types"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; + +const EMPTY_SNAPSHOT: GisEditSessionSnapshot = { + isEditMode: false, + selectedLayerName: "", + activeTool: null, + changes: [], + lastSavedPayload: null, + canUndo: false, + focusedChangeId: null, +}; + +const postMessageToOpener = (type: string, payload?: Record) => { + if (typeof window === "undefined" || !window.opener) return; + window.opener.postMessage({ type, payload }, window.location.origin); +}; + +export default function GisEditSessionPopupContent(_props: WindowPopupContentProps) { + const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT); + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + const data = event.data; + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SYNC) { + setSnapshot((data.payload ?? EMPTY_SNAPSHOT) as GisEditSessionSnapshot); + } + }; + + window.addEventListener("message", handleMessage); + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_READY); + return () => { + window.removeEventListener("message", handleMessage); + }; + }, []); + + return ( +
+
+

+ {windowPopupDefinitions.gisEditSessionPopup.title} +

+
+ +
+ { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE); + }} + onClearChanges={() => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_CLEAR_CHANGES); + }} + onFocusChange={(changeId) => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_FOCUS_CHANGE, { + changeId, + }); + }} + /> +
+
+ ); +} diff --git a/apps/sn/src/types/lucide-react.d.ts b/apps/sn/src/types/lucide-react.d.ts new file mode 100644 index 000000000..708828bd1 --- /dev/null +++ b/apps/sn/src/types/lucide-react.d.ts @@ -0,0 +1,44 @@ +declare module "lucide-react" { + import type { ComponentType, SVGProps } from "react"; + + export type LucideProps = SVGProps & { + size?: string | number; + color?: string; + strokeWidth?: string | number; + absoluteStrokeWidth?: boolean; + }; + + type LucideIcon = ComponentType; + + export const ChartColumnBig: LucideIcon; + export const ArrowLeft: LucideIcon; + export const ArrowRight: LucideIcon; + export const CalendarIcon: LucideIcon; + export const Check: LucideIcon; + export const CheckIcon: LucideIcon; + export const ChevronDown: LucideIcon; + export const ChevronDownIcon: LucideIcon; + export const ChevronLeft: LucideIcon; + export const ChevronLeftIcon: LucideIcon; + export const ChevronRight: LucideIcon; + export const ChevronRightIcon: LucideIcon; + export const ChevronUp: LucideIcon; + export const Circle: LucideIcon; + export const CloudUpload: LucideIcon; + export const History: LucideIcon; + export const Move: LucideIcon; + export const MoreHorizontal: LucideIcon; + export const PanelsTopLeft: LucideIcon; + export const PanelLeft: LucideIcon; + export const Paperclip: LucideIcon; + export const PenTool: LucideIcon; + export const PencilRuler: LucideIcon; + export const Plus: LucideIcon; + export const Search: LucideIcon; + export const Settings2: LucideIcon; + export const Trash2: LucideIcon; + export const TriangleAlert: LucideIcon; + export const Waves: LucideIcon; + export const X: LucideIcon; + export const XIcon: LucideIcon; +} diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 4b903d087..2fae15fc9 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -111,7 +111,7 @@ function Gis({ (data) => { onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출 }, - [mapInstanceRef], + [onFeatureSelect], ); // 지도 생성 및 초기화 diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index 38729b2b2..c17563724 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -1,25 +1,33 @@ -import React, { useContext, useEffect } from "react"; +import React, { useContext, useEffect, useRef } from "react"; import { GisContext } from "./Gis"; import GisApp from "./lib/gis.core"; import GisOlSelect from "./lib/gis.select"; export default React.memo(function GisSelect({ onFeatureSelect }) { const { gisMap } = useContext(GisContext); + const onFeatureSelectRef = useRef(onFeatureSelect); + + useEffect(() => { + onFeatureSelectRef.current = onFeatureSelect; + }, [onFeatureSelect]); useEffect(() => { if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함 - // 이미 select가 있으면 재생성하지 않음 const select = new GisOlSelect(gisMap); GisApp.Module.select = select; // 피처 선택 시 콜백 등록 select.getSelectedInfo(function (features) { if (features?.[0]) { - onFeatureSelect(features[0]); + onFeatureSelectRef.current?.(features[0]); } }); - }, [gisMap, onFeatureSelect]); + + return () => { + select.removeInteration?.(); + }; + }, [gisMap]); // const select = new GisOlSelect(gisMap); // GisApp.Module.select = select;