Files
frontend/apps/sn/app/(map-layout)/map/components/gis-edit/use-gis-edit.ts
T

791 lines
30 KiB
TypeScript

/** 하수관로 GIS 편집 도구의 선택 상태와 변경 내역을 관리하는 훅이다. */
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import {
type GisEditInteractionRequest,
isActionableChange,
isGeometryChange,
buildPreviewFocusMessage,
FLOW_DIRECTION_SRS,
SCREEN_ID,
buildPipeSaveAttributes,
EMPTY_GIS_EDIT_PIPE_BASIC_INFO,
normalizeGeometryType,
collectEditableLayers,
toGeometryObject,
buildBbox,
getFeatureLayerId,
getFeatureObjectId,
getFeatureObjectLabel,
getFeatureRef,
getFeatureProperties,
normalizePipeGeometry,
createChangeId,
} from "@/features/gis-edit";
export { EMPTY_GIS_EDIT_PIPE_BASIC_INFO } from "@/features/gis-edit";
import type {
GisEditChange,
GisEditPipeBasicInfo,
GisEditSavePayload,
GisEditTool,
} from "./gis-edit-types";
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("tb_ms_swage_ppl");
// 1차 구현에서는 activeTool과 지도 선택 이벤트를 조합해 변경분을 로컬 세션에 누적한다.
const [activeTool, setActiveTool] = useState<GisEditTool | null>(null);
// QGIS 유사 스냅핑 on/off는 MOVE, VERTEX_EDIT 상호작용에서 함께 참조한다.
const [isSnapEnabled, setIsSnapEnabled] = useState(false);
// 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지한다.
const [changes, setChanges] = useState<GisEditChange[]>([]);
const [statusMessage, setStatusMessage] = useState(
"GIS편집 버튼을 켜면 하수관로 편집 도구를 사용할 수 있습니다.",
);
// 이전 구현 호환용 상태다. 저장 버튼은 payload 미리보기를 노출하지 않고 즉시 API 호출 흐름으로 넘어간다.
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 actionableChanges = useMemo(
() => changes.filter(isActionableChange),
[changes],
);
const canSave = actionableChanges.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);
setPendingInteraction(null);
setStatusMessage(buildPreviewFocusMessage(targetChange));
}, [changes]);
const findExistingObjectChange = useCallback((
editType: GisEditTool,
layerId: string,
objectId: string,
) =>
changes.find(
(change) =>
!change.attributeOnly &&
change.editType === editType &&
change.layerId === layerId &&
String(change.objectId ?? "") === objectId,
) ?? null,
[changes]);
// 같은 원본 관로에 대한 삭제/이동/꼭지점편집 변경분은 하나만 유지해 도구 전환 시 세션 상태가 섞이지 않게 한다.
const findExistingGeometryChange = useCallback((
layerId: string,
objectId: string,
) =>
changes.find(
(change) =>
isGeometryChange(change) &&
change.layerId === layerId &&
String(change.objectId ?? "") === objectId,
) ?? null,
[changes]);
// 편집 모드를 끄더라도 작업 중 세션을 잃지 않도록 변경분은 유지하고 도구 상태만 정리한다.
const toggleEditMode = useCallback(() => {
setIsEditMode((previous) => {
const next = !previous;
setActiveTool(null);
setPendingInteraction(null);
setFocusedChangeId(null);
setIsSnapEnabled(false);
latestSelectionRef.current = null;
setStatusMessage(
next
? "하수관로 편집 도구를 선택한 뒤 지도에서 편집 작업을 진행하세요."
: "GIS 편집 모드를 종료했습니다. 편집 세션은 유지됩니다.",
);
return next;
});
}, []);
const toggleSnapEnabled = useCallback(() => {
setIsSnapEnabled((previous) => {
const next = !previous;
setStatusMessage(
next
? "스냅핑을 켰습니다. 이동과 꼭지점편집 중 기존 하수관로 꼭지점/선분에 맞춰집니다."
: "스냅핑을 껐습니다. 이동과 꼭지점편집이 자유 좌표 기준으로 동작합니다.",
);
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} 신규 위치를 지도에서 클릭하세요.${isSnapEnabled ? " 스냅핑이 켜져 있으면 기존 관로 꼭지점/선분에 맞춰집니다." : ""}`
: selectedLayer.geometryType === "LineString"
? `${selectedLayer.layerName} 선형을 지도에서 연속 클릭으로 입력한 뒤 더블클릭으로 마무리하세요.${isSnapEnabled ? " 스냅핑이 켜져 있으면 기존 관로 꼭지점/선분에 맞춰집니다." : ""}`
: `${selectedLayer.layerName} 영역을 지도에서 연속 클릭으로 입력한 뒤 더블클릭으로 마무리하세요.${isSnapEnabled ? " 스냅핑이 켜져 있으면 기존 관로 꼭지점/선분에 맞춰집니다." : ""}`;
setStatusMessage(addGuideMessage);
return;
}
const toolLabel = tool === "MOVE"
? "이동"
: tool === "VERTEX_EDIT"
? "꼭지점편집"
: "삭제";
const vertexGuideMessage =
tool === "VERTEX_EDIT"
? `지도에서 ${selectedLayer.layerName} 대상 객체를 클릭하면 실꼭지점은 주황색, 새 점 삽입용 midpoint는 흰색으로 표시됩니다.${isSnapEnabled ? " 스냅핑이 켜져 있으면 기존 관로 선분과 꼭지점에 맞춰집니다." : ""}`
: null;
setStatusMessage(
vertexGuideMessage ??
`지도에서 ${selectedLayer.layerName} 대상 객체를 클릭하면 ${toolLabel} 변경분을 편집 세션에 추가합니다.${tool === "MOVE" && isSnapEnabled ? " 스냅핑이 켜져 있으면 이동 중 기존 관로 꼭지점/선분에 맞춰집니다." : ""}`,
);
}, [appendChange, isSnapEnabled, selectedLayer]);
const deactivateTool = useCallback(() => {
setActiveTool(null);
setPendingInteraction(null);
latestSelectionRef.current = null;
setStatusMessage("편집 도구 선택을 해제했습니다. 추가, 이동, 꼭지점편집, 삭제 모드가 모두 꺼졌습니다.");
}, []);
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);
const nextGeometry = normalizePipeGeometry(selectedLayer.layerId, geometry);
appendChange({
changeId,
editType: "ADD",
layerId: selectedLayer.layerId,
layerName: selectedLayer.layerName,
objectId: clientTempId,
featureRef: undefined,
objectLabel: clientTempId,
beforeGeometry: null,
afterGeometry: nextGeometry,
basicInfo:
selectedLayer.layerId === "tb_ms_swage_ppl"
? {
...EMPTY_GIS_EDIT_PIPE_BASIC_INFO,
}
: null,
basicInfoSavedAt: null,
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 };
if (previewChange.editType === "ADD" && activeTool === "DELETE") {
setLastSavedPayload(null);
setChanges((previous) => previous.filter((change) => change.changeId !== previewChangeId));
setFocusedChangeId((current) => (current === previewChangeId ? null : current));
setPendingInteraction((current) => (current?.changeId === previewChangeId ? null : current));
latestSelectionRef.current = null;
setStatusMessage(`${previewChange.layerName} 신규 객체를 편집 세션에서 삭제했습니다.`);
return {
handled: true,
isPreviewSelection: true,
editType: "DELETE",
};
}
if (
previewChange.editType !== "ADD" &&
previewChange.editType !== "DELETE" &&
activeTool === "DELETE"
) {
setLastSavedPayload(null);
setChanges((previous) =>
previous.map((change) =>
change.changeId === previewChangeId
? {
...change,
editType: "DELETE",
afterGeometry: null,
summary: `${change.layerName} ${change.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`,
}
: change,
),
);
setFocusedChangeId(previewChangeId);
setPendingInteraction(null);
latestSelectionRef.current = null;
setStatusMessage(`${previewChange.layerName} ${previewChange.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`);
return {
handled: true,
isPreviewSelection: true,
changeId: previewChangeId,
editType: "DELETE",
fid: previewChange.fid ?? previewChange.featureRef?.fid ?? previewChange.objectId,
};
}
if (
previewChange.editType === "DELETE" &&
(activeTool === "MOVE" || activeTool === "VERTEX_EDIT")
) {
setFocusedChangeId(previewChangeId);
setPendingInteraction(null);
setStatusMessage("이미 삭제 예정인 관로입니다. 이동 또는 꼭지점편집이 필요하면 마지막 변경 취소 후 다시 선택하세요.");
return {
handled: true,
isPreviewSelection: true,
changeId: previewChangeId,
editType: activeTool,
fid: previewChange.fid ?? previewChange.featureRef?.fid ?? previewChange.objectId,
};
}
if (
previewChange.editType !== "DELETE" &&
(activeTool === "MOVE" || activeTool === "VERTEX_EDIT")
) {
setFocusedChangeId(previewChangeId);
setPendingInteraction({ changeId: previewChangeId, editType: activeTool });
const targetLabel = previewChange.editType === "ADD" ? "신규 객체" : previewChange.objectLabel;
setStatusMessage(
activeTool === "MOVE"
? `${previewChange.layerName} ${targetLabel}를 드래그해 위치를 조정하세요.`
: `${previewChange.layerName} ${targetLabel}의 꼭지점을 드래그해 형상을 조정하세요.`,
);
return {
handled: true,
isPreviewSelection: true,
changeId: previewChangeId,
editType: activeTool,
};
}
focusChange(previewChangeId);
return {
handled: true,
isPreviewSelection: true,
changeId: previewChangeId,
editType: previewChange.editType,
};
}
if (!activeTool || !selectedLayer) return { handled: false };
if (activeTool === "ADD") return { handled: true, isPreviewSelection: false };
const featureLayerId = getFeatureLayerId(featureData);
if (featureLayerId !== selectedLayer.layerId) {
setStatusMessage(
selectedLayer.layerId === "tb_ms_swage_ppl"
? "하수관로를 선택해주세요."
: `현재 선택 레이어는 ${selectedLayer.layerName}입니다. 같은 레이어의 객체를 선택하세요.`,
);
return { handled: true };
}
const objectId = getFeatureObjectId(featureData);
if (!objectId) {
setStatusMessage("선택 객체의 관리번호를 찾을 수 없어 편집 세션에 추가하지 못했습니다.");
return { handled: true };
}
const existingGeometryChange = findExistingGeometryChange(selectedLayer.layerId, objectId);
if (existingGeometryChange?.editType === "DELETE" && (activeTool === "MOVE" || activeTool === "VERTEX_EDIT")) {
focusChange(existingGeometryChange.changeId);
setPendingInteraction(null);
setStatusMessage("이미 삭제 예정인 관로입니다. 이동 또는 꼭지점편집이 필요하면 마지막 변경 취소 후 다시 선택하세요.");
return {
handled: true,
isPreviewSelection: false,
changeId: existingGeometryChange.changeId,
editType: "DELETE",
fid: existingGeometryChange.fid ?? existingGeometryChange.featureRef?.fid ?? objectId,
};
}
if (existingGeometryChange && existingGeometryChange.editType !== "DELETE" && activeTool === "DELETE") {
setLastSavedPayload(null);
setChanges((previous) =>
previous.map((change) =>
change.changeId === existingGeometryChange.changeId
? {
...change,
editType: "DELETE",
afterGeometry: null,
summary: `${change.layerName} ${change.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`,
}
: change,
),
);
setFocusedChangeId(existingGeometryChange.changeId);
setPendingInteraction(null);
latestSelectionRef.current = null;
setStatusMessage(`${existingGeometryChange.layerName} ${existingGeometryChange.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`);
return {
handled: true,
isPreviewSelection: false,
changeId: existingGeometryChange.changeId,
editType: "DELETE",
fid: existingGeometryChange.fid ?? existingGeometryChange.featureRef?.fid ?? objectId,
};
}
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);
if (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") {
setPendingInteraction({ changeId: existingChange.changeId, editType: activeTool });
}
return {
handled: true,
isPreviewSelection: false,
changeId: existingChange.changeId,
editType: existingChange.editType,
fid: existingChange.fid ?? existingChange.featureRef?.fid ?? objectId,
};
}
const geometryObject = toGeometryObject(featureData);
const geometryType = normalizeGeometryType(geometryObject?.type);
const featureRef = getFeatureRef(featureData);
const objectLabel = getFeatureObjectLabel(featureData) || objectId;
const normalizedGeometryObject = normalizePipeGeometry(selectedLayer.layerId, geometryObject);
const nextGeometry =
activeTool === "MOVE"
? normalizedGeometryObject
: activeTool === "VERTEX_EDIT"
? normalizedGeometryObject
: activeTool === "DELETE"
? null
: normalizedGeometryObject;
const summaryMap: Record<GisEditTool, string> = {
MOVE: `${selectedLayer.layerName} ${objectLabel} 객체를 선택했습니다. 지도에서 드래그해 위치를 조정하세요.`,
VERTEX_EDIT: `${selectedLayer.layerName} ${objectLabel} 객체를 선택했습니다. 주황색 실꼭지점을 드래그하거나 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`,
DELETE: `${selectedLayer.layerName} ${objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`,
ADD: `${selectedLayer.layerName} ${objectLabel} 객체를 추가했습니다.`,
};
changeSequenceRef.current += 1;
const changeId = createChangeId(`${activeTool}-${objectId}`, changeSequenceRef.current);
appendChange({
changeId,
editType: activeTool,
layerId: selectedLayer.layerId,
layerName: selectedLayer.layerName,
objectId,
fid: featureRef?.fid ?? objectId,
featureRef,
objectLabel,
beforeGeometry: normalizedGeometryObject,
afterGeometry: nextGeometry,
featureProperties: getFeatureProperties(featureData),
basicInfo: null,
basicInfoSavedAt: null,
summary:
activeTool === "VERTEX_EDIT" && geometryType === "Point"
? `${selectedLayer.layerName} ${objectLabel}는 점형 객체여서 꼭지점편집 대신 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, isPreviewSelection: false, changeId, editType: activeTool, fid: featureRef?.fid ?? objectId };
}, [activeTool, appendChange, findExistingGeometryChange, findExistingObjectChange, focusChange, isEditMode, selectedLayer]);
const registerPipeDetailSelection = useCallback((featureData: any) => {
if (!isEditMode || !selectedLayer) return { handled: false };
if (selectedLayer.layerId !== "tb_ms_swage_ppl") return { handled: false };
const featureLayerId = getFeatureLayerId(featureData);
if (featureLayerId !== selectedLayer.layerId) {
setStatusMessage("하수관로를 선택해주세요.");
return { handled: true };
}
const objectId = getFeatureObjectId(featureData);
if (!objectId) {
setStatusMessage("선택 객체의 관리번호를 찾을 수 없어 상세정보 수정 화면을 열 수 없습니다.");
return { handled: true };
}
const existingChange =
changes.find(
(change) =>
change.layerId === selectedLayer.layerId &&
change.editType !== "DELETE" &&
String(change.objectId ?? "") === objectId,
) ?? null;
if (existingChange) {
focusChange(existingChange.changeId);
setPendingInteraction(null);
setStatusMessage(`${selectedLayer.layerName} ${objectId} 상세정보를 수정할 수 있습니다.`);
return {
handled: true,
isPreviewSelection: false,
changeId: existingChange.changeId,
editType: existingChange.editType as GisEditTool,
fid: existingChange.fid ?? existingChange.featureRef?.fid ?? objectId,
};
}
const geometryObject = toGeometryObject(featureData);
const normalizedGeometryObject = normalizePipeGeometry(selectedLayer.layerId, geometryObject);
const featureRef = getFeatureRef(featureData);
const objectLabel = getFeatureObjectLabel(featureData) || objectId;
changeSequenceRef.current += 1;
const changeId = createChangeId(`DETAIL-${objectId}`, changeSequenceRef.current);
appendChange({
changeId,
editType: "VERTEX_EDIT" as const,
layerId: selectedLayer.layerId,
layerName: selectedLayer.layerName,
objectId,
fid: featureRef?.fid ?? objectId,
featureRef,
objectLabel,
beforeGeometry: normalizedGeometryObject,
afterGeometry: normalizedGeometryObject,
featureProperties: getFeatureProperties(featureData),
basicInfo: null,
basicInfoSavedAt: null,
attributeOnly: true,
summary: `${selectedLayer.layerName} ${objectLabel} 상세정보 수정 대상으로 선택했습니다.`,
createdAt: new Date().toISOString(),
source: "MAP_SELECTION",
});
setFocusedChangeId(changeId);
setPendingInteraction(null);
setStatusMessage(`${selectedLayer.layerName} ${objectLabel} 상세정보를 수정할 수 있습니다.`);
return {
handled: true,
isPreviewSelection: false,
changeId,
editType: "VERTEX_EDIT",
fid: featureRef?.fid ?? objectId,
};
}, [appendChange, changes, focusChange, isEditMode, selectedLayer]);
const save = useCallback((map: any) => {
if (!selectedLayer || actionableChanges.length === 0) {
setStatusMessage("저장할 편집 변경분이 없습니다.");
return null;
}
const payload: GisEditSavePayload = {
screenId: SCREEN_ID,
layerId: selectedLayer.layerId,
layerName: selectedLayer.layerName,
saveTargetType: "FINAL",
view: {
srs: FLOW_DIRECTION_SRS,
bbox: buildBbox(map),
},
changes: actionableChanges.map((change) => ({
changeId: change.changeId,
editType: change.editType,
objectId: change.editType === "ADD" ? undefined : change.objectId,
clientTempId: change.editType === "ADD" ? change.objectId : undefined,
fid: change.editType === "ADD" ? undefined : change.fid ?? change.featureRef?.fid ?? change.objectId,
baseEditSn: change.editType === "ADD" ? undefined : change.baseEditSn,
featureRef: change.featureRef,
attributes: buildPipeSaveAttributes(change.basicInfo),
beforeGeometry: change.beforeGeometry,
afterGeometry: change.editType === "DELETE" ? null : change.afterGeometry,
geometry: change.editType === "ADD" ? change.afterGeometry : undefined,
})),
};
return payload;
}, [actionableChanges, 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 removeFocusedChange = useCallback(() => {
const targetChangeId = focusedChangeId ?? pendingInteraction?.changeId ?? null;
if (!targetChangeId) {
setStatusMessage("취소할 편집 변경분을 선택해주세요.");
return;
}
const removedChange = changes.find((change) => change.changeId === targetChangeId) ?? null;
if (!removedChange) {
setStatusMessage("선택한 편집 변경분을 찾을 수 없습니다.");
return;
}
setLastSavedPayload(null);
setChanges((previous) => previous.filter((change) => change.changeId !== targetChangeId));
if (focusedChangeId === targetChangeId) {
const nextFocusedChange = changes.find((change) => change.changeId !== targetChangeId) ?? null;
setFocusedChangeId(nextFocusedChange?.changeId ?? null);
}
if (pendingInteraction?.changeId === targetChangeId) {
setPendingInteraction(null);
}
latestSelectionRef.current = null;
setStatusMessage(
removedChange.attributeOnly
? `${removedChange.layerName} ${removedChange.objectLabel} 상세정보 변경을 취소했습니다.`
: `${removedChange.layerName} ${removedChange.objectLabel} 편집 변경을 취소했습니다.`,
);
}, [changes, focusedChangeId, pendingInteraction]);
const clearChanges = useCallback(() => {
setLastSavedPayload(null);
setChanges([]);
setActiveTool(null);
setPendingInteraction(null);
setFocusedChangeId(null);
setIsSnapEnabled(false);
latestSelectionRef.current = null;
setStatusMessage("편집 세션을 초기화했습니다.");
}, []);
const discardSession = useCallback(() => {
setIsEditMode(false);
setLastSavedPayload(null);
setChanges([]);
setActiveTool(null);
setPendingInteraction(null);
setFocusedChangeId(null);
setIsSnapEnabled(false);
latestSelectionRef.current = null;
setStatusMessage("하수관로 편집을 종료하고 세션 변경분을 초기화했습니다.");
}, []);
const updateChangeGeometry = useCallback((
changeId: string,
afterGeometry: Record<string, unknown> | null,
summary: string,
options?: { keepVertexEditActive?: boolean; keepMoveToolActive?: boolean },
) => {
setLastSavedPayload(null);
setChanges((previous) =>
previous.map((change) =>
change.changeId === changeId
? {
...change,
afterGeometry,
summary,
}
: change,
),
);
setFocusedChangeId(changeId);
if (options?.keepVertexEditActive) {
setActiveTool("VERTEX_EDIT");
setPendingInteraction({ changeId, editType: "VERTEX_EDIT" });
} else if (options?.keepMoveToolActive) {
// MOVE는 버튼 재클릭 전까지 도구를 유지하고, 다음 지도 클릭에서 새 대상을 선택하게 한다.
setActiveTool("MOVE");
setPendingInteraction(null);
} else {
setActiveTool(null);
setPendingInteraction(null);
}
latestSelectionRef.current = null;
setStatusMessage(summary);
}, []);
const updateChangeBasicInfo = useCallback((
changeId: string,
basicInfo: GisEditPipeBasicInfo,
) => {
setLastSavedPayload(null);
setChanges((previous) =>
previous.map((change) =>
change.changeId === changeId
? {
...change,
featureRef: {
...change.featureRef,
fcltMngNo: basicInfo.fcltMngNo || change.featureRef?.fcltMngNo,
mngInstCd: basicInfo.mngInstCd || change.featureRef?.mngInstCd,
},
basicInfo,
basicInfoSavedAt: new Date().toISOString(),
}
: change,
),
);
setFocusedChangeId(changeId);
setStatusMessage("관로 기본정보를 편집 세션에 저장했습니다. 최종 GIS 저장 전까지는 DB에 반영되지 않습니다.");
}, []);
const updateChangeBaseEditSn = useCallback((changeId: string, baseEditSn: string) => {
setLastSavedPayload(null);
setChanges((previous) =>
previous.map((change) =>
change.changeId === changeId
? {
...change,
baseEditSn,
}
: change,
),
);
}, []);
const clearPendingInteraction = useCallback(() => {
setPendingInteraction(null);
}, []);
const announceStatus = useCallback((message: string) => {
setStatusMessage(message);
}, []);
return {
isEditMode,
editableLayers,
selectedLayer,
selectedLayerId,
activeTool,
isSnapEnabled,
changes,
statusMessage,
canUseTools,
canSave,
lastSavedPayload,
pendingInteraction,
focusedChangeId,
pendingApiMessage:
"현재 구현은 FE 로컬 편집 세션, 관로 기본정보 draft, BE 저장 API(`/api/v1/map/gisEditSessions/save`) 최종본 저장까지 포함합니다.",
canUndo: actionableChanges.length > 0,
toggleEditMode,
toggleSnapEnabled,
selectLayer,
activateTool,
deactivateTool,
registerAddGeometry,
registerFeatureSelection,
registerPipeDetailSelection,
save,
removeFocusedChange,
removeLastChange,
clearChanges,
discardSession,
updateChangeGeometry,
updateChangeBasicInfo,
updateChangeBaseEditSn,
clearPendingInteraction,
focusChange,
announceStatus,
};
};