diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index c053ed707..f2ec117f5 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -17,10 +17,9 @@ "use client"; -import Gis from "@common/business_components/ui/map/Gis"; -import { getData, getRawJson } from "../../components/lib/api"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import { usePathname } from "next/navigation"; + import GeoJSON from "ol/format/GeoJSON.js"; import Point from "ol/geom/Point.js"; import VectorLayer from "ol/layer/Vector.js"; @@ -28,32 +27,39 @@ import { unByKey } from "ol/Observable.js"; import VectorSource from "ol/source/Vector.js"; import Stroke from "ol/style/Stroke.js"; import Style from "ol/style/Style.js"; -import SearchModal from "./searchModal/searchModal"; -import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; -import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth"; + +import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import Gis from "@common/business_components/ui/map/Gis"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; import { Button } from "@common/components"; -import useSearchModalStore from "@/store/useSearchModalStore"; -import useStore from "@/store/useStore"; -import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; + +import { getData, getRawJson } from "../../components/lib/api"; + import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel"; import { + AGED_PIPE_DEFAULT_FILTERS, AGED_PIPE_EXCEL_HEADER, - AGED_PIPE_MIN_ZOOM, AGED_PIPE_LAYER_TYPE, + AGED_PIPE_MIN_ZOOM, AGED_PIPE_PAGE_SIZE, + type AgedPipeFilters, + type AgedPipeRow, buildAgedPipeCqlFilters, isAgedPipeLayerNode, matchesAgedPipeFilters, normalizeAgedPipeFilters, toDisplayPipeRow, validateElapsedYears, - type AgedPipeFilters, - type AgedPipeRow, - AGED_PIPE_DEFAULT_FILTERS, } from "./components/agedPipe/agedPipeUtils"; -import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; +import SearchModal from "./searchModal/searchModal"; +import { WATER_DEPTH_LAYER_TYPES,WaterDepth } from "./waterDepth/waterDepth"; + +import useSearchModalStore from "@/store/useSearchModalStore"; +import useStore from "@/store/useStore"; const AGED_PIPE_CONTROL_NODE_ID = "SN_AGED_PIPE"; const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT"; @@ -70,18 +76,27 @@ const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [ // 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다. const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction"; const FLOW_DIRECTION_LAYER_NAME = "유하방향"; +const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; const FLOW_DIRECTION_SRS = "EPSG:5179"; const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13; -const FLOW_DIRECTION_DEFAULT_LIMIT = 5000; const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)"; +const FLOW_DIRECTION_NETWORK_CORRECTION_COLOR = "rgba(0, 168, 132, 0.95)"; const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35; -const FLOW_DIRECTION_CACHE_MAX_FEATURES = 10000; type FlowDirectionUsageStyle = { color: string; lineColor: string; }; +type FlowDirectionMode = "PIPE_START_END" | "PIPE_DEPTH"; + +// /map은 관로 깊이 기준, /map2는 관로 원천 geometry 시점->종점 기준만 유지한다. 두 화면 모두 BBOX 조회만 사용한다. +const getFlowDirectionModeByPathname = (pathname: string | null): FlowDirectionMode => { + const normalizedPathname = String(pathname ?? "").replace(/\/$/, ""); + if (normalizedPathname.endsWith("/map2")) return "PIPE_START_END"; + return "PIPE_DEPTH"; +}; + const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [120, 120, 120, 120]; const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [50, 50, 50, 50]; @@ -132,15 +147,19 @@ const normalizeFlowDirectionLayerInfos = (data: any[]): any[] => { }; // 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다. +const getFiniteNumber = (value: unknown) => { + if (value === null || value === undefined || String(value).trim() === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + const getFlowDirectionMinZoom = (layerInfo: any) => { - const minZoom = Number(layerInfo?.minViewSize ?? layerInfo?.minZoom); - return Number.isFinite(minZoom) ? minZoom : FLOW_DIRECTION_DEFAULT_MIN_ZOOM; + return getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom) ?? FLOW_DIRECTION_DEFAULT_MIN_ZOOM; }; // 유하방향 화살표는 관로 위에 얹는 보조 레이어라 레이어 설정의 drawOrdr를 우선 사용하고 없으면 높은 zIndex를 준다. const getFlowDirectionZIndex = (layerInfo: any) => { - const drawOrder = Number(layerInfo?.drawOrdr); - return Number.isFinite(drawOrder) ? drawOrder : 9998; + return getFiniteNumber(layerInfo?.drawOrdr) ?? 9998; }; // BE 응답이 FeatureCollection 그대로 오거나 공통 응답 wrapper의 data 안에 들어오는 두 계약을 모두 처리한다. @@ -357,8 +376,19 @@ const createFlowDirectionStyle = ( const usageCode = String(feature.get("usgCd") ?? feature.get("usg_cd") ?? "").trim(); const usageStyle = usageStyleMap[usageCode]; const baseColor = usageStyle?.color ?? FLOW_DIRECTION_DEFAULT_COLOR; + const isNetworkCorrected = String( + feature.get("networkCorrectionYn") ?? + feature.get("network_correction_yn") ?? + "", + ).toUpperCase() === "Y"; - const styleByConfidence: Record = { + const styleByConfidence: Record = { + HIGH: { + color: baseColor, + lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42), + scale: 1, + width: 1.3, + }, MEDIUM: { color: baseColor, lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42), @@ -372,12 +402,21 @@ const createFlowDirectionStyle = ( width: 1.1, }, }; - const style = styleByConfidence[confidence] ?? styleByConfidence.LOW!; + const style = isNetworkCorrected + ? { + color: FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, + lineColor: withAlpha(FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, 0.5), + scale: 1.08, + width: 2, + lineDash: [8, 4], + } + : styleByConfidence[confidence] ?? styleByConfidence.LOW!; const styles = [ new Style({ stroke: new Stroke({ color: style.lineColor, width: style.width, + lineDash: style.lineDash, lineCap: "round", lineJoin: "round", }), @@ -426,8 +465,8 @@ const extractUsageCodesFromLayerInfo = (layerInfo: any) => { // 현재 줌에서 실제 표출 가능한 관로 레이어인지 확인해 숨겨진 관종의 유하방향 요청을 막는다. const isLayerVisibleAtZoom = (layerInfo: any, zoom: number) => { - const minZoom = Number(layerInfo?.minViewSize ?? layerInfo?.minZoom); - return !Number.isFinite(minZoom) || zoom >= minZoom; + const minZoom = getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom); + return minZoom === null || zoom >= minZoom; }; // 현재 체크되어 화면에 보이는 관로 레이어의 관종 코드와 선 색상을 유하방향 조회/스타일 기준으로 변환한다. @@ -437,6 +476,7 @@ const extractCheckedPipeUsageStyles = (checkedNodesData: any[], zoom: number) => checkedNodesData.forEach((node) => { const data = node?.data ?? node; if (!data || isFlowDirectionLayerInfo(data)) return; + if (getLayerTypeName(data) !== FLOW_DIRECTION_PIPE_LAYER_TYPE) return; if (!isLayerVisibleAtZoom(data, zoom)) return; const usageCodes = extractUsageCodesFromLayerInfo(data); @@ -498,6 +538,8 @@ const appendAgedPipeControlNode = (data: any[]) => { }; const Map = () => { + const pathname = usePathname(); + const flowDirectionMode = useMemo(() => getFlowDirectionModeByPathname(pathname), [pathname]); const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore(); const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 @@ -541,9 +583,13 @@ const Map = () => { // 같은 관로가 겹치는 BBOX에서 반복 조회될 수 있으므로 고유키와 삽입 순서를 캐시로 관리한다. const flowDirectionFeatureKeySetRef = useRef>(new Set()); const flowDirectionFeatureKeyQueueRef = useRef([]); + // 관종 체크 변경 시 다른 관종 유하방향을 유지하기 위해 현재 source에 남겨둘 관종 코드를 별도로 추적한다. + const flowDirectionVisibleUsageCodesRef = useRef>(new Set()); + // 관종 체크 변경으로 새로 켜진 usgCd만 다음 조회 대상으로 넘겨 기존 관종 재요청을 막는다. + const flowDirectionSelectionRequestUsageCodesRef = useRef(null); // 체크 해제나 재조회 중 늦게 도착한 이전 API 응답이 레이어를 다시 그리지 못하도록 요청 순번을 관리한다. const flowDirectionRequestSeqRef = useRef(0); - // 지도 이동이 연속으로 발생하면 이전 bbox 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다. + // 지도 이동이 연속으로 발생하면 이전 유하방향 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다. const flowDirectionAbortControllerRef = useRef(null); // 같은 장애 상황에서 alert가 반복 노출되지 않도록 실패 안내 여부를 기억한다. const flowDirectionFailureAlertedRef = useRef(false); @@ -621,6 +667,7 @@ const Map = () => { flowDirectionSourceRef.current = null; flowDirectionFeatureKeySetRef.current.clear(); flowDirectionFeatureKeyQueueRef.current = []; + flowDirectionVisibleUsageCodesRef.current.clear(); }, []); // 유하방향 cache/source만 비워 관종 변경처럼 조건이 달라진 경우 이전 조건의 피처가 남지 않게 한다. @@ -628,8 +675,46 @@ const Map = () => { flowDirectionSourceRef.current?.clear(); flowDirectionFeatureKeySetRef.current.clear(); flowDirectionFeatureKeyQueueRef.current = []; + flowDirectionVisibleUsageCodesRef.current.clear(); }, []); + // 체크 해제된 관종의 유하방향 feature/cache만 제거하고, 새로 체크된 관종 코드만 다음 요청 대상으로 돌려준다. + const retainFlowDirectionUsageCodes = useCallback((usageCodes: string[]) => { + const nextUsageCodes = new Set(usageCodes); + const addedUsageCodes = usageCodes.filter((usageCode) => + !flowDirectionVisibleUsageCodesRef.current.has(usageCode), + ); + const source = flowDirectionSourceRef.current; + if (source) { + source.getFeatures().forEach((feature: any) => { + const usageCode = String(feature.get("usgCd") ?? feature.get("usg_cd") ?? "").trim(); + if (nextUsageCodes.has(usageCode)) return; + + const featureKey = feature.get("flowDirectionCacheKey"); + if (featureKey) { + flowDirectionFeatureKeySetRef.current.delete(String(featureKey)); + } + source.removeFeature(feature); + }); + flowDirectionFeatureKeyQueueRef.current = flowDirectionFeatureKeyQueueRef.current.filter((featureKey) => + flowDirectionFeatureKeySetRef.current.has(featureKey), + ); + } + flowDirectionVisibleUsageCodesRef.current = new Set( + Array.from(flowDirectionVisibleUsageCodesRef.current).filter((usageCode) => + nextUsageCodes.has(usageCode), + ), + ); + return addedUsageCodes; + }, []); + + useEffect(() => { + if (!flowDirectionCheckedRef.current) return; + clearFlowDirectionFeatureCache(); + flowDirectionSelectionRequestUsageCodesRef.current = null; + setFlowDirectionSelectionVersion((version) => version + 1); + }, [clearFlowDirectionFeatureCache, flowDirectionMode]); + // 유하방향 레이어는 하나만 유지하고 source에 피처를 누적한다. const ensureFlowDirectionLayer = useCallback((layerInfo: any) => { const map = gisMaps.current; @@ -695,23 +780,11 @@ const Map = () => { source.addFeatures(nextFeatures); } - while (flowDirectionFeatureKeyQueueRef.current.length > FLOW_DIRECTION_CACHE_MAX_FEATURES) { - const staleKey = flowDirectionFeatureKeyQueueRef.current.shift(); - if (!staleKey) break; - - const staleFeature = source.getFeatures().find((feature: any) => - feature.get("flowDirectionCacheKey") === staleKey, - ); - if (staleFeature) { - source.removeFeature(staleFeature); - } - flowDirectionFeatureKeySetRef.current.delete(staleKey); - } }, [ensureFlowDirectionLayer]); - // 현재 지도 BBOX와 체크된 관종 필터로 유하방향 GeoJSON을 조회하고, 최신 요청만 지도에 반영한다. + // 현재 지도 BBOX와 체크된 관종별 필터로 유하방향 GeoJSON을 각각 조회하고, 최신 요청만 지도에 반영한다. const loadFlowDirectionLayer = useCallback( - async (layerInfo: any) => { + async (layerInfo: any, requestOnlyUsageCodes?: string[]) => { const map = gisMaps.current; if (!map) return; @@ -739,45 +812,113 @@ const Map = () => { removeFlowDirectionLayer(); return; } + retainFlowDirectionUsageCodes(usageCodes); + + const usageCodeSet = new Set(usageCodes); + const requestUsageCodes = requestOnlyUsageCodes + ? requestOnlyUsageCodes.filter((usageCode) => usageCodeSet.has(usageCode)) + : usageCodes; + const isPartialUsageRequest = Array.isArray(requestOnlyUsageCodes); + if (requestUsageCodes.length === 0) { + if (flowDirectionAbortControllerRef.current === abortController) { + flowDirectionAbortControllerRef.current = null; + } + return; + } const screenExtent = view.calculateExtent(size); const requestExtent = expandFlowDirectionExtent(screenExtent); const screenBbox = formatFlowDirectionBbox(screenExtent); const requestBbox = formatFlowDirectionBbox(requestExtent); - const params = new URLSearchParams({ - bbox: requestBbox, + const baseParams = new URLSearchParams({ srs: FLOW_DIRECTION_SRS, - limit: String(FLOW_DIRECTION_DEFAULT_LIMIT), - usgCd: usageCodes.join(","), + directionMode: flowDirectionMode, + searchStrategy: "BBOX", + bbox: requestBbox, }); try { - const payload = await getRawJson( - `/sewerage-service/api/v1/map/flowDirectionGeometry?${params.toString()}`, - { signal: abortController.signal }, - ); + const settledResponses = await Promise.allSettled(requestUsageCodes.map(async (usageCode) => { + const params = new URLSearchParams(baseParams); + params.set("usgCd", usageCode); + const payload = await getRawJson( + `/sewerage-service/api/v1/map/flowDirectionGeometry?${params.toString()}`, + { signal: abortController.signal }, + ); + return { usageCode, featureCollection: getFeatureCollection(payload) }; + })); if (requestSeq !== flowDirectionRequestSeqRef.current) return; + if (abortController.signal.aborted) return; - const featureCollection = getFeatureCollection(payload); - renderFlowDirectionLayer(featureCollection, layerInfo, usageStyleMap); + const responses = settledResponses + .filter((result): result is PromiseFulfilledResult<{ usageCode: string; featureCollection: any }> => + result.status === "fulfilled", + ) + .map((result) => result.value); + const failedResponses = settledResponses.filter((result) => result.status === "rejected"); + + if (responses.length === 0) { + if (!isPartialUsageRequest) { + removeFlowDirectionLayer(); + } + throw failedResponses[0]?.reason ?? new Error("유하방향 관종별 조회 결과가 없습니다."); + } + + if (failedResponses.length > 0) { + console.error("유하방향 일부 관종 조회 실패:", failedResponses.map((result) => result.reason)); + if (!flowDirectionFailureAlertedRef.current) { + flowDirectionFailureAlertedRef.current = true; + } + } + + responses.forEach(({ usageCode, featureCollection }) => { + renderFlowDirectionLayer(featureCollection, layerInfo, usageStyleMap); + flowDirectionVisibleUsageCodesRef.current.add(usageCode); + }); + const returnedFeaturesByUsage = responses.reduce>( + (acc, { usageCode, featureCollection }) => { + acc[usageCode] = featureCollection.features?.length ?? 0; + return acc; + }, + {}, + ); + const networkCorrectedFeatures = responses.reduce((count, { featureCollection }) => { + const correctedCount = featureCollection.features?.filter((feature: any) => + String( + feature?.properties?.networkCorrectionYn ?? + feature?.properties?.network_correction_yn ?? + "", + ).toUpperCase() === "Y", + ).length ?? 0; + return count + correctedCount; + }, 0); (window as any).__SN_FLOW_DIRECTION_DEBUG__ = { screenBbox, requestBbox, requestSeq, zoom, + directionMode: flowDirectionMode, + searchStrategy: "BBOX", usageCodes, - returnedFeatures: featureCollection.features?.length ?? 0, + requestUsageCodes, + requestCount: responses.length, + returnedFeaturesByUsage, + returnedFeatures: Object.values(returnedFeaturesByUsage).reduce((sum, count) => sum + count, 0), + networkCorrectedFeatures, cachedFeatures: flowDirectionSourceRef.current?.getFeatures?.().length ?? 0, }; - flowDirectionFailureAlertedRef.current = false; + if (failedResponses.length === 0) { + flowDirectionFailureAlertedRef.current = false; + } } catch (error) { if (requestSeq !== flowDirectionRequestSeqRef.current) return; if (typeof error === "object" && error !== null && "name" in error && error.name === "AbortError") return; console.error("유하방향 레이어 조회 실패:", error); - removeFlowDirectionLayer(); + if (!isPartialUsageRequest) { + removeFlowDirectionLayer(); + } if (!flowDirectionFailureAlertedRef.current) { - alert("유하방향 정보를 불러오지 못했습니다."); flowDirectionFailureAlertedRef.current = true; } } finally { @@ -786,7 +927,7 @@ const Map = () => { } } }, - [removeFlowDirectionLayer, renderFlowDirectionLayer], + [flowDirectionMode, removeFlowDirectionLayer, renderFlowDirectionLayer, retainFlowDirectionUsageCodes], ); // SearchModal, GisContent 등에서 호출 @@ -1057,12 +1198,22 @@ const Map = () => { setIsFlowDirectionChecked(Boolean(nextFlowDirectionLayerInfo)); setFlowDirectionLayerInfo(nextFlowDirectionLayerInfo); if (nextFlowDirectionLayerInfo) { - clearFlowDirectionFeatureCache(); + const map = gisMaps.current; + const zoom = map?.getView?.()?.getZoom?.() ?? 0; + const nextUsageStyleMap = extractCheckedPipeUsageStyles(checkedNodesData, zoom); + const requestUsageCodes = retainFlowDirectionUsageCodes(Object.keys(nextUsageStyleMap)); + flowDirectionSelectionRequestUsageCodesRef.current = requestUsageCodes; + if (requestUsageCodes.length === 0) { + flowDirectionRequestSeqRef.current += 1; + flowDirectionAbortControllerRef.current?.abort(); + flowDirectionAbortControllerRef.current = null; + } setFlowDirectionSelectionVersion((version) => version + 1); } if (!nextFlowDirectionLayerInfo) { // 해제 직후 pending API 응답이 늦게 도착해도 renderFlowDirectionLayer가 실행되지 않도록 순번을 무효화한다. + flowDirectionSelectionRequestUsageCodesRef.current = null; flowDirectionRequestSeqRef.current += 1; flowDirectionAbortControllerRef.current?.abort(); flowDirectionAbortControllerRef.current = null; @@ -1072,7 +1223,7 @@ const Map = () => { handleWaterDepthVisibility(checkedIds, checkedNodesData); setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node))); - }, [clearFlowDirectionFeatureCache, handleWaterDepthVisibility, removeFlowDirectionLayer]); + }, [handleWaterDepthVisibility, removeFlowDirectionLayer, retainFlowDirectionUsageCodes]); const handleOpenLayerPopup = useCallback( (_layerNm: string, node?: any) => { @@ -1149,7 +1300,10 @@ const Map = () => { // 트리 체크 상태나 관종 레이어 선택이 바뀌면 유하방향 API vector layer를 추가/갱신/제거한다. if (isFlowDirectionChecked) { - loadFlowDirectionLayer(flowDirectionLayerInfo); + const requestUsageCodes = flowDirectionSelectionRequestUsageCodesRef.current; + flowDirectionSelectionRequestUsageCodesRef.current = null; + if (requestUsageCodes && requestUsageCodes.length === 0) return; + loadFlowDirectionLayer(flowDirectionLayerInfo, requestUsageCodes ?? undefined); } else { removeFlowDirectionLayer(); } diff --git a/apps/sn/app/(map-layout)/map2/page.tsx b/apps/sn/app/(map-layout)/map2/page.tsx new file mode 100644 index 000000000..972464ec9 --- /dev/null +++ b/apps/sn/app/(map-layout)/map2/page.tsx @@ -0,0 +1 @@ +export { default } from "../map/page";