GIS 편집 임시 관로 스냅 지원 및 세션 취소/뒤로가기 분리

This commit is contained in:
kiminchan
2026-06-29 17:45:31 +09:00
parent 87cda451f3
commit 31caee87db
8 changed files with 163 additions and 13 deletions
@@ -3,6 +3,7 @@
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
type Props = GisEditSessionSnapshot & {
onRemoveFocusedChange: () => void;
onRemoveLastChange: () => void;
onClearChanges: () => void;
onFocusChange: (changeId: string) => void;
@@ -71,6 +72,7 @@ export default function GisEditSessionPanel({
changes,
canUndo,
focusedChangeId,
onRemoveFocusedChange,
onRemoveLastChange,
onClearChanges,
onFocusChange,
@@ -87,13 +89,21 @@ export default function GisEditSessionPanel({
</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={!focusedChangeId}
onClick={onRemoveFocusedChange}
>
취소
</button>
<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"
@@ -125,7 +135,6 @@ export default function GisEditSessionPanel({
</div>
)}
</div>
</div>
);
}
@@ -1061,6 +1061,38 @@ export const useGisEdit = (treeData: any[]) => {
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([]);
@@ -1191,6 +1223,7 @@ export const useGisEdit = (treeData: any[]) => {
registerFeatureSelection,
registerPipeDetailSelection,
save,
removeFocusedChange,
removeLastChange,
clearChanges,
discardSession,
+77 -11
View File
@@ -2855,7 +2855,12 @@ const Map = () => {
}
if (!gisEditDetailChangeId) return;
if (!gisEditDetailChange) return;
if (!gisEditDetailChange) {
setGisEditDetailChangeId(null);
setFeatureData(null);
setIsOpen(false);
return;
}
if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl" || gisEditDetailChange.editType === "DELETE") {
setGisEditDetailChangeId(null);
@@ -2890,6 +2895,11 @@ const Map = () => {
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE) {
gisEdit.removeFocusedChange();
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE) {
gisEdit.removeLastChange();
return;
@@ -5253,7 +5263,36 @@ const Map = () => {
gisEditPipeSnapSourceRef.current?.clear();
}, []);
const loadGisEditPipeSnapSource = useCallback(async (excludedFid?: string | null) => {
const buildGisEditPipeSnapPreviewFeatures = useCallback((excludedChangeId?: string | null) => {
const normalizedExcludedChangeId = String(excludedChangeId ?? "").trim();
const geoJson = new GeoJSON();
return gisEdit.changes
.filter((change) => change.layerId === "tb_ms_swage_ppl")
.filter((change) => !change.attributeOnly && change.editType !== "DELETE" && Boolean(change.afterGeometry))
.filter((change) => change.changeId !== normalizedExcludedChangeId)
.map((change) => {
const feature = geoJson.readFeature(
{
type: "Feature",
geometry: change.afterGeometry,
properties: {
fid: change.fid ?? change.featureRef?.fid ?? change.objectId ?? change.changeId,
layerId: change.layerId,
changeId: change.changeId,
},
},
{
dataProjection: FLOW_DIRECTION_SRS,
featureProjection: GisApp.TO_PROJ,
},
);
feature.setId(`gis-edit-snap:${change.changeId}`);
return feature;
});
}, [gisEdit.changes]);
const loadGisEditPipeSnapSource = useCallback(async (options?: { excludedFid?: string | null; excludedChangeId?: string | null }) => {
const map = gisMaps.current;
const source = ensureGisEditPipeSnapSource();
if (!map || !source) return;
@@ -5262,11 +5301,29 @@ const Map = () => {
const view = map.getView?.();
if (!size || !view?.calculateExtent) return;
const normalizedExcludedFid = normalizeGisEditFid(options?.excludedFid ?? "");
const normalizedExcludedChangeId = String(options?.excludedChangeId ?? "").trim();
const hiddenPipeFidSet = new Set(
gisEdit.changes
.filter((change) => change.layerId === "tb_ms_swage_ppl")
.filter((change) => !change.attributeOnly && change.editType !== "DELETE")
.map((change) => normalizeGisEditFid(change.fid ?? change.featureRef?.fid ?? change.objectId))
.filter((fid) => /^\d+$/.test(fid)),
);
if (/^\d+$/.test(normalizedExcludedFid)) {
hiddenPipeFidSet.add(normalizedExcludedFid);
}
const previewSnapFeatures = buildGisEditPipeSnapPreviewFeatures(normalizedExcludedChangeId);
source.clear();
if (previewSnapFeatures.length > 0) {
source.addFeatures(previewSnapFeatures);
}
const screenExtent = view.calculateExtent(size);
const requestExtent = expandGisEditPipeSnapExtent(screenExtent);
const [minX, minY, maxX, maxY] = requestExtent;
const bboxFilter = `BBOX(geom, ${minX}, ${minY}, ${maxX}, ${maxY}, '${GisApp.TO_PROJ}')`;
const normalizedExcludedFid = normalizeGisEditFid(excludedFid ?? "");
const cqlFilter = bboxFilter;
const requestSeq = gisEditPipeSnapRequestSeqRef.current + 1;
@@ -5297,23 +5354,26 @@ const Map = () => {
const features = new GeoJSON().readFeatures(featureCollection, {
dataProjection: GisApp.TO_PROJ,
featureProjection: GisApp.TO_PROJ,
}).filter((feature: any) => {
if (!/^\d+$/.test(normalizedExcludedFid)) return true;
return getGisEditFeatureFid(feature) !== normalizedExcludedFid;
});
}).filter((feature: any) => !hiddenPipeFidSet.has(getGisEditFeatureFid(feature)));
source.clear();
source.addFeatures(features);
if (previewSnapFeatures.length > 0) {
source.addFeatures(previewSnapFeatures);
}
} catch (error) {
if ((error as Error)?.name === "AbortError") return;
console.error("GIS 편집 하수관로 스냅 source 조회 실패:", error);
console.error("GIS ?? ???? ?? source ?? ??:", error);
source.clear();
if (previewSnapFeatures.length > 0) {
source.addFeatures(previewSnapFeatures);
}
} finally {
if (gisEditPipeSnapAbortControllerRef.current === abortController) {
gisEditPipeSnapAbortControllerRef.current = null;
}
}
}, [ensureGisEditPipeSnapSource]);
}, [buildGisEditPipeSnapPreviewFeatures, ensureGisEditPipeSnapSource, gisEdit.changes]);
const restoreGisEditOriginalPipeMask = useCallback(() => {
gisEditOriginalLayerFilterRef.current.forEach((originalFilter, layer) => {
@@ -6275,7 +6335,10 @@ const Map = () => {
)
: "";
void loadGisEditPipeSnapSource(excludedFid);
void loadGisEditPipeSnapSource({
excludedFid,
excludedChangeId: gisEdit.pendingInteraction?.changeId ?? null,
});
}, [
clearGisEditPipeSnapSource,
gisEdit.changes,
@@ -6298,7 +6361,10 @@ const Map = () => {
"",
)
: "";
void loadGisEditPipeSnapSource(excludedFid);
void loadGisEditPipeSnapSource({
excludedFid,
excludedChangeId: gisEdit.pendingInteraction?.changeId ?? null,
});
});
return () => {
@@ -631,3 +631,18 @@
- 남은 수동 검증
- 브라우저에서 MOVE 후 상세 팝업 진입 시 Console Error 오버레이가 뜨지 않는지 확인이 필요하다.
- Network 탭에서 `fcltppl` 조회가 실패해도 팝업 입력 흐름이 유지되는지 확인이 필요하다.
### 2.19 2026-06-29 임시 관로 스냅 및 세션 버튼 재정렬 점검
- 코드 기준 점검
- `page.tsx`의 pipe snap source는 이제 원본 하수관로 WFS만 담지 않고, 현재 세션의 `ADD`/`MOVE`/`VERTEX_EDIT` preview 관로도 함께 합친다.
- 기존 수정 관로의 원본 fid는 WFS snap source에서 제외해, 이미 옮겨진 preview 관로가 수정 전 원본 geometry에 다시 붙지 않도록 조정했다.
- `GIS 편집 세션` popup은 `취소`, `뒤로가기`, `초기화` 3버튼으로 바뀌었고, `취소`는 현재 focus된 change 제거, `뒤로가기`는 기존 마지막 변경 취소, `초기화`는 전체 세션 비우기 역할로 분리됐다.
- 현재 상세 패널이 열린 change를 `취소`로 제거하면 우측 상세 패널도 닫히도록 방어 로직을 추가했다.
- 정적 검증
- 실행 명령: `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0`
- 결과: 실패. 저장소 기존 오류(`components/common/index.ts`, `floodHistoryTypes.ts`, `page.tsx` 기존 null 가능성, html route, chart implicit any`)만 남았고, 이번 변경 파일 신규 오류는 출력되지 않았다.
- 남은 수동 검증
- 신규 관로 A를 추가한 직후 신규 관로 B를 추가하거나 이동/꼭지점편집할 때 A preview에 실제로 스냅되는지 확인이 필요하다.
- `취소` 버튼이 현재 선택 change만 제거하고, `뒤로가기`는 가장 최근 change를 제거하는지 popup과 지도 focus 기준으로 확인이 필요하다.
- `취소`로 현재 상세 패널 대상 change를 제거했을 때 우측 패널이 자연스럽게 닫히는지 확인이 필요하다.
@@ -16,6 +16,11 @@
- `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면설계서_v1_1.png`
## 1-2. 최근 진행 갱신
- 2026-06-29
- 원본 하수관로 WFS snap source를 우선 사용할 때 사용자 임시 추가 관로 preview가 스냅 대상에서 빠지는 구조를 확인했다.
- `page.tsx`의 pipe snap source 로더를 보강해 현재 세션의 `ADD`/`MOVE`/`VERTEX_EDIT` preview 관로도 함께 병합하고, 기존 수정 관로의 원본 fid는 숨겨 stale geometry로 스냅되지 않게 했다.
- `GIS 편집 세션` popup 버튼은 `취소/뒤로가기/초기화` 3종으로 재정렬하고, `취소`는 현재 선택 change 제거, `뒤로가기`는 기존 마지막 변경 취소 의미로 분리했다.
- 정적 타입체크 결과 이번 변경 파일 신규 오류는 없었고, 저장소 기존 오류만 남았다. 브라우저 수동 검증은 다음 범위로 남긴다.
- 2026-06-26
- `MOVE` 도구가 관로 1회 이동 후 자동 해제되는 현상을 요구사항 분석 범위로 확인했다.
- 현재 원인은 `useGisEdit.updateChangeGeometry()` 기본 경로가 geometry 갱신 뒤 `activeTool`과 `pendingInteraction`을 모두 비우는 구조이고, `page.tsx`의 `MOVE translateend`가 이 함수를 옵션 없이 호출하는 데 있다.
@@ -361,3 +361,21 @@
- 조회 실패 시 기존 WFS 속성/세션 draft 값을 유지하고 팝업 입력 흐름은 계속 진행한다.
- 백엔드 연동 보강
- `PplService.findByFcltMngNo(fcltMngNo, mngInstCd)`에서 MS 관로 상세 보조속성 조회와 관리기관명 조회를 안전 래퍼로 감싸, 보조 조회 실패가 `GET /api/v1/fcltppl/...` 전체 500으로 전파되지 않게 했다.
### 4.21 2026-06-29 임시 관로 스냅 및 세션 버튼 재정렬
- 사용자 요구사항 기준
- 기존 관로에는 잘 붙지만, 사용자가 임시로 추가한 관로에는 스냅핑이 제대로 동작하지 않는 문제가 있었다.
- `GIS 편집 세션` popup 버튼도 `취소`, `뒤로가기`, `초기화` 3종 의미로 다시 나눠야 했다.
- 원인 정리
- pipe snap source는 현재 화면 BBOX 기준 `tb_ms_swage_ppl` 원본 WFS를 다시 읽는 구조라서, FE 세션 preview로만 존재하는 신규 관로/수정 관로가 source 우선 경로에서는 빠질 수 있었다.
- 세션 popup은 `마지막 변경 취소`, `초기화`만 있어 현재 선택한 change 1건만 제거하는 동작을 구분해 제공하지 못했다.
- 이번 라운드 반영
- `page.tsx`에 `buildGisEditPipeSnapPreviewFeatures()`를 추가해 현재 세션의 하수관로 preview geometry를 별도 snap feature로 만들고, pipe snap source에 원본 WFS와 함께 병합했다.
- 기존 관로를 이동/꼭지점편집한 change는 원본 fid를 WFS snap source에서 제외해 수정 전 geometry에 다시 붙는 현상을 줄였다.
- pipe snap source는 WFS 응답 전에도 preview snap feature를 먼저 채워, 신규 관로를 추가한 직후 다른 신규 관로에 연속으로 스냅할 수 있게 했다.
- `useGisEdit`에 `removeFocusedChange()`를 추가해 현재 선택 change 제거를 지원하고, popup message type과 세션 패널 버튼도 `취소/뒤로가기/초기화` 기준으로 재정렬했다.
- 현재 상세 패널이 열려 있던 change가 세션에서 제거되면 우측 상세 패널도 함께 닫히도록 `page.tsx`의 detail effect를 보강했다.
- 남긴 판단
- 이번 수정으로 임시 preview 관로가 snap 대상에서 빠지는 구조적 누락은 해소했지만, 실제 QGIS 체감과 지도 시각 피드백은 브라우저 수동 검증이 아직 필요하다.
- 세션 `취소`는 현재 focus된 change 기준이므로, 사용자가 어떤 change를 지우는지 목록 highlight와 지도 focus를 함께 보는 UX를 유지한다.
@@ -38,6 +38,7 @@ export const WINDOW_POPUP_MESSAGE_TYPE = {
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_FOCUSED_CHANGE: "sn:popup:gisEditSessionRemoveFocusedChange",
GIS_EDIT_SESSION_REMOVE_LAST_CHANGE: "sn:popup:gisEditSessionRemoveLastChange",
GIS_EDIT_SESSION_CLEAR_CHANGES: "sn:popup:gisEditSessionClearChanges",
WATER_RISK_READY: "sn:popup:waterRiskReady",
@@ -57,6 +57,9 @@ export default function GisEditSessionPopupContent(_props: WindowPopupContentPro
<GisEditSessionPanel
{...snapshot}
className="h-full shadow-none"
onRemoveFocusedChange={() => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE);
}}
onRemoveLastChange={() => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE);
}}