하수관로 편집 기능 구현

This commit is contained in:
kiminchan
2026-05-15 15:03:00 +09:00
parent be72a594d7
commit 6d0461eaef
23 changed files with 3621 additions and 11 deletions
@@ -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<WindowPopupKey | "infoSearch" | null>(null);
// GIS 편집 모드가 꺼지면 메뉴 강조 상태도 함께 정리해 사용자가 현재 모드를 오해하지 않게 한다.
useEffect(() => {
if (!isGisEditActive && activeKey === "gisEditPopup") {
setActiveKey(null);
}
}, [activeKey, isGisEditActive]);
return (
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
<div className="relative">
@@ -53,7 +67,9 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
<div className="flex flex-col gap-1 p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeKey === item.key;
const isActive = item.key === "gisEditPopup"
? isGisEditActive
: activeKey === item.key;
return (
<button
@@ -65,6 +81,12 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
: "border-transparent bg-white text-[#374151] hover:border-[#cfe5ff] hover:bg-[#eef6ff] hover:text-[#1e3a5f]"
}`}
onClick={() => {
if (item.key === "gisEditPopup" && onToggleGisEdit) {
setActiveKey(item.key);
onToggleGisEdit();
return;
}
setActiveKey(item.key);
if (item.key === "infoSearch") {
@@ -0,0 +1,142 @@
"use client";
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
type Props = GisEditSessionSnapshot & {
onRemoveLastChange: () => void;
onClearChanges: () => void;
onFocusChange: (changeId: string) => void;
className?: string;
};
const TOOL_LABELS: Record<GisEditTool, string> = {
ADD: "추가",
MOVE: "이동",
VERTEX_EDIT: "꼭지점편집",
DELETE: "삭제",
};
const buildChangeItemClassName = (isFocused: boolean) =>
`block w-full rounded border px-3 py-3 text-left text-[12px] transition ${
isFocused
? "border-[#5a9bd5] bg-[#eef7ff] shadow-[inset_0_0_0_1px_rgba(90,155,213,0.2)]"
: "border-[#d8dee8] bg-[#fbfcfe] hover:border-[#bfd2e5] hover:bg-white"
}`;
type ChangeListProps = {
changes: GisEditSessionSnapshot["changes"];
focusedChangeId: string | null;
onFocusChange: (changeId: string) => void;
};
const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps) => (
<>
{changes.map((change, index) => (
<button
key={change.changeId}
type="button"
className={buildChangeItemClassName(focusedChangeId === change.changeId)}
onClick={() => onFocusChange(change.changeId)}
>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-[#173f67]">
{index + 1}. {TOOL_LABELS[change.editType]} / {change.objectLabel}
</div>
{focusedChangeId === change.changeId ? (
<span className="rounded-full border border-[#b8d3ef] bg-white px-2 py-0.5 text-[10px] font-semibold text-[#176db8]">
지도 미리보기 선택
</span>
) : null}
</div>
<div className="mt-1 text-[#526173]">{change.summary}</div>
</button>
))}
</>
);
export default function GisEditSessionPanel({
isEditMode,
selectedLayerName,
activeTool,
changes,
lastSavedPayload,
canUndo,
focusedChangeId,
onRemoveLastChange,
onClearChanges,
onFocusChange,
className = "",
}: Props) {
return (
<div
className={`rounded border border-[#d8dee8] bg-[rgba(255,255,255,0.98)] p-4 text-sm text-[#304255] shadow-[0_10px_26px_rgba(23,42,70,0.14)] ${className}`.trim()}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-[13px] font-bold text-[#173f67]">편집 세션</div>
<div className="mt-1 text-[12px] text-[#667485]">
레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"}
</div>
</div>
{isEditMode ? (
<div className="rounded-full border border-[#b8d3ef] bg-[#f2f8ff] px-2 py-1 text-[11px] font-semibold text-[#174e82]">
FE 로컬 세션
</div>
) : (
<div className="rounded-full border border-[#f2d18d] bg-[#fff8eb] px-2 py-1 text-[11px] font-semibold text-[#9a5b00]">
지도 미리보기 유지
</div>
)}
</div>
<div className="mb-3 flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
disabled={!canUndo}
onClick={onRemoveLastChange}
>
마지막 변경 취소
</button>
<button
type="button"
className="rounded border border-[#f3d0d0] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#b42318] transition hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#d0a5a5]"
disabled={!canUndo}
onClick={onClearChanges}
>
초기화
</button>
<div className="ml-auto rounded-full border border-[#d8dee8] bg-white px-2.5 py-1 text-[11px] font-semibold text-[#526173]">
총 {changes.length}건
</div>
</div>
<div className="space-y-2">
{changes.length === 0 ? (
<div className="rounded border border-dashed border-[#d8dee8] bg-[#fbfcfe] px-3 py-4 text-[12px] text-[#667485]">
저장할 편집 변경분이 없습니다.
</div>
) : (
<div className="max-h-[calc(100vh-220px)] min-h-[640px] overflow-y-auto pr-1">
<div className="space-y-2">
<ChangeList
changes={changes}
focusedChangeId={focusedChangeId}
onFocusChange={onFocusChange}
/>
</div>
</div>
)}
</div>
{lastSavedPayload ? (
<div className="mt-4 rounded border border-[#d8dee8] bg-[#0f172a] px-3 py-3 text-[11px] text-[#dbeafe]">
<div className="mb-2 font-semibold text-white">저장 payload 미리보기</div>
<pre className="max-h-[220px] overflow-auto whitespace-pre-wrap break-all">
{JSON.stringify(lastSavedPayload, null, 2)}
</pre>
</div>
) : null}
</div>
);
}
@@ -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 (
<div className="absolute left-1/2 top-4 z-[60] flex w-[min(780px,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2">
<div className="rounded border border-[#9bbddf] bg-[rgba(255,255,255,0.96)] px-3 py-3 shadow-[0_10px_26px_rgba(23,42,70,0.14)]">
<div className="flex flex-wrap items-center gap-2">
<select
className="h-9 min-w-[176px] rounded border border-[#9bbddf] bg-[#f8fbff] px-3 text-sm text-[#243548]"
value={selectedLayerId}
onChange={(event) => onSelectLayer(event.target.value)}
>
<option value="">레이어 선택</option>
{editableLayers.map((layer) => (
<option key={layer.layerId} value={layer.layerId} disabled={layer.editableYn !== "Y"}>
{layer.layerName}
</option>
))}
</select>
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
const isActive = activeTool === tool;
const isToolEnabled =
canUseTools && Boolean(selectedLayer?.allowedTools.includes(tool));
return (
<button
key={tool}
type="button"
className={`min-w-[92px] rounded border px-3 py-2 text-sm font-semibold transition ${
isActive
? "border-[#176db8] bg-[#176db8] text-white"
: "border-[#9bbddf] bg-[#eef7ff] text-[#0c5fa6]"
} disabled:cursor-not-allowed disabled:border-[#d6dde6] disabled:bg-[#f0f2f4] disabled:text-[#9aa8b7]`}
disabled={!isToolEnabled}
onClick={() => onSelectTool(tool)}
>
<span className="flex items-center justify-center gap-2">
<Icon className="h-4 w-4" />
<span>{label}</span>
</span>
</button>
);
})}
<button
type="button"
className="ml-auto rounded border border-[#a9d9bd] bg-[#e9f8ef] px-4 py-2 text-sm font-semibold text-[#16834f] transition disabled:cursor-not-allowed disabled:border-[#d6dde6] disabled:bg-[#f0f2f4] disabled:text-[#9aa8b7]"
disabled={!canSave}
onClick={onSave}
>
저장
</button>
</div>
</div>
<div className="rounded border border-[#d8dee8] bg-[rgba(255,255,255,0.94)] px-3 py-2 text-sm text-[#304255] shadow-[0_10px_26px_rgba(23,42,70,0.12)]">
{statusMessage}
</div>
{activeTool === "MOVE" ? (
<div className="self-center rounded-full border border-[#b8d3ef] bg-[rgba(242,248,255,0.96)] px-3 py-1 text-[12px] font-medium text-[#175f9c] shadow-[0_6px_18px_rgba(23,42,70,0.1)]">
이동 대상 선택 후 파란 미리보기 객체를 드래그해 위치를 조정하세요.
</div>
) : null}
{activeTool === "VERTEX_EDIT" ? (
<div className="self-center rounded-full border border-[#f2d18d] bg-[rgba(255,248,235,0.96)] px-3 py-1 text-[12px] font-medium text-[#9a5b00] shadow-[0_6px_18px_rgba(23,42,70,0.1)]">
편집 대상 선택 후 주황색 꼭지점 handle을 드래그해 형상을 조정하세요.
</div>
) : null}
</div>
);
}
@@ -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<string, unknown> | null;
afterGeometry?: Record<string, unknown> | 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<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
geometry?: Record<string, unknown> | null;
}>;
};
export type GisEditSessionSnapshot = {
isEditMode: boolean;
selectedLayerName: string;
activeTool: GisEditTool | null;
changes: GisEditChange[];
lastSavedPayload: GisEditSavePayload | null;
canUndo: boolean;
focusedChangeId: string | null;
};
@@ -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<GisEditChange, "editType" | "layerName" | "objectLabel">,
) => {
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<GisEditLayerInfo, "layerId" | "editableYn">
> = {
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<string, GisEditLayerInfo>();
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<string, unknown>;
} 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<GisEditTool | null>(null);
// 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지하고 BE API 연결 전 payload 검토 근거로 사용한다.
const [changes, setChanges] = useState<GisEditChange[]>([]);
const [statusMessage, setStatusMessage] = useState(
"GIS편집 버튼을 켜면 레이어를 선택하고 편집 도구를 사용할 수 있습니다.",
);
// 실제 저장 대신 마지막으로 조립한 payload를 세션 패널에서 미리보기로 노출한다.
const [lastSavedPayload, setLastSavedPayload] = useState<GisEditSavePayload | null>(null);
const [pendingInteraction, setPendingInteraction] = useState<GisEditInteractionRequest | null>(null);
const [focusedChangeId, setFocusedChangeId] = useState<string | null>(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<string, unknown> | 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<GisEditTool, string> = {
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<string, unknown> | 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,
};
};
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,20 @@
- 확인됨: Zustand에서 서버 기준 데이터를 조회해 보관하는 경우에는 로딩, 오류, 중복 호출 방지, 재조회 조건을 함께 정의한다.
- 확인됨: 같은 의미의 기준 데이터 store를 두 계열 이상 유지하지 않는다. 기존 store가 겹치면 조회 출처, 데이터 shape, 필터링 규칙을 비교하고 하나의 기준으로 정리한다.
- 확인됨: React Query를 사용하는 Provider는 QueryClient 인스턴스가 렌더마다 새로 생성되지 않도록 안정적으로 유지한다.
- 확인됨: 지도형 편집 기능은 조회 상태, 상호작용 상태, 편집 세션 상태, 저장 상태를 한 덩어리로 섞지 않고 분리해 기록한다. 근거: `015-하네스엔지니어링정책.md`
## 지도 상호작용 상태 기록 항목
- 확인 필요: `isEditMode`처럼 편집 모드 on/off 상태가 분리되어 있는가
- 확인 필요: `selectedLayer`, `activeTool`, `selectedFeatureId`처럼 현재 조작 대상을 식별하는 상태가 있는가
- 확인 필요: `pendingInteraction`, `focusedChangeId`처럼 도구 전환과 지도 interaction 정리를 위한 중간 상태가 있는가
- 확인 필요: `changes[]`, `canUndo`, `canSave`처럼 편집 세션 누적 상태가 별도로 드러나는가
- 확인 필요: `savePending`, `saveError`, `statusMessage`처럼 저장 상태와 사용자 피드백이 분리되어 있는가
- 확인 필요: preview feature, vertex handle, 임시 삭제 스타일 같은 지도 시각 표식이 어떤 상태와 연결되는지 문서에서 추적 가능한가
## 지도 상호작용 상태 전이 기록 기준
- 확인됨: 도구 전환 시 이전 interaction 정리 조건을 함께 적는다.
- 확인됨: 선택 없음, 레이어 미선택, 객체 미선택, 저장 가능, 저장 불가 같은 경계 상태를 표나 목록으로 남긴다.
- 확인됨: canvas 조작 결과를 직접 읽기 어려우면 상태 메시지, 변경 목록, preview label 같은 간접 표식을 함께 둔다.
## 화면 흐름 기록 항목
- 확인 필요: 초기 진입 시 호출되는 API
@@ -19,6 +33,7 @@
- 확인 필요: 로딩, 오류, 중복 호출 방지, 재조회 조건
- 확인 필요: 팝업, 모달, 지도, 파일, 엑셀 흐름
- 확인 필요: 에러 메시지 표시 위치
- 확인 필요: 지도 상호작용 관측 포인트와 자동검증 가능한 화면 표식
## 주의 사항
- 확인됨: 요구사항 근거 없는 기능 추가는 금지한다. 근거: `apps/sn/AGENTS.md`
+10 -2
View File
@@ -1,5 +1,10 @@
# 검증 기준
## 공통 원칙
- 확인됨: 하네스 친화 검증의 공통 기준은 `FE_APP_DOCS_ROOT/기준문서/015-하네스엔지니어링정책.md`를 우선 참조한다.
- 확인됨: 기능별 자동검증 선행조건, 관측 포인트, 자동/수동 검증 경계는 각 기능의 작업카드와 통합검증계획에 남긴다.
- 확인됨: 인증 차단, 환경 차단, 데이터 차단, 자동화 한계는 기능 결함과 분리해 기록한다.
## 기본 빌드 검증
- 확인됨: `sn`만 수정한 경우 아래 명령으로 로컬 빌드를 확인한다. 근거: `apps/sn/AGENTS.md`
@@ -28,7 +33,10 @@ pnpm turbo run build --filter="...[origin/main]"
## 브라우저 검증
- 확인됨: 백엔드 API 응답과 실제 브라우저 화면 동작을 함께 확인하는 것을 기본으로 한다. 근거: `apps/sn/AGENTS.md`
- 확인 필요: 각 기능별 URL, 로그인 계정, 권한, 테스트 데이터는 작업카드에서 확정한다. 근거: 실행 환경 확인 필요
- 확인됨: 버튼 상태, 상태 메시지, 변경 목록, 네트워크 payload처럼 자동으로 관측 가능한 항목을 먼저 고정한다. 근거: `015-하네스엔지니어링정책.md`
- 확인됨: 지도 canvas drag, vertex 이동처럼 자동화가 불안정한 항목은 수동 검증으로 전환할 수 있으나, 전환 사유를 남겨야 한다. 근거: `015-하네스엔지니어링정책.md`
## 검증 기록
- 확인됨: 검증 결과는 `보고/YYYY-MM-DD/결과보고.md`에 기록한다. 근거: 이 하네스 운영 문서
- 확인됨: 외부 범위 오류는 `보고/YYYY-MM-DD/이슈.md`에 기록한다. 근거: 이 하네스 운영 문서
- 확인됨: 기능 검증 계획은 기능별 `통합검증계획` 문서에 누적한다. 근거: `014-문서관리정책.md`
- 확인됨: 기능 검증 결과는 기능별 `통합검증결과` 문서에 누적한다. 근거: `014-문서관리정책.md`
- 확인됨: 외부 범위 오류나 공통 차단 이슈는 기능 검증결과에 요약하고, 여러 채팅방에 영향을 주는 경우 공통 결정로그 또는 공통 작업내용 문서에 함께 남긴다. 근거: `015-하네스엔지니어링정책.md`
@@ -15,11 +15,25 @@
- 폼과 조회 조건은 화면 컴포넌트 내부에서 뒤섞지 말고 역할 단위로 분리한다.
- 지도, 표, 팝업이 함께 있는 화면은 사용자 흐름 기준으로 이벤트 책임을 나눈다.
## 3-1. 지도 상호작용 설계 원칙
- 편집 도구별 책임은 `선택`, `추가`, `이동`, `꼭지점편집`, `삭제`, `저장`처럼 사용자 행위 단위로 구분한다.
- 같은 지도 feature를 재사용하더라도 `MOVE`와 `VERTEX_EDIT`처럼 interaction lifecycle이 다른 도구는 상태 전이와 정리 조건을 분리한다.
- 기존 상세 팝업, 레이어 선택, 지도 클릭 이벤트와 편집 도구가 충돌하면 우선순위 규칙을 설계 문서에 남긴다.
- 지도 상호작용 결과는 canvas 안에서만 끝나지 않게 상태 메시지, 변경 목록, preview label, 버튼 활성 상태 중 하나 이상으로 바깥에서 관측 가능해야 한다.
- `map/page.tsx` 같은 대형 화면 파일에 책임이 몰려 있으면, 코드 리팩터링 전에도 문서에서 상태 경계와 컴포넌트 분리 목표를 먼저 고정한다.
## 4. API 연동 원칙
- 프론트는 실제 백엔드 DTO, API 명세, 에러 응답을 기준으로 맞춘다.
- 필드명 해석이 애매하면 프론트에서 임의 변환 규칙을 늘리기보다 계약을 먼저 확인한다.
- 목록/상세/다운로드 API는 요청 파라미터와 응답 구조를 문서에 남긴다.
## 4-1. 하네스 친화 설계 원칙
- 자동검증 선행조건, 테스트 데이터, 인증 요구사항은 설계 또는 작업카드에서 명시한다.
- 브라우저 자동화로 안정적으로 확인할 수 있는 관측 포인트를 설계 단계에서 먼저 정한다.
- 자동화가 어려운 canvas drag, vertex 이동은 수동 검증으로 전환할 수 있지만, 전환 사유와 대체 관측 포인트를 함께 둔다.
- 인증 차단, 환경 차단, 데이터 차단, 자동화 한계는 기능 결함과 분리해 설계와 검증 문서에 반영한다.
## 5. 공통 문서 참조
- 공통 용어는 `COMMON_DOCS_ROOT/기준문서/003-공통용어사전.md`를 우선 참조한다.
- 공통 환경/빌드 이슈는 `COMMON_DOCS_ROOT/기준문서/004-환경빌드문제대응.md`를 함께 본다.
- 하네스 친화 검증 기준은 `FE_APP_DOCS_ROOT/기준문서/015-하네스엔지니어링정책.md`를 함께 참조한다.
@@ -0,0 +1,155 @@
# 014. 문서관리정책
## 1. 목적
- `sn` 앱과 연동 기능 문서를 기능 단위 통합 문서 방식으로 관리한다.
- 라운드별 개별 md가 계속 늘어나는 문제를 막고, 나중에 한 기능의 전체 맥락을 빠르게 읽을 수 있게 한다.
## 2. 기본 원칙
- 기능마다 아래 문서만 유지한다.
- 설계 문서 1개
- 작업내용 문서 1개
- 작업검증 계획 문서 1개
- 작업검증 결과 문서 1개
- 세부 이력은 새 파일을 만들지 않고 기존 문서 내부 헤더로 누적한다.
- 라운드별 `002`, `003`, `004` 식 후속 파일 추가를 기본 금지한다.
- 예외적으로 별도 문서가 꼭 필요하면, 왜 기존 통합 문서 헤더로 해결되지 않는지 먼저 기록한다.
- 하네스 정보도 별도 기능 md를 늘리기보다 기존 4문서에 배치한다.
- 하네스 정보 배치 기준은 `FE_APP_DOCS_ROOT/기준문서/015-하네스엔지니어링정책.md`를 따른다.
## 3. 경로별 생성 규칙
### 3.1 공통 설계 문서
- 경로
- `COMMON_DOCS_ROOT/작업계획/<기능폴더>/`
- 파일
- `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. 예외 처리
- 사용자 요청으로 개별 문서 분리가 명시된 경우
- 외부 전달용 산출물이라 통합 문서 내부 헤더로 대체할 수 없는 경우
- 법적/감사/계약상 독립 파일 보관이 필요한 경우
예외 상황에서도 먼저 통합 문서에 “왜 별도 파일이 필요한지”를 기록한다.
@@ -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`를 기능마다 별도 생성하지 않는다.
- 관측 포인트 없이 `수동 확인 필요`만 적고 끝내지 않는다.
- 인증 실패, 환경 실패, 자동화 한계를 모두 기능 결함으로 뭉뚱그려 기록하지 않는다.
@@ -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`
@@ -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`
@@ -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 요청 문서가 별도 정리됨
- 브라우저에서 정상/예외 시나리오를 확인할 수 있음
- 결과 보고 시 작업 목적, 수정 대상 파일, 영향 범위, 검증 방법, 롤백, 리스크를 정리할 수 있음
@@ -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. 편집도구 아이콘 기준
현재 구현과 문서 표기는 화면 시안 기준 편집도구 아이콘 의미를 따른다.
| 도구 | 아이콘 | 메모 |
| --- | --- | --- |
| 추가 | <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"></path><path d="M5 12h14"></path><path d="M5 19h14"></path></svg> | geometry family별 직접 캡처 대상 |
| 이동 | <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M12 2l-3 3M12 2l3 3M12 22l-3-3M12 22l3-3"></path><path d="M2 12h20M2 12l3-3M2 12l3 3M22 12l-3-3M22 12l-3 3"></path></svg> | preview drag 유지 기준 |
| 꼭지점편집 | <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 17l6-10 8 8"></path><circle cx="5" cy="17" r="2"></circle><circle cx="11" cy="7" r="2"></circle><circle cx="19" cy="15" r="2"></circle></svg> | 양 끝점 포함 vertex 조정 목표 |
| 삭제 | <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"></path><path d="M10 11v6M14 11v6"></path><path d="M6 7l1 14h10l1-14"></path><path d="M9 7V4h6v3"></path></svg> | 임시 삭제 상태 유지 |
| 저장 | <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h12l2 2v16H5z"></path><path d="M8 3v6h8V3"></path><path d="M8 17h8"></path></svg> | 원본 분리 저장 계약 |
## 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`
+1
View File
@@ -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",
@@ -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 관리",
@@ -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 =
@@ -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,
@@ -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<string, unknown>) => {
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<GisEditSessionSnapshot>(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 (
<main className="flex h-screen flex-col overflow-hidden bg-[#f6f8fb]">
<header className="flex min-h-[48px] items-center border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.2rem] font-semibold text-white">
{windowPopupDefinitions.gisEditSessionPopup.title}
</h1>
</header>
<section className="flex-1 overflow-hidden p-3">
<GisEditSessionPanel
{...snapshot}
className="h-full shadow-none"
onRemoveLastChange={() => {
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,
});
}}
/>
</section>
</main>
);
}
+44
View File
@@ -0,0 +1,44 @@
declare module "lucide-react" {
import type { ComponentType, SVGProps } from "react";
export type LucideProps = SVGProps<SVGSVGElement> & {
size?: string | number;
color?: string;
strokeWidth?: string | number;
absoluteStrokeWidth?: boolean;
};
type LucideIcon = ComponentType<LucideProps>;
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;
}
@@ -111,7 +111,7 @@ function Gis({
(data) => {
onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출
},
[mapInstanceRef],
[onFeatureSelect],
);
// 지도 생성 및 초기화
@@ -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;