diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx index 7398a4d70..dfba25f39 100644 --- a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx @@ -5,11 +5,8 @@ "use client"; import { useMemo, useState } from "react"; -import { - formatFloodArea, - formatFloodDepth, - type FldngHstryItem, -} from "./fldngHstryTypes"; +import type { FldngHstryItem } from "./fldngHstryTypes"; +import { formatFldngHstryArea, formatFldngHstryDepth } from "./fldngHstryUtils"; type Props = { detail: FldngHstryItem; @@ -54,8 +51,8 @@ export default function FldngHstryDetailPanel({ const yearRows = useMemo( () => [ - ["침수심", `${detail.floodDepthM.toFixed(2)} m`], - ["침수면적", formatFloodArea(detail.floodAreaHa)], + ["침수심", `${formatFldngHstryDepth(detail.floodDepthM)} m`], + ["침수면적", formatFldngHstryArea(detail.floodAreaHa)], ["침수년도", `${detail.floodYear}년`], ["등급", detail.gradeLabel], ["침수구역", detail.districtName], @@ -67,7 +64,7 @@ export default function FldngHstryDetailPanel({ () => [ ["침수재해명", detail.disasterName], - ["침수면적", formatFloodArea(detail.floodAreaHa)], + ["침수면적", formatFldngHstryArea(detail.floodAreaHa)], ["침수원인상세명", detail.causeDetailName], ["침수시작년월일", detail.startDate], ["침수종료년월일", detail.endDate], diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryFeatureClick.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryFeatureClick.tsx new file mode 100644 index 000000000..a379b5e40 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryFeatureClick.tsx @@ -0,0 +1,330 @@ +/** + * 화면명: 침수흔적위선 연계정보 + * 우측 정보 화면의 심선·위선 목록, 선택 상태와 화면 생명주기 캐시를 관리한다. + */ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import type Feature from "ol/Feature.js"; +import VectorSource from "ol/source/Vector.js"; + +import { loadFldngHstryDetail } from "./fldngHstryClient"; +import { + FLDNG_HSTRY_DEPTH_LAYER_TYPE, + FLDNG_HSTRY_LAYER_TYPE, + type FldngHstryDetailCacheValue, +} from "./fldngHstryTypes"; + +type FldngHstryGisLayer = { + searchLayer?: { getSource?: () => VectorSource | null } | null; + clearSearchLayer: (option: unknown) => void; + ensureSearchLayer: (option: unknown) => { + set: (key: string, value: unknown) => void; + setSource: (source: VectorSource) => void; + }; + showSearchResultsFromGeoJSON: ( + layerTypeName: string, + featureId: string, + ) => void | Promise; +}; + +const cloneFldngHstryFeature = (feature: Feature) => { + const clonedFeature = feature.clone(); + const featureId = feature.getId(); + if (featureId !== undefined) clonedFeature.setId(featureId); + return clonedFeature; +}; + +const displayCachedFldngHstryFeatures = ( + gisLayer: FldngHstryGisLayer, + layerTypeName: string, + features: Feature[], +) => { + const matched = Object.values(GisApp.layerCode ?? {}).find( + (item: any) => item?.lyrTyNm === layerTypeName, + ) as any; + if (!matched || features.length === 0) return; + + gisLayer.clearSearchLayer(matched); + const layer = gisLayer.ensureSearchLayer(matched); + layer.set("gobjTy", matched.gobjTy); + const clonedFeatures = features.map(cloneFldngHstryFeature); + layer.setSource(new VectorSource({ features: clonedFeatures })); + (GisApp.Module.select as any)?.selectFeature(clonedFeatures[0], false); +}; + +const showFldngHstryFeature = async ( + cache: Map>, + gisLayer: FldngHstryGisLayer, + layerTypeName: string, + featureId: string, +) => { + const cacheKey = `${layerTypeName}:${featureId}`; + const cachedRequest = cache.get(cacheKey); + if (cachedRequest) { + displayCachedFldngHstryFeatures( + gisLayer, + layerTypeName, + await cachedRequest, + ); + return; + } + + await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId); + const source = gisLayer.searchLayer?.getSource?.(); + if (!source) return; + + const featureRequest = new Promise((resolve, reject) => { + const resolveFeatures = () => { + const features = source.getFeatures(); + if (features.length > 0) { + resolve(features.map(cloneFldngHstryFeature)); + } else { + reject(new Error("침수흔적 위치 정보가 없습니다.")); + } + }; + const rejectFeatures = () => + reject(new Error("침수흔적 위치 조회에 실패했습니다.")); + + if (source.getFeatures().length > 0) { + resolveFeatures(); + return; + } + source.once("featuresloadend", resolveFeatures); + source.once("featuresloaderror", rejectFeatures); + }); + + cache.set(cacheKey, featureRequest); + try { + await featureRequest; + } catch (error) { + cache.delete(cacheKey); + console.warn(error); + } +}; + +export const useFldngHstryFeatureClick = () => { + const fldngHstryDetailCacheRef = useRef( + new Map>(), + ); + const fldngHstryFeatureCacheRef = useRef( + new Map>(), + ); + const [fldngHstryDepthRows, setFldngHstryDepthRows] = useState< + Record[] + >([]); + const [fldngHstryWtlvRows, setFldngHstryWtlvRows] = useState< + Record[] + >([]); + const [selectedFldngHstryDepthFid, setSelectedFldngHstryDepthFid] = useState< + string | null + >(null); + const [selectedFldngHstryWtlvFid, setSelectedFldngHstryWtlvFid] = useState< + string | null + >(null); + const fldngHstryDepthYearListRef = useRef(null); + const fldngHstryWtlvListRef = useRef(null); + + useEffect(() => { + const detailCache = fldngHstryDetailCacheRef.current; + const featureCache = fldngHstryFeatureCacheRef.current; + return () => { + detailCache.clear(); + featureCache.clear(); + }; + }, []); + + const resetFldngHstryFeatureClick = useCallback(() => { + setFldngHstryDepthRows([]); + setFldngHstryWtlvRows([]); + setSelectedFldngHstryDepthFid(null); + setSelectedFldngHstryWtlvFid(null); + }, []); + + const loadFldngHstryFeatureClickDetail = useCallback( + async (fldngHstryFid: string) => { + const cacheKey = `${FLDNG_HSTRY_LAYER_TYPE}:${fldngHstryFid}`; + let request = fldngHstryDetailCacheRef.current.get(cacheKey); + if (!request) { + request = loadFldngHstryDetail(fldngHstryFid); + fldngHstryDetailCacheRef.current.set(cacheKey, request); + } + + const detail = await request; + if (!detail.complete) fldngHstryDetailCacheRef.current.delete(cacheKey); + setFldngHstryDepthRows(detail.fldngHstryDepthRows); + setFldngHstryWtlvRows(detail.fldngHstryWtlvRows); + setSelectedFldngHstryDepthFid(null); + setSelectedFldngHstryWtlvFid(null); + return detail; + }, + [], + ); + + const headName = useMemo( + () => ( +
+ 기본정보 + {fldngHstryDepthRows.length > 0 && ( +
+ + 심선 + + +
+ {[...fldngHstryDepthRows] + .map((row, index) => ({ + row, + index, + year: String( + row.fldng_yr ?? row.fldngYr ?? row.yr ?? "", + ).trim(), + })) + .filter(({ year }) => Boolean(year)) + .sort((left, right) => { + const yearOrder = left.year.localeCompare( + right.year, + undefined, + { numeric: true }, + ); + return yearOrder !== 0 ? yearOrder : left.index - right.index; + }) + .map(({ row, index, year }) => { + const depthFid = String( + row.fldng_dpth_map_sn ?? row.fid ?? "", + ).trim(); + return ( + + ); + })} +
+ +
+ )} + {fldngHstryWtlvRows.length > 0 && ( +
+ + 위선 + + +
+ {fldngHstryWtlvRows.map((row, index) => { + const waterLevelFid = String(row.fid ?? "").trim(); + const disasterName = String( + row.fldng_dstr_nm ?? row.fldngDstrNm ?? "", + ).trim(); + return ( + + ); + })} +
+ +
+ )} +
+ ), + [ + fldngHstryDepthRows, + fldngHstryWtlvRows, + selectedFldngHstryDepthFid, + selectedFldngHstryWtlvFid, + ], + ); + + return { + hasFldngHstryLinkedFeatures: + fldngHstryDepthRows.length > 0 || fldngHstryWtlvRows.length > 0, + fldngHstryFeatureClickHeadName: headName, + loadFldngHstryFeatureClickDetail, + resetFldngHstryFeatureClick, + }; +}; diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryLayerSettingPanel.tsx index c5a8cd190..3f8998bf1 100644 --- a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryLayerSettingPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryLayerSettingPanel.tsx @@ -6,16 +6,16 @@ import { SelectBox } from "@common/business_components/ui"; import { - FLOOD_HISTORY_MIN_YEAR, - normalizeFloodHistoryYearRange, - type FloodHistoryYearRange, + FLDNG_HSTRY_MIN_YEAR, + type FldngHstryYearRange, } from "./fldngHstryTypes"; +import { normalizeFldngHstryYearRange } from "./fldngHstryUtils"; import LayerSettingCard from "../layerSetting/LayerSettingCard"; type Props = { - yearRange: FloodHistoryYearRange; + yearRange: FldngHstryYearRange; enabled: boolean; - onYearRangeChange: (yearRange: FloodHistoryYearRange) => void; + onYearRangeChange: (yearRange: FldngHstryYearRange) => void; onEnabledChange: (enabled: boolean) => void; onClose: () => void; }; @@ -28,10 +28,10 @@ export default function FldngHstryLayerSettingPanel({ onEnabledChange, onClose, }: Props) { - const normalizedRange = normalizeFloodHistoryYearRange(yearRange); + const normalizedRange = normalizeFldngHstryYearRange(yearRange); const currentYear = new Date().getFullYear(); const yearOptions = Array.from( - { length: currentYear - FLOOD_HISTORY_MIN_YEAR + 1 }, + { length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 }, (_, index) => String(currentYear - index), ); const startYearOptions = yearOptions @@ -43,9 +43,9 @@ export default function FldngHstryLayerSettingPanel({ const yearSelectClass = "!h-[26px] w-full !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground"; - const changeYear = (key: keyof FloodHistoryYearRange, value: string) => { + const changeYear = (key: keyof FldngHstryYearRange, value: string) => { onYearRangeChange( - normalizeFloodHistoryYearRange({ + normalizeFldngHstryYearRange({ ...normalizedRange, [key]: value, }), diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx index 43bd0d5f0..a82e6962a 100644 --- a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx @@ -17,22 +17,24 @@ import LoadingOverlay from "@/components/ui/LoadingBar"; import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; import { - FLOOD_HISTORY_LAYER_TYPE, + FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_DEFAULT_FILTERS, FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE, FLDNG_HSTRY_DOWNLOAD_HEADERS, FLDNG_HSTRY_LIST_HEADLIST, FLDNG_HSTRY_PAGE_SIZE, FLDNG_HSTRY_ROW_SELECT_MESSAGE, + type FldngHstryApiItem, + type FldngHstryFilters, + type FldngHstryTableRow, +} from "./fldngHstryTypes"; +import { attachFldngHstryGeometry, buildFldngHstryListQueryString, buildFldngHstryYearOptions, normalizeFldngHstryFilters, toFldngHstryTableRows, - type FldngHstryApiItem, - type FldngHstryFilters, - type FldngHstryTableRow, -} from "./fldngHstryTypes"; +} from "./fldngHstryUtils"; const unwrapPayload = (response: any): T => (response?.data ?? response) as T; @@ -50,8 +52,8 @@ const resolveOptionText = ( const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => { const fid = String(row.fid ?? "").trim(); - if (fid.startsWith(`${FLOOD_HISTORY_LAYER_TYPE}.`)) return fid; - if (fid) return `${FLOOD_HISTORY_LAYER_TYPE}.${fid}`; + if (fid.startsWith(`${FLDNG_HSTRY_LAYER_TYPE}.`)) return fid; + if (fid) return `${FLDNG_HSTRY_LAYER_TYPE}.${fid}`; return ""; }; @@ -123,7 +125,6 @@ export default function FldngHstryPopupPage() { const fldngYearOptions = useMemo(() => buildFldngHstryYearOptions(), []); - const sggOptions = useMemo( () => filters.ctpvCd === "0" @@ -157,11 +158,15 @@ export default function FldngHstryPopupPage() { ...hydrated, ctpvNm: resolveOptionText(hydrated.ctpvCd ?? "", ctpv as any, 2) || - String((hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "").trim() || + String( + (hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "", + ).trim() || String(hydrated.ctpvCd ?? "").trim(), sggNm: resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5) || - String((hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "").trim() || + String( + (hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "", + ).trim() || String(hydrated.sggCd ?? "").trim(), }; }); @@ -233,7 +238,7 @@ export default function FldngHstryPopupPage() { { type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE, payload: { - layerNm: FLOOD_HISTORY_LAYER_TYPE, + layerNm: FLDNG_HSTRY_LAYER_TYPE, fid: mapFeatureFid, }, }, @@ -262,8 +267,7 @@ export default function FldngHstryPopupPage() { try { const response = await fetchAllPages({ batchSize: FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE, - fetchPage: (page, size) => - fetchRows(page + 1, appliedFilters, size), + fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size), getItems: (page) => page.rows, getTotal: (page) => page.total, }); @@ -362,7 +366,9 @@ export default function FldngHstryPopupPage() {