sn: 침수예측 오버레이 수정
This commit is contained in:
@@ -60,7 +60,6 @@ import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Collection from "ol/Collection.js";
|
||||
import Feature from "ol/Feature.js";
|
||||
import GeoJSON from "ol/format/GeoJSON.js";
|
||||
import Overlay from "ol/Overlay.js";
|
||||
import CircleGeom from "ol/geom/Circle.js";
|
||||
import Draw from "ol/interaction/Draw.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 LayerSettingCard from "./components/layerSetting/LayerSettingCard";
|
||||
import { getExmnMapLayer } from "./components/exmn/exmnMapApi";
|
||||
import { getFloodRiskGradeClassName } from "@/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent";
|
||||
import { useFloodPredictionMap } from "@/features/fldngPredcPrst/lib/use-flood-prediction-map";
|
||||
import {
|
||||
EMPTY_EXMN_MAP_FILTERS,
|
||||
EXMN_RESULT_MNHL_LAYER_ID,
|
||||
@@ -241,15 +240,6 @@ const EXMN_GRADE_COLORS: Record<number, string> = {
|
||||
};
|
||||
const EXMN_RESULT_MANHOLE_ICON_SRC =
|
||||
"/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 grade = Number(feature?.get?.("structuralGrade") ?? 0);
|
||||
@@ -275,7 +265,6 @@ const createExmnManholeStyle = () =>
|
||||
}),
|
||||
});
|
||||
|
||||
/** 선택한 도시침수지도 중앙에 침수예측 분석값을 표시하는 오버레이 스타일이다. */
|
||||
// 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다.
|
||||
const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
|
||||
const FLOW_DIRECTION_SRS = "EPSG:5179";
|
||||
@@ -1124,21 +1113,20 @@ const Map = () => {
|
||||
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 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 [featureData, setFeatureData] = useState<any>(null); // 선택된 피쳐 데이터
|
||||
// 오른쪽 상세 패널에 열린 상세 컴포넌트를 책갈피 탭 단위로 보관한다.
|
||||
@@ -1422,8 +1410,6 @@ const Map = () => {
|
||||
const waterRiskPipeSourceRef = useRef<VectorSource | null>(null);
|
||||
const waterRiskManholeLayerRef = useRef<VectorLayer<any> | null>(null);
|
||||
const waterRiskManholeSourceRef = useRef<VectorSource | null>(null);
|
||||
// 침수예측현황 선택행에 따라 초단기예보 격자 중앙 라벨을 교체하는 전용 overlay를 보관한다.
|
||||
const floodPredictionGridLayerRef = useRef<Overlay | null>(null);
|
||||
const gisEditDrawRef = useRef<any | null>(null);
|
||||
const gisEditDrawSnapRef = 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(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
@@ -2012,43 +1819,10 @@ const Map = () => {
|
||||
if (
|
||||
data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP
|
||||
) {
|
||||
const extent = Array.isArray(data.payload?.extent)
|
||||
? 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 = {
|
||||
await handleFloodPredictionPopupSelection(data.payload, {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2087,50 +1861,7 @@ const Map = () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("message", handlePopupMessage);
|
||||
};
|
||||
}, [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]);
|
||||
}, [handleFloodPredictionPopupSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
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 { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel";
|
||||
import { getFloodRiskGradeClassName } from "../lib/flood-risk-grade";
|
||||
|
||||
type ApiRow = {
|
||||
sggCd: string;
|
||||
@@ -189,23 +190,6 @@ const validateFilters = (filters: Filters) => {
|
||||
const buildRowId = (row: ApiRow) =>
|
||||
`${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() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
|
||||
Reference in New Issue
Block a user