Compare commits

...
2 Commits
Author SHA1 Message Date
mind-one005 735bb397ec sn: 침수예측현황 분석시각 조건 초기화 2026-09-30 18:09:20 +09:00
mind-one005 8b5a2b713c sn: 도시침수지도 자동선택 2026-09-30 17:53:50 +09:00
4 changed files with 234 additions and 68 deletions
+142 -52
View File
@@ -194,6 +194,7 @@ import {
isSbmrgDngrMapRootNode,
} from "./components/sbmrgDngrMap/sbmrgDngrMapTypes";
import {
FLOOD_DNGR_MAP_LAYER_NAME,
FLOOD_DNGR_MAP_LAYER_TYPE,
FLOOD_DNGR_MAP_POPUP_KEY,
isFloodDngrMapRootNode,
@@ -1176,11 +1177,24 @@ const Map = () => {
const sinkholeScope = useMemo(() => buildSinkholeScope(userInfo), [userInfo]);
const [treeData, setTreeData] = useState<any[]>([]); // 초기값을 객체 또는 null로 설정
// 통수위험 결과 행을 선택할 때 기존 체크를 유지하면서 강우 격자 레이어를 켜기 위한 요청이다.
// 지도 연계 팝업의 행을 선택할 때 기존 체크를 유지하면서 필요한 업무 레이어를 켜기 위한 요청이다.
const [checkedLayerRequest, setCheckedLayerRequest] = useState<{
requestKey: number;
layerNames: string[];
} | null>(null);
// 침수예측현황에서 선택한 행은 도시침수지도 레이어가 켜진 뒤 지도 이동에 사용한다.
const [pendingFloodPredictionSelection, setPendingFloodPredictionSelection] =
useState<{
extent: [number, number, number, number];
sggCd: string;
frequency: string;
analysisDateTime: string;
} | null>(null);
// 팝업 Window 객체는 렌더링 대상이 아니므로 자동 레이어 선택 실패 안내용 ref로 보관한다.
const pendingFloodPredictionPopupRef = useRef<{
source: Window | null;
origin: string;
} | null>(null);
const [layerLoadError, setLayerLoadError] = useState<string | null>(null);
const gisMaps = useRef<any | null>(null);
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
@@ -1856,6 +1870,74 @@ const Map = () => {
[removeFloodPredictionGridLayer],
);
/** 활성화된 도시침수지도에서 선택 행의 시군구·빈도 피처를 선택하고 지도와 오버레이를 이동한다. */
const applyFloodPredictionSelection = useCallback(
async (selection: {
extent: [number, number, number, number];
sggCd: string;
frequency: string;
analysisDateTime: string;
}) => {
const map = gisMaps.current;
const select = GisApp.Module.select as any;
if (!map?.getView || !select?.selectFocusedBoundaryFeature) return false;
const floodLayers = map
.getLayers()
.getArray()
.filter(
(layer: any) =>
String(layer.get?.("lyrTyNm") ?? "").trim() ===
FLOOD_DNGR_MAP_LAYER_TYPE && layer.getVisible?.() !== false,
);
if (floodLayers.length === 0) return false;
await select.resetSelectedFeature?.();
const selectedFeatureIds = new Set<string>();
const selectLoadedGroupFeatures = (source: any) => {
const features = source?.getFeatures?.() ?? [];
features.forEach((feature: any) => {
const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim();
const featureFrequency = String(
feature.get?.("sbmrg_frq_expln") ?? "",
).trim();
if (
featureSggCd !== selection.sggCd ||
featureFrequency !== selection.frequency
)
return;
const featureId = String(
feature.getId?.() ?? feature.get?.("fid") ?? "",
);
if (featureId && selectedFeatureIds.has(featureId)) return;
if (featureId) selectedFeatureIds.add(featureId);
select.selectFocusedBoundaryFeature(feature, true);
});
};
floodLayers.forEach((layer: any) => {
const source = layer.getSource?.();
if (!source?.getFeatures) return;
selectLoadedGroupFeatures(source);
source.once?.("featuresloadend", () =>
selectLoadedGroupFeatures(source),
);
});
map.getView().fit(selection.extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
void loadFloodPredictionGridLayer(
selection.sggCd,
selection.analysisDateTime,
selection.extent,
);
return true;
},
[loadFloodPredictionGridLayer],
);
useEffect(() => {
fetchData();
}, []);
@@ -2008,58 +2090,23 @@ const Map = () => {
) {
return;
}
await select.resetSelectedFeature?.();
const selectedFeatureIds = new Set<string>();
let floodLayerVisible = false;
const selectLoadedGroupFeatures = (source: any) => {
const features = source?.getFeatures?.() ?? [];
features.forEach((feature: any) => {
const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim();
const featureFrequency = String(
feature.get?.("sbmrg_frq_expln") ?? "",
).trim();
if (featureSggCd !== sggCd || featureFrequency !== frequency)
return;
const featureId = String(
feature.getId?.() ?? feature.get?.("fid") ?? "",
);
if (featureId && selectedFeatureIds.has(featureId)) return;
if (featureId) selectedFeatureIds.add(featureId);
select.selectFocusedBoundaryFeature(feature, true);
});
const selection = {
extent: extent as [number, number, number, number],
sggCd,
frequency,
analysisDateTime,
};
if (await applyFloodPredictionSelection(selection)) return;
map
.getLayers()
.getArray()
.forEach((layer: any) => {
if (
String(layer.get?.("lyrTyNm") ?? "").trim() !==
FLOOD_DNGR_MAP_LAYER_TYPE
)
return;
if (layer.getVisible?.() !== false) floodLayerVisible = true;
const source = layer.getSource?.();
if (!source?.getFeatures) return;
selectLoadedGroupFeatures(source);
source.once?.("featuresloadend", () =>
selectLoadedGroupFeatures(source),
);
});
if (!floodLayerVisible) {
(event.source as Window | null)?.postMessage?.(
{ type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED },
event.origin,
);
return;
}
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
void loadFloodPredictionGridLayer(sggCd, analysisDateTime, extent);
pendingFloodPredictionPopupRef.current = {
source: event.source as Window | null,
origin: event.origin,
};
setPendingFloodPredictionSelection(selection);
setCheckedLayerRequest((current) => ({
requestKey: (current?.requestKey ?? 0) + 1,
layerNames: [FLOOD_DNGR_MAP_LAYER_NAME],
}));
return;
}
@@ -2098,7 +2145,50 @@ const Map = () => {
window.clearInterval(timer);
window.removeEventListener("message", handlePopupMessage);
};
}, [loadFloodPredictionGridLayer]);
}, [applyFloodPredictionSelection]);
useEffect(() => {
if (!pendingFloodPredictionSelection) return;
let retryTimer: number | null = null;
let retryCount = 0;
const maxRetryCount = 20;
/** 자동 체크 요청으로 도시침수지도 레이어가 생성될 때까지 선택 행 적용을 제한적으로 재시도한다. */
const applyPendingSelection = async () => {
if (
await applyFloodPredictionSelection(pendingFloodPredictionSelection)
) {
setPendingFloodPredictionSelection((current) =>
current === pendingFloodPredictionSelection ? null : current,
);
pendingFloodPredictionPopupRef.current = null;
return;
}
retryCount += 1;
if (retryCount >= maxRetryCount) {
const popup = pendingFloodPredictionPopupRef.current;
popup?.source?.postMessage(
{ type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED },
popup.origin,
);
pendingFloodPredictionPopupRef.current = null;
setPendingFloodPredictionSelection((current) =>
current === pendingFloodPredictionSelection ? null : current,
);
return;
}
retryTimer = window.setTimeout(() => {
void applyPendingSelection();
}, 200);
};
void applyPendingSelection();
return () => {
if (retryTimer !== null) window.clearTimeout(retryTimer);
};
}, [applyFloodPredictionSelection, pendingFloodPredictionSelection]);
useEffect(() => {
if (shouldShowGisEditSession) return;
@@ -0,0 +1,39 @@
# 침수예측현황 분석시각 초기화 구현기록
## 작업 목적
- 침수예측현황 팝업을 처음 열거나 검색조건을 초기화할 때 분석시각 범위를 자동 입력하지 않는다.
- 분석시각 제한 없이 조회한 결과는 최근 분석 데이터부터 표시한다.
## 원본 및 변경 근거
- 요구사항정의서 `SFR-011-008 위험도 산정`
- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황`
- 사용자 승인사항: 분석시각 기본 검색조건 제거 및 최근 데이터 우선 표시
## 변경 내용
- 최초·초기화 검색조건의 `analysisFrom`, `analysisTo`를 빈 값으로 변경했다.
- 목록과 Excel 전체 조회에 `analysisDateTime,desc` 정렬을 명시했다.
- 사용자가 필요할 때 직접 분석시각 범위를 입력할 수 있도록 검색 입력란은 유지했다.
## 영향 범위
- `apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx`
- 백엔드 API와 응답 구조는 변경하지 않는다.
- 시도, 시군구, 강우시각, 초과 빈도 검색조건은 기존 동작을 유지한다.
## 검증
- `pnpm --filter sn run typecheck` 통과
- `git diff --check` 통과
- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다.
## 롤백
- `buildInitialFilters`에서 분석시각의 금일 기본 범위를 다시 생성하고 명시적 최신순 정렬 파라미터를 제거한다.
## 남은 확인
- 팝업 최초 진입 시 분석시각 입력란이 비어 있는지 확인한다.
- 초기화 버튼을 누르면 분석시각이 비워지고 첫 페이지에 최신 분석 데이터가 표시되는지 확인한다.
@@ -0,0 +1,42 @@
# 침수예측현황 도시침수지도 자동 선택 구현기록
## 작업 목적
- 침수예측현황 팝업에서 행을 선택하면 사용자가 레이어 트리에서 직접 조작하지 않아도 도시침수지도 레이어를 자동으로 체크한다.
- 레이어 활성화 후 선택한 시군구와 빈도에 맞는 위치 이동, 피처 선택, 침수예측 오버레이 표시를 이어서 실행한다.
## 원본 근거
- 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 94행, `SFR-011-008 위험도 산정`
- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황`
- 화면설계서 슬라이드 28, `SN_MN010206 침수예측지도`
- 자동 레이어 선택 비교 화면: 슬라이드 47, `SN_MN040106 조사결과 지도보기`
## 변경 내용
- 하수관망도에 이미 연결된 `checkedLayerRequest`를 사용하여 `도시침수지도(정보제공포털)` 레이어를 기존 체크 상태에 추가한다.
- 도시침수지도 레이어가 활성화되기 전에 도착한 행 선택 정보는 임시 상태에 보관한다.
- 레이어가 준비되면 기존 시군구·빈도 피처 선택, 지도 범위 이동, 침수예측 오버레이 표시 로직을 실행한다.
- 제한 시간 안에 레이어가 준비되지 않으면 기존 수동 선택 안내 메시지를 팝업에 전달한다.
## 영향 범위
- `apps/sn/app/(map-layout)/map/page.tsx`
- 백엔드 API와 메시지 payload 계약은 변경하지 않는다.
- 기존에 사용자가 체크한 다른 지도 레이어는 유지한다.
- 도시침수지도 미제공 빈도에 대한 기존 안내는 유지한다.
## 검증
- `pnpm --filter sn exec tsc --noEmit` 통과
- `git diff --check` 통과
- 개발 서버가 3002 포트에서 실행 중이므로 별도 빌드는 수행하지 않는다.
## 롤백
- `map/page.tsx`의 도시침수지도 자동 체크 요청, 보류 상태, 재시도 effect를 제거하고 기존 레이어 가시성 검사와 수동 선택 안내 흐름으로 복원한다.
## 남은 확인
- 로그인된 브라우저에서 도시침수지도가 꺼진 상태와 이미 켜진 상태를 각각 확인한다.
- 행을 연속 선택했을 때 마지막 선택 행 기준으로 지도와 오버레이가 갱신되는지 확인한다.
@@ -17,7 +17,6 @@ import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore";
import { createPredictionAnalysisTimeRange } from "../model/analysisTimeRange";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel";
@@ -111,21 +110,16 @@ const HEADS: UiHeadProps[] = [
{ id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 },
];
/** 최초·초기화 조회조건은 금일 분석결과를 기준으로 생성한다. */
const buildInitialFilters = (): Filters => {
const range = createPredictionAnalysisTimeRange();
const toDateTimeLocal = (value: string) =>
`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(8, 10)}:${value.slice(10, 12)}`;
return {
ctpvCd: "0",
sggCd: "0",
rainBgngFrom: "",
rainBgngTo: "",
analysisFrom: toDateTimeLocal(range.analysisFrom),
analysisTo: toDateTimeLocal(range.analysisTo),
frequencyYear: "0",
};
};
/** 최초·초기화 시 시각 범위를 제한하지 않고 최신 침수예측 데이터부터 조회한다. */
const buildInitialFilters = (): Filters => ({
ctpvCd: "0",
sggCd: "0",
rainBgngFrom: "",
rainBgngTo: "",
analysisFrom: "",
analysisTo: "",
frequencyYear: "0",
});
const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12);
@@ -151,6 +145,7 @@ const buildQuery = (filters: Filters, page?: number, size = PAGE_SIZE) => {
if (page !== undefined) {
params.set("page", String(page - 1));
params.set("size", String(size));
params.set("sort", "analysisDateTime,desc");
}
appendApiSearchParams(params, {
ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),