From 37374cb754e7a9295acde2e791a9f283817b0f0b Mon Sep 17 00:00:00 2001 From: ljpark Date: Tue, 1 Sep 2026 15:38:25 +0900 Subject: [PATCH] =?UTF-8?q?sn:=20=EC=84=A0=ED=83=9D=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EC=84=A4=EC=A0=95=20-=20=EC=B9=A8=EC=88=98?= =?UTF-8?q?=ED=9D=94=EC=A0=81=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FldngHstryLayerSettingPanel.tsx | 116 ++++++ .../components/fldngHstry/fldngHstryTypes.ts | 71 +++- apps/sn/app/(map-layout)/map/page.tsx | 375 +++++++++++++++--- apps/sn/app/store/useOnsemiroAddrStore.ts | 20 +- 4 files changed, 519 insertions(+), 63 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryLayerSettingPanel.tsx 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"),