sn: 침수예측 오버레이 수정

This commit is contained in:
2026-10-01 15:02:53 +09:00
parent 5fcc184a88
commit ae341d66ba
6 changed files with 537 additions and 302 deletions
+16 -285
View File
@@ -60,7 +60,6 @@ import { usePathname, useSearchParams } from "next/navigation";
import Collection from "ol/Collection.js"; import Collection from "ol/Collection.js";
import Feature from "ol/Feature.js"; import Feature from "ol/Feature.js";
import GeoJSON from "ol/format/GeoJSON.js"; import GeoJSON from "ol/format/GeoJSON.js";
import Overlay from "ol/Overlay.js";
import CircleGeom from "ol/geom/Circle.js"; import CircleGeom from "ol/geom/Circle.js";
import Draw from "ol/interaction/Draw.js"; import Draw from "ol/interaction/Draw.js";
import Snap from "ol/interaction/Snap.js"; import Snap from "ol/interaction/Snap.js";
@@ -131,7 +130,7 @@ import FldngHstryLayerSettingPanel from "./components/fldngHstry/FldngHstryLayer
import ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel"; import ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel";
import LayerSettingCard from "./components/layerSetting/LayerSettingCard"; import LayerSettingCard from "./components/layerSetting/LayerSettingCard";
import { getExmnMapLayer } from "./components/exmn/exmnMapApi"; import { getExmnMapLayer } from "./components/exmn/exmnMapApi";
import { getFloodRiskGradeClassName } from "@/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent"; import { useFloodPredictionMap } from "@/features/fldngPredcPrst/lib/use-flood-prediction-map";
import { import {
EMPTY_EXMN_MAP_FILTERS, EMPTY_EXMN_MAP_FILTERS,
EXMN_RESULT_MNHL_LAYER_ID, EXMN_RESULT_MNHL_LAYER_ID,
@@ -241,15 +240,6 @@ const EXMN_GRADE_COLORS: Record<number, string> = {
}; };
const EXMN_RESULT_MANHOLE_ICON_SRC = const EXMN_RESULT_MANHOLE_ICON_SRC =
"/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png?v=3"; "/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png?v=3";
const FLOOD_PREDICTION_GRID_LAYER_ID = "SN_FLDNG_PREDICTION_GRID_LABEL";
/** 침수예측 오버레이용 12자리 분석시각을 화면 표시 형식으로 변환한다. */
const formatFloodPredictionDateTime = (value: unknown) => {
const text = String(value ?? "").trim();
return /^\d{12}$/.test(text)
? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)} ${text.slice(8, 10)}:${text.slice(10, 12)}`
: text || "-";
};
const getExmnGradeColor = (feature: any) => { const getExmnGradeColor = (feature: any) => {
const grade = Number(feature?.get?.("structuralGrade") ?? 0); const grade = Number(feature?.get?.("structuralGrade") ?? 0);
@@ -275,7 +265,6 @@ const createExmnManholeStyle = () =>
}), }),
}); });
/** 선택한 도시침수지도 중앙에 침수예측 분석값을 표시하는 오버레이 스타일이다. */
// 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다. // 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다.
const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
const FLOW_DIRECTION_SRS = "EPSG:5179"; const FLOW_DIRECTION_SRS = "EPSG:5179";
@@ -1124,21 +1113,20 @@ const Map = () => {
requestKey: number; requestKey: number;
layerNames: string[]; layerNames: string[];
} | null>(null); } | 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 [layerLoadError, setLayerLoadError] = useState<string | null>(null);
const gisMaps = useRef<any | null>(null); const gisMaps = useRef<any | null>(null);
/** 침수예측현황 행 선택 시 기존 체크를 유지하면서 도시침수지도 레이어 활성화를 요청한다. */
const requestFloodPredictionLayer = useCallback(() => {
setCheckedLayerRequest((current) => ({
requestKey: (current?.requestKey ?? 0) + 1,
layerNames: [FLOOD_DNGR_MAP_LAYER_NAME],
}));
}, []);
const { handleFloodPredictionPopupSelection } = useFloodPredictionMap({
mapRef: gisMaps,
floodLayerType: FLOOD_DNGR_MAP_LAYER_TYPE,
onRequestFloodLayer: requestFloodPredictionLayer,
});
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
const [featureData, setFeatureData] = useState<any>(null); // 선택된 피쳐 데이터 const [featureData, setFeatureData] = useState<any>(null); // 선택된 피쳐 데이터
// 오른쪽 상세 패널에 열린 상세 컴포넌트를 책갈피 탭 단위로 보관한다. // 오른쪽 상세 패널에 열린 상세 컴포넌트를 책갈피 탭 단위로 보관한다.
@@ -1422,8 +1410,6 @@ const Map = () => {
const waterRiskPipeSourceRef = useRef<VectorSource | null>(null); const waterRiskPipeSourceRef = useRef<VectorSource | null>(null);
const waterRiskManholeLayerRef = useRef<VectorLayer<any> | null>(null); const waterRiskManholeLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskManholeSourceRef = useRef<VectorSource | null>(null); const waterRiskManholeSourceRef = useRef<VectorSource | null>(null);
// 침수예측현황 선택행에 따라 초단기예보 격자 중앙 라벨을 교체하는 전용 overlay를 보관한다.
const floodPredictionGridLayerRef = useRef<Overlay | null>(null);
const gisEditDrawRef = useRef<any | null>(null); const gisEditDrawRef = useRef<any | null>(null);
const gisEditDrawSnapRef = useRef<any | null>(null); const gisEditDrawSnapRef = useRef<any | null>(null);
const gisEditTranslateRef = useRef<any | null>(null); const gisEditTranslateRef = useRef<any | null>(null);
@@ -1701,185 +1687,6 @@ const Map = () => {
} }
}, []); }, []);
/** 기존 침수예측 중앙 오버레이를 제거한다. */
const removeFloodPredictionGridLayer = useCallback(() => {
const core = GisApp.Module.core as any;
core?.deleteLayer?.(FLOOD_PREDICTION_GRID_LAYER_ID);
const map = gisMaps.current;
if (map && floodPredictionGridLayerRef.current) {
map.removeOverlay(floodPredictionGridLayerRef.current);
}
floodPredictionGridLayerRef.current = null;
}, []);
/** 선택행의 시군구·분석시각으로 도시침수지도 중앙 오버레이를 표시한다. */
const loadFloodPredictionGridLayer = useCallback(
async (sggCd: string, analysisDateTime: string, extent: number[]) => {
const map = gisMaps.current;
if (!map || !/^\d{5}$/.test(sggCd) || !/^\d{12}$/.test(analysisDateTime))
return;
removeFloodPredictionGridLayer();
try {
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`,
);
const payload = response?.data ?? response;
const row = payload && !Array.isArray(payload) ? payload : null;
if (
!row ||
extent.length !== 4 ||
extent.some((value) => !Number.isFinite(value))
)
return;
const center = [
(extent[0]! + extent[2]!) / 2,
(extent[1]! + extent[3]!) / 2,
];
const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed(
1,
);
const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed(
1,
);
const container = document.createElement("div");
container.style.cssText =
"position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; min-width:230px;";
const close = document.createElement("button");
close.type = "button";
close.textContent = "×";
close.title = "침수예측 오버레이 닫기";
close.style.cssText =
"position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;";
close.addEventListener("click", () => removeFloodPredictionGridLayer());
const table = document.createElement("table");
table.style.cssText = "border-collapse:collapse; width:100%;";
const title = table.createTHead().insertRow();
const titleCell = title.insertCell();
titleCell.colSpan = 2;
titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`;
titleCell.style.cssText =
"font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;";
const body = table.createTBody();
[
[
"분석시각",
formatFloodPredictionDateTime(
row.analysisDateTime ?? analysisDateTime,
),
],
[
"강우시작시각",
formatFloodPredictionDateTime(row.rainBeginningDateTime),
],
["관측 누적강수량(mm)", observed],
["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)],
["예측 누적강수량(mm)", predicted],
["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`],
["침수위험도", row.floodRiskGrade ?? "-"],
].forEach(([label, value]) => {
const tr = body.insertRow();
const key = tr.insertCell();
key.textContent = label;
key.style.cssText =
"font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;";
const val = tr.insertCell();
val.style.cssText =
"font-weight:400; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;";
if (label === "침수위험도") {
// 침수예측현황 목록과 같은 배지 스타일로 등급을 표시한다.
const badge = document.createElement("span");
badge.textContent = value;
badge.className = `sn-popup-badge min-w-[44px] ${getFloodRiskGradeClassName(String(value ?? ""))}`;
val.append(badge);
} else {
val.textContent = value;
}
});
container.append(close, table);
const overlay = new Overlay({
element: container,
positioning: "center-center",
stopEvent: true,
});
overlay.set("id", FLOOD_PREDICTION_GRID_LAYER_ID);
overlay.setPosition(center);
map.addOverlay(overlay);
floodPredictionGridLayerRef.current = overlay;
} catch (error) {
console.error("침수예측지도 오버레이 표시 실패:", error);
}
},
[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(() => { useEffect(() => {
fetchData(); fetchData();
}, []); }, []);
@@ -2012,43 +1819,10 @@ const Map = () => {
if ( if (
data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP
) { ) {
const extent = Array.isArray(data.payload?.extent) await handleFloodPredictionPopupSelection(data.payload, {
? data.payload.extent.map(Number)
: [];
const sggCd = String(data.payload?.sggCd ?? "").trim();
const frequency = String(data.payload?.sbmrgFrqExpln ?? "").trim();
const analysisDateTime = String(
data.payload?.analysisDateTime ?? "",
).trim();
const map = gisMaps.current;
const select = GisApp.Module.select as any;
if (
extent.length !== 4 ||
extent.some((value: number) => !Number.isFinite(value)) ||
!/^\d{5}$/.test(sggCd) ||
!/^(030|050|080|100)$/.test(frequency) ||
!map?.getView ||
!select?.selectFocusedBoundaryFeature
) {
return;
}
const selection = {
extent: extent as [number, number, number, number],
sggCd,
frequency,
analysisDateTime,
};
if (await applyFloodPredictionSelection(selection)) return;
pendingFloodPredictionPopupRef.current = {
source: event.source as Window | null, source: event.source as Window | null,
origin: event.origin, origin: event.origin,
}; });
setPendingFloodPredictionSelection(selection);
setCheckedLayerRequest((current) => ({
requestKey: (current?.requestKey ?? 0) + 1,
layerNames: [FLOOD_DNGR_MAP_LAYER_NAME],
}));
return; return;
} }
@@ -2087,50 +1861,7 @@ const Map = () => {
window.clearInterval(timer); window.clearInterval(timer);
window.removeEventListener("message", handlePopupMessage); window.removeEventListener("message", handlePopupMessage);
}; };
}, [applyFloodPredictionSelection]); }, [handleFloodPredictionPopupSelection]);
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(() => { useEffect(() => {
if (shouldShowGisEditSession) return; if (shouldShowGisEditSession) return;
@@ -0,0 +1,46 @@
# 침수예측지도 오버레이 시각 표시 구현기록
## 작업 목적
- 침수예측현황 팝업에서 행을 선택했을 때 지도 오버레이의 분석시각과 예측 관련 시각을 생략 없이 표시한다.
## 원본 및 변경 근거
- 요구사항정의서 `SFR-011-008 위험도 산정`
- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황`
- 사용자 요청사항: 지도 오버레이에서 월 뒤로 생략되는 분석시각 값을 전체 표시
## 원인
- SN 목록 테이블의 데이터 셀에 적용되는 전역 말줄임 규칙이 지도 오버레이 내부 테이블에도 적용되었다.
- 오버레이의 기존 날짜 변환 함수는 `YYYY-MM-DD HH:mm` 전체 값을 만들고 있었으나, 좁은 셀에서 화면 표시만 생략되었다.
## 변경 내용
- 지도 오버레이 폭을 기존과 동일한 `230px`로 유지했다.
- 고정된 폭 안에서 라벨 열 55%, 값 열 45%로 배분하도록 `colgroup`과 `table-layout: fixed`를 적용했다.
- 오버레이의 라벨·값 셀을 목록용 전역 말줄임 대상에서 제외했다.
- 전체 날짜와 시각이 한 줄에 표시되도록 값 셀의 줄바꿈과 말줄임을 해제했다.
- 기존 날짜 변환 형식과 침수위험도 배지 동작은 유지했다.
## 영향 범위
- `apps/sn/src/features/fldngPredcPrst/lib/use-flood-prediction-map.ts`
- 침수예측 격자 선택 후 표시되는 지도 오버레이에만 적용한다.
- 일반 목록 그리드의 말줄임 정책은 변경하지 않는다.
## 검증
- `pnpm --filter sn run typecheck` 통과
- `git diff --check` 통과
- 실행 중인 개발 서버의 `/map` 경로 HTTP 200 응답 확인
- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다.
## 롤백
- 오버레이 셀의 `data-sn-cell-wrap` 속성과 표·셀 너비 스타일을 제거하고 기존 최소 너비를 복원한다.
## 남은 확인
- 침수예측현황 행 선택 후 분석시각, 강우시작시각, 예측시각이 `YYYY-MM-DD HH:mm` 형식으로 모두 보이는지 확인한다.
- 지도의 오른쪽 경계 부근에서도 오버레이가 화면 안에 정상적으로 표시되는지 확인한다.
@@ -0,0 +1,52 @@
# 침수예측지도 기능 분리 구현기록
## 작업 목적
- 하수관망도 `map/page.tsx`에 포함된 침수예측현황 전용 로직을 기능 디렉터리로 이동한다.
- 지도 페이지에는 공통 지도 구성과 침수예측 기능 연결 코드만 남겨 기능별 책임을 분명히 한다.
## 원본 및 변경 근거
- 요구사항정의서 `SFR-011-008 위험도 산정`
- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황`
- 화면설계서 슬라이드 28, `SN_MN010206 침수예측지도`
- 사용자 요청사항: `map/page.tsx`의 침수예측현황 관련 함수와 상태를 `features` 하위 해당 기능으로 이동
## 변경 내용
- 침수예측 팝업 메시지 payload 검증을 기능 훅으로 이동했다.
- 도시침수지도 자동 활성화 요청 이후의 재시도 상태와 팝업 응답 대상을 기능 훅에서 관리한다.
- 도시침수지도 피처 선택, 지도 범위 이동, 분석결과 API 조회와 오버레이 생성·삭제를 기능 훅으로 이동했다.
- 침수위험도 배지 색상 계산을 별도 기능 유틸리티로 분리하여 팝업 목록과 지도 오버레이가 함께 사용하도록 했다.
- `map/page.tsx`에는 도시침수지도 레이어 활성화 요청과 팝업 메시지 위임만 남겼다.
## 수정 대상 파일
- `apps/sn/app/(map-layout)/map/page.tsx`
- `apps/sn/src/features/fldngPredcPrst/lib/use-flood-prediction-map.ts`
- `apps/sn/src/features/fldngPredcPrst/lib/flood-risk-grade.ts`
- `apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx`
## 영향 범위
- 침수예측현황 행 선택 후 도시침수지도 자동 선택·이동·분석 오버레이 표시 흐름
- 침수예측현황 목록과 지도 오버레이의 위험도 배지 색상
- API 경로, 팝업 메시지 타입, 레이어명·레이어 타입과 사용자 동작은 변경하지 않는다.
## 검증
- 신규 기능 파일 ESLint 검사 통과
- `pnpm --filter sn run typecheck` 통과
- `git diff --check` 확인
- 실행 중인 개발 서버의 `/map` 경로 HTTP 응답 확인
- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다.
## 롤백
- `useFloodPredictionMap`의 상태·콜백·재시도 effect를 `map/page.tsx`로 되돌린다.
- 오버레이 DOM 생성 함수와 날짜 포맷터를 `map/page.tsx`로 되돌린다.
- 위험도 배지 유틸리티를 팝업 컴포넌트 내부 함수로 복원한다.
## 남은 리스크
- 실제 로그인 세션에서 팝업 행을 선택해 레이어 자동 체크, 지도 이동, 오버레이 표시가 연속으로 유지되는지 최종 화면 확인이 필요하다.
@@ -0,0 +1,16 @@
/** 침수위험도 등급을 목록과 지도 오버레이에서 사용하는 고정 의미색으로 변환한다. */
export const getFloodRiskGradeClassName = (value: string) => {
const grade = String(value ?? "")
.trim()
.toUpperCase();
if (grade.includes("위험") || grade === "DANGER") {
return "bg-[#dc2626] text-white";
}
if (grade.includes("경계") || grade === "WARNING") {
return "bg-[#f97316] text-white";
}
if (grade.includes("주의") || grade === "CAUTION") {
return "bg-[#facc15] text-[#713f12]";
}
return "sn-popup-badge-normal";
};
@@ -0,0 +1,406 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Overlay from "ol/Overlay.js";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getRawJson } from "@/components/lib/api";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { getFloodRiskGradeClassName } from "./flood-risk-grade";
const FLOOD_PREDICTION_GRID_LAYER_ID = "SN_FLDNG_PREDICTION_GRID_LABEL";
const FLOOD_PREDICTION_LAYER_RETRY_LIMIT = 20;
const FLOOD_PREDICTION_LAYER_RETRY_DELAY = 200;
/** 침수예측현황 행 선택 후 지도에 적용할 위치·행 식별 정보다. */
type FloodPredictionSelection = {
extent: [number, number, number, number];
sggCd: string;
frequency: string;
analysisDateTime: string;
};
/** 자동 레이어 선택 실패 시 안내를 돌려보낼 침수예측 팝업 정보다. */
type FloodPredictionPopupContext = {
source: Window | null;
origin: string;
};
type FloodPredictionFeature = {
get?: (key: string) => unknown;
getId?: () => string | number | undefined;
};
type FloodPredictionSource = {
getFeatures?: () => FloodPredictionFeature[];
once?: (eventName: string, listener: () => void) => void;
};
type FloodPredictionLayer = {
get?: (key: string) => unknown;
getVisible?: () => boolean;
getSource?: () => FloodPredictionSource | null;
};
type FloodPredictionMap = {
addOverlay: (overlay: Overlay) => void;
removeOverlay: (overlay: Overlay) => void;
getLayers: () => { getArray: () => FloodPredictionLayer[] };
getView: () => {
fit: (
extent: [number, number, number, number],
options: {
duration: number;
padding: [number, number, number, number];
maxZoom: number;
},
) => void;
};
};
type FloodPredictionOverlayRow = Record<string, unknown> & {
analysisDateTime?: unknown;
ctpvCd?: unknown;
ctpvNm?: unknown;
exceededFrequencyYear?: unknown;
floodRiskGrade?: unknown;
observedAccumulatedRainfall?: unknown;
predictedAccumulatedRainfall?: unknown;
predictionDateTime?: unknown;
rainBeginningDateTime?: unknown;
sggCd?: unknown;
sggNm?: unknown;
};
type FloodSelectionModule = {
resetSelectedFeature?: () => void | Promise<void>;
selectFocusedBoundaryFeature?: (
feature: FloodPredictionFeature,
append: boolean,
) => void;
};
type UseFloodPredictionMapOptions = {
mapRef: { current: FloodPredictionMap | null };
floodLayerType: string;
onRequestFloodLayer: () => void;
};
/** 침수예측 오버레이용 12자리 분석시각을 화면 표시 형식으로 변환한다. */
const formatFloodPredictionDateTime = (value: unknown) => {
const text = String(value ?? "").trim();
return /^\d{12}$/.test(text)
? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)} ${text.slice(8, 10)}:${text.slice(10, 12)}`
: text || "-";
};
/** 팝업 메시지 payload를 검증하여 지도 선택 정보로 변환한다. */
const parseFloodPredictionSelection = (
payload: unknown,
): FloodPredictionSelection | null => {
const message =
payload && typeof payload === "object"
? (payload as Record<string, unknown>)
: {};
const extent = Array.isArray(message.extent)
? message.extent.map(Number)
: [];
const sggCd = String(message.sggCd ?? "").trim();
const frequency = String(message.sbmrgFrqExpln ?? "").trim();
const analysisDateTime = String(message.analysisDateTime ?? "").trim();
if (
extent.length !== 4 ||
extent.some((value: number) => !Number.isFinite(value)) ||
!/^\d{5}$/.test(sggCd) ||
!/^(030|050|080|100)$/.test(frequency)
) {
return null;
}
return {
extent: extent as [number, number, number, number],
sggCd,
frequency,
analysisDateTime,
};
};
/** 선택 행의 분석값을 표시할 지도 오버레이 DOM을 생성한다. */
const createFloodPredictionOverlayElement = (
row: FloodPredictionOverlayRow,
analysisDateTime: string,
onClose: () => void,
) => {
const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed(1);
const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed(1);
const container = document.createElement("div");
container.style.cssText =
"position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; width:230px;";
const close = document.createElement("button");
close.type = "button";
close.textContent = "×";
close.title = "침수예측 오버레이 닫기";
close.style.cssText =
"position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;";
close.addEventListener("click", onClose);
const table = document.createElement("table");
table.style.cssText =
"border-collapse:collapse; table-layout:fixed; width:100%;";
const colgroup = document.createElement("colgroup");
const keyColumn = document.createElement("col");
const valueColumn = document.createElement("col");
keyColumn.style.width = "55%";
valueColumn.style.width = "45%";
colgroup.append(keyColumn, valueColumn);
table.append(colgroup);
const title = table.createTHead().insertRow();
const titleCell = title.insertCell();
titleCell.colSpan = 2;
titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`;
titleCell.style.cssText =
"font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;";
const body = table.createTBody();
const overlayRows: Array<[string, string]> = [
[
"분석시각",
formatFloodPredictionDateTime(row.analysisDateTime ?? analysisDateTime),
],
[
"강우시작시각",
formatFloodPredictionDateTime(row.rainBeginningDateTime),
],
["관측 누적강수량(mm)", observed],
["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)],
["예측 누적강수량(mm)", predicted],
["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`],
["침수위험도", String(row.floodRiskGrade ?? "-")],
];
overlayRows.forEach(([label, value]) => {
const tr = body.insertRow();
const key = tr.insertCell();
key.textContent = label;
// 목록용 전역 말줄임 규칙이 지도 오버레이 셀에는 적용되지 않게 한다.
key.setAttribute("data-sn-cell-wrap", "true");
key.style.cssText =
"font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;";
const val = tr.insertCell();
val.setAttribute("data-sn-cell-wrap", "true");
val.style.cssText =
"font-weight:400; overflow:visible; text-overflow:clip; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;";
if (label === "침수위험도") {
// 침수예측현황 목록과 같은 배지 스타일로 등급을 표시한다.
const badge = document.createElement("span");
badge.textContent = value;
badge.className = `sn-popup-badge min-w-[44px] ${getFloodRiskGradeClassName(String(value ?? ""))}`;
val.append(badge);
} else {
val.textContent = value;
}
});
container.append(close, table);
return container;
};
/** 침수예측현황 팝업과 도시침수지도의 선택·이동·오버레이 흐름을 관리한다. */
export const useFloodPredictionMap = ({
mapRef,
floodLayerType,
onRequestFloodLayer,
}: UseFloodPredictionMapOptions) => {
// 자동 레이어 선택이 완료된 뒤 다시 적용할 침수예측 행을 보관한다.
const [pendingSelection, setPendingSelection] =
useState<FloodPredictionSelection | null>(null);
// 팝업 Window 객체는 렌더링 대상이 아니므로 자동 선택 실패 안내용 ref로 보관한다.
const pendingPopupRef = useRef<FloodPredictionPopupContext | null>(null);
// 행을 다시 선택할 때 기존 오버레이를 제거할 수 있도록 OpenLayers 객체를 ref로 보관한다.
const overlayRef = useRef<Overlay | null>(null);
/** 기존 침수예측 중앙 오버레이를 제거한다. */
const removeFloodPredictionOverlay = useCallback(() => {
const core = GisApp.Module.core as unknown as
| { deleteLayer?: (layerId: string) => void }
| undefined;
core?.deleteLayer?.(FLOOD_PREDICTION_GRID_LAYER_ID);
const map = mapRef.current;
if (map && overlayRef.current) {
map.removeOverlay(overlayRef.current);
}
overlayRef.current = null;
}, [mapRef]);
/** 선택행의 시군구·분석시각으로 도시침수지도 중앙 오버레이를 표시한다. */
const loadFloodPredictionOverlay = useCallback(
async (selection: FloodPredictionSelection) => {
const { sggCd, analysisDateTime, extent } = selection;
const map = mapRef.current;
if (!map || !/^\d{12}$/.test(analysisDateTime)) return;
removeFloodPredictionOverlay();
try {
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`,
);
const payload = response?.data ?? response;
const row =
payload && typeof payload === "object" && !Array.isArray(payload)
? (payload as FloodPredictionOverlayRow)
: null;
if (!row) return;
const center = [
(extent[0] + extent[2]) / 2,
(extent[1] + extent[3]) / 2,
];
const overlay = new Overlay({
element: createFloodPredictionOverlayElement(
row,
analysisDateTime,
removeFloodPredictionOverlay,
),
positioning: "center-center",
stopEvent: true,
});
overlay.set("id", FLOOD_PREDICTION_GRID_LAYER_ID);
overlay.setPosition(center);
map.addOverlay(overlay);
overlayRef.current = overlay;
} catch (error) {
console.error("침수예측지도 오버레이 표시 실패:", error);
}
},
[mapRef, removeFloodPredictionOverlay],
);
/** 활성화된 도시침수지도에서 선택 행의 시군구·빈도 피처를 선택하고 지도와 오버레이를 이동한다. */
const applyFloodPredictionSelection = useCallback(
async (selection: FloodPredictionSelection) => {
const map = mapRef.current;
const select = GisApp.Module.select as unknown as
| FloodSelectionModule
| undefined;
const selectFocusedBoundaryFeature =
select?.selectFocusedBoundaryFeature;
if (!map || !selectFocusedBoundaryFeature) return false;
const floodLayers = map
.getLayers()
.getArray()
.filter(
(layer) =>
String(layer.get?.("lyrTyNm") ?? "").trim() === floodLayerType &&
layer.getVisible?.() !== false,
);
if (floodLayers.length === 0) return false;
await select.resetSelectedFeature?.();
const selectedFeatureIds = new Set<string>();
/** 현재 로드된 동일 시군구·빈도 피처를 중복 없이 선택한다. */
const selectLoadedGroupFeatures = (source: FloodPredictionSource) => {
const features = source?.getFeatures?.() ?? [];
features.forEach((feature) => {
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);
selectFocusedBoundaryFeature(feature, true);
});
};
floodLayers.forEach((layer) => {
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 loadFloodPredictionOverlay(selection);
return true;
},
[floodLayerType, loadFloodPredictionOverlay, mapRef],
);
/** 침수예측현황 행 선택 메시지를 검증하고, 필요하면 도시침수지도 레이어 활성화를 요청한다. */
const handleFloodPredictionPopupSelection = useCallback(
async (payload: unknown, popupContext: FloodPredictionPopupContext) => {
const selection = parseFloodPredictionSelection(payload);
if (!selection) return;
if (await applyFloodPredictionSelection(selection)) return;
pendingPopupRef.current = popupContext;
setPendingSelection(selection);
onRequestFloodLayer();
},
[applyFloodPredictionSelection, onRequestFloodLayer],
);
useEffect(() => {
if (!pendingSelection) return;
let retryTimer: number | null = null;
let retryCount = 0;
/** 자동 체크 요청으로 도시침수지도 레이어가 생성될 때까지 선택 행 적용을 제한적으로 재시도한다. */
const applyPendingSelection = async () => {
if (await applyFloodPredictionSelection(pendingSelection)) {
setPendingSelection((current) =>
current === pendingSelection ? null : current,
);
pendingPopupRef.current = null;
return;
}
retryCount += 1;
if (retryCount >= FLOOD_PREDICTION_LAYER_RETRY_LIMIT) {
const popup = pendingPopupRef.current;
popup?.source?.postMessage(
{ type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED },
popup.origin,
);
pendingPopupRef.current = null;
setPendingSelection((current) =>
current === pendingSelection ? null : current,
);
return;
}
retryTimer = window.setTimeout(() => {
void applyPendingSelection();
}, FLOOD_PREDICTION_LAYER_RETRY_DELAY);
};
void applyPendingSelection();
return () => {
if (retryTimer !== null) window.clearTimeout(retryTimer);
};
}, [applyFloodPredictionSelection, pendingSelection]);
return { handleFloodPredictionPopupSelection };
};
@@ -19,6 +19,7 @@ import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore"; import useStore from "@/store/useStore";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel"; import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel";
import { getFloodRiskGradeClassName } from "../lib/flood-risk-grade";
type ApiRow = { type ApiRow = {
sggCd: string; sggCd: string;
@@ -189,23 +190,6 @@ const validateFilters = (filters: Filters) => {
const buildRowId = (row: ApiRow) => const buildRowId = (row: ApiRow) =>
`${row.ctpvCd}:${row.sggCd}:${row.rainBeginningDateTime}:${row.analysisDateTime}:${row.predictionHour}`; `${row.ctpvCd}:${row.sggCd}:${row.rainBeginningDateTime}:${row.analysisDateTime}:${row.predictionHour}`;
/** 침수위험도 등급을 목록 배지의 고정 의미색으로 변환한다. */
export const getFloodRiskGradeClassName = (value: string) => {
const grade = String(value ?? "")
.trim()
.toUpperCase();
if (grade.includes("위험") || grade === "DANGER") {
return "bg-[#dc2626] text-white";
}
if (grade.includes("경계") || grade === "WARNING") {
return "bg-[#f97316] text-white";
}
if (grade.includes("주의") || grade === "CAUTION") {
return "bg-[#facc15] text-[#713f12]";
}
return "sn-popup-badge-normal";
};
export default function FldngPredcPrstPopupContent() { export default function FldngPredcPrstPopupContent() {
const alert = useAlertStore((state) => state.setMessage); const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();