GIS 편집 임시 관로 스냅 지원 및 세션 취소/뒤로가기 분리
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user