하수관로 편집 기능 구현
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+44
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user