diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryLayerSettingPanel.tsx
new file mode 100644
index 000000000..ed579f904
--- /dev/null
+++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryLayerSettingPanel.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { SelectBox } from "@common/business_components/ui";
+import {
+ FLOOD_HISTORY_MIN_YEAR,
+ normalizeFloodHistoryYearRange,
+ type FloodHistoryYearRange,
+} from "./fldngHstryTypes";
+
+type Props = {
+ yearRange: FloodHistoryYearRange;
+ enabled: boolean;
+ onYearRangeChange: (yearRange: FloodHistoryYearRange) => void;
+ onEnabledChange: (enabled: boolean) => void;
+ onClose: () => void;
+};
+
+/** 침수흔적위선·심선에 공통 적용되는 즉시 반영 침수년도 설정 카드다. */
+export default function FldngHstryLayerSettingPanel({
+ yearRange,
+ enabled,
+ onYearRangeChange,
+ onEnabledChange,
+ onClose,
+}: Props) {
+ const normalizedRange = normalizeFloodHistoryYearRange(yearRange);
+ const currentYear = new Date().getFullYear();
+ const yearOptions = Array.from(
+ { length: currentYear - FLOOD_HISTORY_MIN_YEAR + 1 },
+ (_, index) => String(currentYear - index),
+ );
+ const startYearOptions = yearOptions
+ .filter((year) => Number(year) <= Number(normalizedRange.endYear))
+ .map((year) => ({ val: year, text: year }));
+ const endYearOptions = yearOptions
+ .filter((year) => Number(year) >= Number(normalizedRange.startYear))
+ .map((year) => ({ val: year, text: year }));
+ const yearSelectClass =
+ "h-[22px] w-[62px] rounded-none border border-input bg-background px-[4px] text-center text-[1rem] leading-none text-foreground";
+
+ const changeYear = (key: keyof FloodHistoryYearRange, value: string) => {
+ onYearRangeChange(
+ normalizeFloodHistoryYearRange({
+ ...normalizedRange,
+ [key]: value,
+ }),
+ );
+ };
+
+ return (
+
+
+
+ 침수흔적도
+
+
+
+
+
+
+
+
+
침수년도
+
+
+ changeYear("startYear", String(value))}
+ selectContentClass="z-[101]"
+ labelClass="hidden"
+ />
+
+
+ ~
+
+
+ changeYear("endYear", String(value))}
+ selectContentClass="z-[101]"
+ labelClass="hidden"
+ />
+
+
+
+
+ );
+}
diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts b/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts
index 28f176144..bca73fc0e 100644
--- a/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts
+++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts
@@ -5,13 +5,57 @@ import type { UiHeadProps } from "@common/business_components/ui";
export const FLOOD_HISTORY_POPUP_KEY = "fldngHstryPopup";
export const FLDNG_HSTRY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
export const FLOOD_HISTORY_LAYER_TYPE = "tb_ms_fldng_wtlv_map";
+export const FLOOD_HISTORY_DEPTH_LAYER_TYPE = "tb_ms_fldng_dpth_map";
export const FLOOD_HISTORY_PREVIEW_LAYER_TYPE = "tb_sn_flood_trace_mock";
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적위선";
+export const FLOOD_HISTORY_DEPTH_LAYER_NAME = "침수흔적심선";
export const FLOOD_HISTORY_ROOT_NAME = "침수흔적도";
export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
export const FLDNG_HSTRY_PAGE_SIZE = 10;
export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000;
+export const FLOOD_HISTORY_MIN_YEAR = 2002;
+
+/** 지도 침수흔적위선·심선이 공통으로 사용하는 침수년도 범위다. */
+export type FloodHistoryYearRange = {
+ startYear: string;
+ endYear: string;
+};
+
+/** 침수년도 설정의 초기 범위는 데이터 최소연도부터 현재년도까지다. */
+export const buildFloodHistoryDefaultYearRange = (
+ currentYear = new Date().getFullYear(),
+): FloodHistoryYearRange => ({
+ startYear: String(FLOOD_HISTORY_MIN_YEAR),
+ endYear: String(currentYear),
+});
+
+/** 연도 설정값을 4자리 숫자로 정규화하고 유효하지 않으면 기본 범위를 사용한다. */
+export const normalizeFloodHistoryYearRange = (
+ range?: Partial | null,
+ fallback: FloodHistoryYearRange = buildFloodHistoryDefaultYearRange(),
+): FloodHistoryYearRange => {
+ const normalizeYear = (value: unknown) =>
+ String(value ?? "")
+ .replace(/[^0-9]/g, "")
+ .slice(0, 4);
+ const startYear = normalizeYear(range?.startYear);
+ const endYear = normalizeYear(range?.endYear);
+ return {
+ startYear: /^\d{4}$/.test(startYear) ? startYear : fallback.startYear,
+ endYear: /^\d{4}$/.test(endYear) ? endYear : fallback.endYear,
+ };
+};
+
+/** 위선·심선 WFS에 동일하게 결합할 침수년도 CQL 조건을 생성한다. */
+export const buildFloodHistoryYearCql = (
+ range: FloodHistoryYearRange,
+ enabled: boolean,
+) => {
+ if (!enabled) return "";
+ const normalized = normalizeFloodHistoryYearRange(range);
+ return `(fldng_yr >= '${normalized.startYear}' AND fldng_yr <= '${normalized.endYear}')`;
+};
export type FldngHstryFilters = {
ctpvCd: string;
@@ -382,8 +426,10 @@ export const buildFldngHstryListQueryString = (
if (normalized.floodYear) params.set("fldngYr", normalized.floodYear);
if (normalized.disasterName)
params.set("fldngDstrNm", normalized.disasterName);
- if (normalized.rainStartDate) params.set("rainBgngTm", normalized.rainStartDate);
- if (normalized.districtName) params.set("fldngDstrctNm", normalized.districtName);
+ if (normalized.rainStartDate)
+ params.set("rainBgngTm", normalized.rainStartDate);
+ if (normalized.districtName)
+ params.set("fldngDstrctNm", normalized.districtName);
return params.toString();
};
@@ -494,11 +540,32 @@ export const isFldngHstryNode = (node: any) => {
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLOOD_HISTORY_LAYER_NAME ||
+ name === FLOOD_HISTORY_DEPTH_LAYER_NAME ||
layerType === FLOOD_HISTORY_LAYER_TYPE ||
+ layerType === FLOOD_HISTORY_DEPTH_LAYER_TYPE ||
layerType === FLOOD_HISTORY_PREVIEW_LAYER_TYPE
);
};
+/** 침수흔적위선 체크 상태를 심선과 분리해 판별한다. */
+export const isFloodHistoryWaterLevelNode = (node: any) => {
+ const name = resolveFldngHstryNodeName(node);
+ const layerType = resolveFldngHstryLayerType(node);
+ return (
+ name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE
+ );
+};
+
+/** 침수흔적심선 체크 상태를 위선과 분리해 판별한다. */
+export const isFloodHistoryDepthNode = (node: any) => {
+ const name = resolveFldngHstryNodeName(node);
+ const layerType = resolveFldngHstryLayerType(node);
+ return (
+ name === FLOOD_HISTORY_DEPTH_LAYER_NAME ||
+ layerType === FLOOD_HISTORY_DEPTH_LAYER_TYPE
+ );
+};
+
export const isFldngHstryRootNode = (node: any) =>
resolveFldngHstryNodeName(node) === FLOOD_HISTORY_ROOT_NAME;
diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx
index 48e75e968..9b6037bdf 100644
--- a/apps/sn/app/(map-layout)/map/page.tsx
+++ b/apps/sn/app/(map-layout)/map/page.tsx
@@ -102,6 +102,7 @@ import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap";
import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
import FldngHstryDetailPanel from "./components/fldngHstry/FldngHstryDetailPanel";
+import FldngHstryLayerSettingPanel from "./components/fldngHstry/FldngHstryLayerSettingPanel";
import ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel";
import { getExmnMapLayer } from "./components/exmn/exmnMapApi";
import {
@@ -143,14 +144,20 @@ import {
} from "./components/sinkhole/sinkholeTypes";
import {
FLOOD_HISTORY_AREA_LAYER_ID,
+ FLOOD_HISTORY_DEPTH_LAYER_NAME,
+ FLOOD_HISTORY_DEPTH_LAYER_TYPE,
FLOOD_HISTORY_DEPTH_LAYER_ID,
FLOOD_HISTORY_PREVIEW_LAYER_TYPE,
FLOOD_HISTORY_LAYER_TYPE,
FLOOD_HISTORY_LAYER_NAME,
FLOOD_HISTORY_POPUP_KEY,
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
- isFldngHstryNode,
+ buildFloodHistoryDefaultYearRange,
+ buildFloodHistoryYearCql,
+ isFloodHistoryDepthNode,
+ isFloodHistoryWaterLevelNode,
isFldngHstryRootNode,
+ type FloodHistoryYearRange,
type FldngHstryItem,
} from "./components/fldngHstry/fldngHstryTypes";
import {
@@ -186,6 +193,8 @@ const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT";
// 노후관로 지도 강조선과 시설물 트리의 선형 범례가 동일하게 사용하는 빨간색이다.
const AGED_PIPE_HIGHLIGHT_COLOR = "rgba(235, 40, 40, 0.82)";
const FLDNG_HSTRY_WFS_LAYER_ID = "SN_FLDNG_HSTRY_WFS";
+// 심선은 동일 레이어 유형에 6개 깊이 등급 설정이 있어 prefix 뒤에 lyrSn을 붙여 각각 생성한다.
+const FLDNG_DEPTH_WFS_LAYER_ID_PREFIX = "SN_FLDNG_DEPTH_WFS";
const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
@@ -3005,6 +3014,24 @@ const Map = () => {
const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false);
const [isSinkholeChecked, setIsSinkholeChecked] = useState(false);
const [isFldngHstryChecked, setIsFldngHstryChecked] = useState(false);
+ // 침수흔적위선과 심선은 하나의 연도 범위를 공유하지만 체크 상태는 독립적으로 관리한다.
+ const [isFloodWtlvChecked, setIsFloodWtlvChecked] = useState(false);
+ const [isFloodDepthChecked, setIsFloodDepthChecked] = useState(false);
+ // 심선은 6개 깊이 등급 중 사용자가 실제 체크한 lyrSn만 필터 레이어로 생성한다.
+ const [checkedFloodDepthLayerKeys, setCheckedFloodDepthLayerKeys] = useState<
+ string[]
+ >([]);
+ const [floodYearRange, setFloodYearRange] = useState(
+ () => buildFloodHistoryDefaultYearRange(),
+ );
+ const [isFloodYearFilterEnabled, setIsFloodYearFilterEnabled] =
+ useState(true);
+ const [isFloodYearSettingVisible, setIsFloodYearSettingVisible] =
+ useState(true);
+ // 닫힌 설정 카드는 레이어가 완전히 해제된 뒤 다시 체크될 때만 자동으로 연다.
+ const floodLayerCheckedRef = useRef(false);
+ // 체크 등급 변경과 연도 필터 변경을 구분해 연도 변경 때만 기존 선택 상세를 정리한다.
+ const floodYearFilterSignatureRef = useRef("");
const [isExmnPplChecked, setIsExmnPplChecked] = useState(false);
const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false);
const [exmnPplFilters, setExmnPplFilters] = useState(() => ({
@@ -3463,7 +3490,10 @@ const Map = () => {
selectedWaterRiskMapView?.riskStatus ??
selectedWaterRiskResultRow?.riskStatus ??
"NORMAL",
- [selectedWaterRiskMapView?.riskStatus, selectedWaterRiskResultRow?.riskStatus],
+ [
+ selectedWaterRiskMapView?.riskStatus,
+ selectedWaterRiskResultRow?.riskStatus,
+ ],
);
/** 선택 영역 오른쪽에 중점관리지역 강우·침수 요약카드를 배치한다. */
@@ -3521,7 +3551,8 @@ const Map = () => {
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;
+ if (!map || !/^\d{5}$/.test(sggCd) || !/^\d{12}$/.test(analysisDateTime))
+ return;
removeFloodPredictionGridLayer();
try {
const response = await getRawJson(
@@ -3529,29 +3560,74 @@ const Map = () => {
);
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;
+ 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 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;";
+ 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.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 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.textContent = value; val.style.cssText = "font-weight:400; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; });
+ [
+ [
+ "분석시각",
+ 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.textContent = value;
+ val.style.cssText =
+ "font-weight:400; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;";
+ });
container.append(close, table);
- const overlay = new Overlay({ element: container, positioning: "center-center", stopEvent: true });
+ 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);
@@ -3682,7 +3758,9 @@ const Map = () => {
: [];
const sggCd = String(data.payload?.sggCd ?? "").trim();
const frequency = String(data.payload?.sbmrgFrqExpln ?? "").trim();
- const analysisDateTime = String(data.payload?.analysisDateTime ?? "").trim();
+ const analysisDateTime = String(
+ data.payload?.analysisDateTime ?? "",
+ ).trim();
const map = gisMaps.current;
const select = GisApp.Module.select as any;
if (
@@ -6147,18 +6225,24 @@ const Map = () => {
try {
setIsAgedPipeListLoading(true);
setAgedPipeDownloadProgressText("Excel 파일을 준비하고 있습니다.");
- await downloadAgedPipeExcel(appliedAgedPipeFilters, (job) => {
- if (job.status === "QUEUED") {
- setAgedPipeDownloadProgressText("Excel 다운로드 작업이 대기 중입니다.");
- return;
- }
+ await downloadAgedPipeExcel(
+ appliedAgedPipeFilters,
+ (job) => {
+ if (job.status === "QUEUED") {
+ setAgedPipeDownloadProgressText(
+ "Excel 다운로드 작업이 대기 중입니다.",
+ );
+ return;
+ }
- if (job.status === "RUNNING") {
- setAgedPipeDownloadProgressText(
- `Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
- );
- }
- }, agedPipeTotalCount);
+ if (job.status === "RUNNING") {
+ setAgedPipeDownloadProgressText(
+ `Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
+ );
+ }
+ },
+ agedPipeTotalCount,
+ );
setAgedPipeDownloadProgressText("");
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
@@ -6343,10 +6427,33 @@ const Map = () => {
WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")),
);
setIsAgedPipeChecked(agedPipeChecked);
- const fldngHstryChecked = checkedNodesData.some((node) =>
- isFldngHstryNode(node),
+ const floodWtlvChecked = checkedNodesData.some((node) =>
+ isFloodHistoryWaterLevelNode(node),
+ );
+ const checkedFloodDepthNodes = checkedNodesData.filter((node) =>
+ isFloodHistoryDepthNode(node),
+ );
+ const checkedDepthLayerKeys = Array.from(
+ new Set(
+ checkedFloodDepthNodes
+ .map((node) => String(node?.data?.lyrSn ?? node?.id ?? "").trim())
+ .filter(Boolean),
+ ),
+ ).sort((left, right) => Number(left) - Number(right));
+ const floodDepthChecked = checkedDepthLayerKeys.length > 0;
+ const fldngHstryChecked = floodWtlvChecked || floodDepthChecked;
+ setIsFloodWtlvChecked(floodWtlvChecked);
+ setIsFloodDepthChecked(floodDepthChecked);
+ setCheckedFloodDepthLayerKeys((currentKeys) =>
+ currentKeys.join(",") === checkedDepthLayerKeys.join(",")
+ ? currentKeys
+ : checkedDepthLayerKeys,
);
setIsFldngHstryChecked(fldngHstryChecked);
+ if (fldngHstryChecked && !floodLayerCheckedRef.current) {
+ setIsFloodYearSettingVisible(true);
+ }
+ floodLayerCheckedRef.current = fldngHstryChecked;
if (!fldngHstryChecked) {
clearFldngHstrySelection();
}
@@ -6365,6 +6472,7 @@ const Map = () => {
setActiveLayerSettingOrder((currentOrder) => {
const activeKeys = [
waterDepthChecked ? "waterDepthLegend" : null,
+ fldngHstryChecked ? "fldngHstry" : null,
sinkholeChecked ? "sinkhole" : null,
agedPipeChecked ? "agedPipe" : null,
exmnPplChecked ? "exmnPpl" : null,
@@ -6522,6 +6630,23 @@ const Map = () => {
});
}
+ if (isFldngHstryChecked && isFloodYearSettingVisible) {
+ nextItems.push({
+ key: "fldngHstry",
+ label: "침수흔적도",
+ order: resolveSettingOrder("fldngHstry", 9997),
+ content: (
+ setIsFloodYearSettingVisible(false)}
+ />
+ ),
+ });
+ }
+
if (isSinkholeChecked) {
nextItems.push({
key: "sinkhole",
@@ -6603,9 +6728,13 @@ const Map = () => {
exmnMnhlFilters,
exmnPplFilters,
handleApplyAgedPipeFilters,
+ floodYearRange,
isAgedPipeChecked,
isExmnMnhlChecked,
isExmnPplChecked,
+ isFldngHstryChecked,
+ isFloodYearFilterEnabled,
+ isFloodYearSettingVisible,
isSinkholeChecked,
isWaterDepthLayerActive,
lastSinkholeSyncSummary,
@@ -6732,39 +6861,173 @@ const Map = () => {
if (!mapReady || !gisMaps.current) return;
const core = GisApp.Module.core as any;
- core?.deleteLayer?.(FLDNG_HSTRY_WFS_LAYER_ID);
+ const yearCql = buildFloodHistoryYearCql(
+ floodYearRange,
+ isFloodYearFilterEnabled,
+ );
+ const yearFilterSignature = `${floodYearRange.startYear}:${floodYearRange.endYear}:${isFloodYearFilterEnabled}`;
+ if (
+ floodYearFilterSignatureRef.current &&
+ floodYearFilterSignatureRef.current !== yearFilterSignature
+ ) {
+ clearFldngHstrySelection();
+ }
+ floodYearFilterSignatureRef.current = yearFilterSignature;
- if (!isFldngHstryChecked) return;
-
- const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find(
- (item: any) =>
- item?.lyrTyNm === FLOOD_HISTORY_LAYER_TYPE && item?.actlLyrYn === "Y",
- ) as any;
-
- if (!baseLayerCode) return;
-
- const layerCode = {
- ...baseLayerCode,
- id: FLDNG_HSTRY_WFS_LAYER_ID,
- lyrSn: FLDNG_HSTRY_WFS_LAYER_ID,
- lyrNm: FLOOD_HISTORY_LAYER_NAME,
- lyrTyNm: FLOOD_HISTORY_LAYER_TYPE,
- actlLyrYn: "Y",
- exprsYn: "Y",
- clickPsblYn: "Y",
- cmncNm: "bbox",
- srvcNm: "wfs",
+ /** 기준 레이어 필터를 보존하면서 선택한 침수년도 조건을 결합한다. */
+ const resolveFloodFilter = (baseLayerCode: any) => {
+ const baseFilter = String(baseLayerCode?.flterSql ?? "").trim();
+ return [baseFilter && `(${baseFilter})`, yearCql]
+ .filter(Boolean)
+ .join(" AND ");
};
- const layer = core?.addWfsLayer?.(layerCode);
- layer?.set?.("id", FLDNG_HSTRY_WFS_LAYER_ID);
- layer?.set?.("lyrNm", FLOOD_HISTORY_LAYER_NAME);
- layer?.set?.("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE);
+ type FloodLayerSpec = {
+ id: string;
+ layerType: string;
+ layerName: string;
+ baseLayerCode: any;
+ filterSql: string;
+ };
+ const desiredLayers: FloodLayerSpec[] = [];
+ const layerCodes = Object.values(GisApp.layerCode ?? {}) as any[];
+ if (isFloodWtlvChecked) {
+ const baseLayerCode = layerCodes.find(
+ (item) =>
+ item?.lyrTyNm === FLOOD_HISTORY_LAYER_TYPE && item?.actlLyrYn === "Y",
+ );
+ if (baseLayerCode) {
+ desiredLayers.push({
+ id: FLDNG_HSTRY_WFS_LAYER_ID,
+ layerType: FLOOD_HISTORY_LAYER_TYPE,
+ layerName: baseLayerCode?.lyrNm || FLOOD_HISTORY_LAYER_NAME,
+ baseLayerCode,
+ filterSql: resolveFloodFilter(baseLayerCode),
+ });
+ }
+ }
+
+ if (isFloodDepthChecked) {
+ const selectedDepthLayerKeys = new Set(checkedFloodDepthLayerKeys);
+ layerCodes
+ .filter((item) => {
+ const layerKey = String(item?.lyrSn ?? item?.id ?? "").trim();
+ return (
+ item?.lyrTyNm === FLOOD_HISTORY_DEPTH_LAYER_TYPE &&
+ item?.actlLyrYn === "Y" &&
+ selectedDepthLayerKeys.has(layerKey)
+ );
+ })
+ .forEach((baseLayerCode) => {
+ const layerKey = String(
+ baseLayerCode?.lyrSn ?? baseLayerCode?.id,
+ ).trim();
+ desiredLayers.push({
+ id: `${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_${layerKey}`,
+ layerType: FLOOD_HISTORY_DEPTH_LAYER_TYPE,
+ layerName: baseLayerCode?.lyrNm || FLOOD_HISTORY_DEPTH_LAYER_NAME,
+ baseLayerCode,
+ filterSql: resolveFloodFilter(baseLayerCode),
+ });
+ });
+ }
+
+ const desiredLayerIds = new Set(desiredLayers.map((item) => item.id));
+ const mapLayers = gisMaps.current?.getLayers?.()?.getArray?.() ?? [];
+ const isManagedFloodLayerId = (id: string) =>
+ id === FLDNG_HSTRY_WFS_LAYER_ID ||
+ id.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`);
+
+ // 체크 해제된 등급만 삭제하고, 계속 체크된 등급은 지도에 그대로 유지한다.
+ mapLayers.forEach((mapLayer: any) => {
+ const layerId = String(mapLayer?.get?.("id") ?? "");
+ if (isManagedFloodLayerId(layerId) && !desiredLayerIds.has(layerId)) {
+ core?.deleteLayer?.(layerId);
+ }
+ });
+
+ desiredLayers.forEach((spec) => {
+ const filterSignature = `${spec.layerType}|${spec.filterSql}`;
+ // 공통 getLayerById는 ID를 Number로 비교하므로 문자열 ID인 필터 레이어를 찾지 못한다.
+ // 지도 컬렉션에서 문자열 ID를 직접 비교해 기존 레이어와 WFS source를 재사용한다.
+ const existingLayer = gisMaps.current
+ ?.getLayers?.()
+ ?.getArray?.()
+ ?.find(
+ (mapLayer: any) => String(mapLayer?.get?.("id") ?? "") === spec.id,
+ );
+ if (existingLayer?.get?.("floodFilterSignature") === filterSignature) {
+ existingLayer?.setVisible?.(true);
+ return;
+ }
+
+ // 연도나 적용 여부가 달라진 레이어만 교체하고, 동일 필터 레이어는 재사용한다.
+ core?.deleteLayer?.(spec.id);
+ const layerCode = {
+ ...spec.baseLayerCode,
+ id: spec.id,
+ lyrSn: spec.id,
+ lyrNm: spec.layerName,
+ lyrTyNm: spec.layerType,
+ actlLyrYn: "Y",
+ exprsYn: "Y",
+ clickPsblYn: "Y",
+ cmncNm: "bbox",
+ srvcNm: "wfs",
+ flterSql: spec.filterSql,
+ };
+ const layer = core?.addWfsLayer?.(layerCode);
+ layer?.set?.("id", spec.id);
+ layer?.set?.("lyrNm", spec.layerName);
+ layer?.set?.("lyrTyNm", spec.layerType);
+ layer?.set?.("floodFilterSignature", filterSignature);
+ });
+
+ // 전용 필터 레이어가 담당하는 유형의 원본 레이어만 숨겨 중복 표출을 막는다.
+ const activeFilteredTypes = new Set(
+ desiredLayers.map((item) => item.layerType),
+ );
+ gisMaps.current
+ ?.getLayers?.()
+ ?.getArray?.()
+ ?.forEach((mapLayer: any) => {
+ const layerId = String(mapLayer?.get?.("id") ?? "");
+ const layerType = String(mapLayer?.get?.("lyrTyNm") ?? "");
+ if (
+ activeFilteredTypes.has(layerType) &&
+ !isManagedFloodLayerId(layerId)
+ ) {
+ mapLayer?.setVisible?.(false);
+ }
+ });
+ }, [
+ checkedFloodDepthLayerKeys,
+ clearFldngHstrySelection,
+ floodYearRange,
+ isFldngHstryChecked,
+ isFloodDepthChecked,
+ isFloodWtlvChecked,
+ isFloodYearFilterEnabled,
+ mapReady,
+ ]);
+
+ // 페이지가 닫힐 때만 관리 대상 필터 레이어 전체를 정리한다.
+ useEffect(() => {
return () => {
- core?.deleteLayer?.(FLDNG_HSTRY_WFS_LAYER_ID);
+ const map = gisMaps.current;
+ const layers = [...(map?.getLayers?.()?.getArray?.() ?? [])];
+ layers.forEach((layer: any) => {
+ const layerId = String(layer?.get?.("id") ?? "");
+ if (
+ layerId === FLDNG_HSTRY_WFS_LAYER_ID ||
+ layerId.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`)
+ ) {
+ map?.removeLayer?.(layer);
+ }
+ });
};
- }, [isFldngHstryChecked, mapReady]);
+ }, []);
const openAnalysisPopup = useCallback(
(popupKey: WindowPopupKey) => {
@@ -8936,15 +9199,11 @@ const Map = () => {
: "bg-[#e8f6f4] text-[#0f766e]"
}`}
>
- {buildWaterRiskStatusLabel(
- selectedWaterRiskDisplayStatus,
- )}
+ {buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
-
- GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면
-
+
GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면
위험, 없으면{" "}
정상
diff --git a/apps/sn/app/store/useOnsemiroAddrStore.ts b/apps/sn/app/store/useOnsemiroAddrStore.ts
index f48f02af1..3702c1e8f 100644
--- a/apps/sn/app/store/useOnsemiroAddrStore.ts
+++ b/apps/sn/app/store/useOnsemiroAddrStore.ts
@@ -111,6 +111,22 @@ const loadSelectOptions = async (url: string) => {
.filter((item): item is SelectDataType => Boolean(item));
};
+/** ONSEMIRO 환경청 응답을 코드/환경청명 기준으로 SelectBox 옵션으로 변환한다. */
+const loadGovAreaOptions = async () => {
+ const response = await getRawJson(
+ "/sewerage-service/api/v1/onsemiro/addr/govArea",
+ );
+ return toResponseList(response)
+ .map((item) => {
+ if (!item || typeof item !== "object") return null;
+ const { lclgvrnmntCd, lclgvrnmntNm } = item as Record;
+ const code = String(lclgvrnmntCd ?? "").trim();
+ const name = String(lclgvrnmntNm ?? "").trim();
+ return code && name ? { text: name, val: code } : null;
+ })
+ .filter((item): item is SelectDataType => Boolean(item));
+};
+
/** ONSEMIRO 환경청·시도·시군구 선택목록을 공유하는 상태 저장소다. */
const useOnsemiroAddrStore = create((set) => ({
govArea: [],
@@ -118,9 +134,7 @@ const useOnsemiroAddrStore = create((set) => ({
sgg: [],
/** 환경청 선택목록을 조회해 한글명 순으로 저장한다. */
fetchGovArea: async () => {
- const govArea = await loadSelectOptions(
- "/sewerage-service/api/v1/onsemiro/addr/govArea",
- );
+ const govArea = await loadGovAreaOptions();
set({
govArea: govArea.sort((left, right) =>
left.text.localeCompare(right.text, "ko"),