791 lines
30 KiB
TypeScript
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,
|
|
};
|
|
};
|