"use client"; import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react"; import GeoJSON from "ol/format/GeoJSON.js"; import VectorLayer from "ol/layer/Vector.js"; import VectorSource from "ol/source/Vector.js"; import Stroke from "ol/style/Stroke.js"; import Fill from "ol/style/Fill.js"; import Style from "ol/style/Style.js"; import { intersects as intersectsExtent } from "ol/extent"; import { getRawJson } from "@/components/lib/api"; import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; import { useAlertStore } from "@/shared/lib"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils"; import { INFTWT_RESULT_OVERLAY_TYPE, INFTWT_RESULT_ROW_SELECT_MESSAGE, INFTWT_RESULT_ROW_SELECT_STORAGE_KEY, type InftwtResultMapViewResponse, type InftwtResultRow, } from "./inftwtResultTypes"; type UseInftwtResultMapParams = { gisMaps: MutableRefObject; setFeatureData: (value: any) => void; setIsOpen: (value: boolean) => void; setIsSearchModalOpen: (value: boolean) => void; }; const INFTWT_ZONE_LAYER_ID = "SN_INFTWT_RESULT_ZONE_LAYER"; const INFTWT_PIPE_LAYER_ID = "SN_INFTWT_RESULT_PIPE_LAYER"; const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120]; const INFTWT_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH"; const zoneStyle = new Style({ fill: new Fill({ color: "rgba(16, 185, 129, 0.06)", }), stroke: new Stroke({ color: "rgba(5, 150, 105, 0.72)", width: 2, lineDash: [10, 8], }), zIndex: 19991, }); const buildPipeStyle = (hasOilq: boolean) => { const innerColor = hasOilq ? "rgba(236, 72, 153, 0.98)" : "rgba(14, 165, 233, 0.98)"; const haloWidth = hasOilq ? 11 : 5; const innerWidth = hasOilq ? 6 : 2.5; const dashPattern = hasOilq ? [18, 9] : undefined; return [ new Style({ stroke: new Stroke({ color: "rgba(15, 23, 42, 0.58)", width: hasOilq ? haloWidth + 4 : haloWidth + 2, lineCap: "round", lineJoin: "round", }), zIndex: hasOilq ? 20000 : 19999, }), new Style({ stroke: new Stroke({ color: "rgba(255, 255, 255, 0.98)", width: haloWidth, lineCap: "round", lineJoin: "round", }), zIndex: hasOilq ? 20001 : 20000, }), new Style({ stroke: new Stroke({ color: innerColor, width: innerWidth, lineCap: "round", lineJoin: "round", lineDash: dashPattern, }), zIndex: hasOilq ? 20002 : 20001, }), ]; }; const getFeatureCollection = (payload: any) => { if (payload?.type === "FeatureCollection") return payload; if (payload?.data?.type === "FeatureCollection") return payload.data; return { type: "FeatureCollection", features: [] }; }; const normalizeProjectionName = (value: string) => { const trimmed = String(value ?? "").trim(); if (!trimmed) return "EPSG:4326"; if (trimmed.startsWith("EPSG:")) return trimmed; const match = trimmed.match(/EPSG[:/]{1,2}(\d{4,5})/i) || trimmed.match(/(\d{4,5})$/); return match?.[1] ? `EPSG:${match[1]}` : trimmed; }; const resolveFeatureCollectionProjection = (payload: any) => { const featureCollection = getFeatureCollection(payload); const crsName = String(featureCollection?.crs?.properties?.name ?? "").trim(); return normalizeProjectionName(crsName || GisApp.TO_PROJ); }; const getOverlayFlag = (data: any) => String( data?.get?.("analysisOverlayType") ?? data?.values_?.analysisOverlayType ?? data?.analysisOverlayType ?? "", ).trim(); const getOverlayBasePplMngNo = (data: any) => String( data?.get?.("fcltMngNo") ?? data?.values_?.fcltMngNo ?? data?.values_?.fclt_mng_no ?? data?.fcltMngNo ?? data?.fclt_mng_no ?? "", ).trim(); const getOverlayAnalysisPplMngNo = (data: any) => String( data?.get?.("pplMngNo") ?? data?.values_?.pplMngNo ?? data?.pplMngNo ?? "", ).trim(); const getOverlayAnalysisSn = (data: any) => Number( data?.get?.("inftwtAnlsSn") ?? data?.values_?.inftwtAnlsSn ?? data?.inftwtAnlsSn ?? 0, ); const getOverlayAplcnYm = (data: any) => String( data?.get?.("aplcnYm") ?? data?.values_?.aplcnYm ?? data?.aplcnYm ?? "", ).trim(); const getOverlayAnalysisInfo = (data: any) => String( data?.get?.("analysisInfo") ?? data?.values_?.analysisInfo ?? data?.analysisInfo ?? "", ).trim(); const getFirstFeatureId = (payload: any) => { const featureCollection = getFeatureCollection(payload); const firstFeature = Array.isArray(featureCollection?.features) ? featureCollection.features[0] : null; return String(firstFeature?.id ?? "").trim(); }; const getOilqPipeExtent = (features: any[]) => { const oilqFeatures = features.filter((feature) => Boolean(feature?.get?.("hasOilq"))); if (oilqFeatures.length === 0) return null; let nextExtent: [number, number, number, number] | null = null; oilqFeatures.forEach((feature) => { const geometryExtent = feature?.getGeometry?.()?.getExtent?.(); if (!Array.isArray(geometryExtent) || geometryExtent.some((value: number) => !Number.isFinite(value))) { return; } if (!nextExtent) { nextExtent = [...geometryExtent] as [number, number, number, number]; return; } nextExtent = [ Math.min(nextExtent[0], geometryExtent[0]), Math.min(nextExtent[1], geometryExtent[1]), Math.max(nextExtent[2], geometryExtent[2]), Math.max(nextExtent[3], geometryExtent[3]), ]; }); return nextExtent; }; export const useInftwtResultMap = ({ gisMaps, setFeatureData, setIsOpen, setIsSearchModalOpen, }: UseInftwtResultMapParams) => { const alert = useAlertStore((state) => state.setMessage); const activeResultRowRef = useRef(null); const zoneLayerRef = useRef | null>(null); const zoneSourceRef = useRef(null); const pipeLayerRef = useRef | null>(null); const pipeSourceRef = useRef(null); const mapViewCacheRef = useRef>(new Map()); const [activeMapView, setActiveMapView] = useState(null); const clearLayers = useCallback(() => { zoneSourceRef.current?.clear?.(); pipeSourceRef.current?.clear?.(); zoneLayerRef.current?.changed?.(); pipeLayerRef.current?.changed?.(); gisMaps.current?.renderSync?.(); }, [gisMaps]); const ensureZoneLayer = useCallback(() => { const map = gisMaps.current; if (!map) return null; if (zoneLayerRef.current && zoneSourceRef.current) { return zoneSourceRef.current; } const source = new VectorSource(); const layer = new VectorLayer({ source, zIndex: 19991, }); layer.set("id", INFTWT_ZONE_LAYER_ID); layer.set("lyrNm", "移⑥엯?섎텇??泥섎━援ъ뿭"); layer.set("lyrTyNm", "tb_ms_swage_prcs_zone"); layer.set("clickPsblYn", "N"); layer.setVisible?.(true); map.addLayer(layer); zoneLayerRef.current = layer; zoneSourceRef.current = source; return source; }, [gisMaps]); const ensurePipeLayer = useCallback(() => { const map = gisMaps.current; if (!map) return null; if (pipeLayerRef.current && pipeSourceRef.current) { return pipeSourceRef.current; } const source = new VectorSource(); const layer = new VectorLayer({ source, zIndex: 19997, }); layer.set("id", INFTWT_PIPE_LAYER_ID); layer.set("lyrNm", "침입수분석 결과관로"); layer.set("lyrTyNm", "tb_ms_swage_ppl"); layer.set("clickPsblYn", "Y"); layer.setVisible?.(true); map.addLayer(layer); pipeLayerRef.current = layer; pipeSourceRef.current = source; return source; }, [gisMaps]); const fitToRenderedFeatures = useCallback( (mapView?: InftwtResultMapViewResponse | null) => { const map = gisMaps.current; const zoneExtent = zoneSourceRef.current?.getExtent?.(); const pipeExtent = pipeSourceRef.current?.getExtent?.(); const pipeFeatures = pipeSourceRef.current?.getFeatures?.() ?? []; const oilqPipeExtent = getOilqPipeExtent(pipeFeatures); const hasPipeFeatures = pipeFeatures.length > 0; const hasZoneFeatures = Boolean(zoneSourceRef.current?.getFeatures?.().length); const preferZone = mapView?.focusTargetType === "ZONE" && hasZoneFeatures; const zoneExtentIsFinite = Array.isArray(zoneExtent) && zoneExtent.every((value: number) => Number.isFinite(value)); const pipeExtentIsFinite = Array.isArray(pipeExtent) && pipeExtent.every((value: number) => Number.isFinite(value)); const hasPipeInsideZone = preferZone && zoneExtentIsFinite ? pipeFeatures.some((feature: any) => { const geometryExtent = feature?.getGeometry?.()?.getExtent?.(); return Array.isArray(geometryExtent) && geometryExtent.every((value: number) => Number.isFinite(value)) && intersectsExtent(zoneExtent as [number, number, number, number], geometryExtent as [number, number, number, number]); }) : false; const shouldUseZoneExtent = preferZone && (hasPipeInsideZone || !hasPipeFeatures); const extent = oilqPipeExtent ? oilqPipeExtent : shouldUseZoneExtent ? zoneExtent : hasPipeFeatures ? pipeExtent : hasZoneFeatures ? zoneExtent : null; if (!map || !extent || extent.some((value: number) => !Number.isFinite(value))) { return; } map.getView?.().fit?.(extent, { duration: 350, padding: DEFAULT_RESULT_FIT_PADDING, maxZoom: oilqPipeExtent ? 17 : shouldUseZoneExtent ? 15 : 16, constrainResolution: true, nearest: true, }); }, [gisMaps], ); const moveByRegionCodes = useCallback( (ctpvCd?: string, sggCd?: string) => { const normalizedSggCd = String(sggCd ?? "").trim(); if (normalizedSggCd && normalizedSggCd !== "0") { const sigunguCenter = getSigunguCenter(normalizedSggCd); if (sigunguCenter) { moveMapToCenter(gisMaps, sigunguCenter, 12); return true; } } const normalizedCtpvCd = String(ctpvCd ?? "").trim(); if (normalizedCtpvCd && normalizedCtpvCd !== "0") { const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)]; if (sidoCenterCoordinate) { moveMapToCenter(gisMaps, sidoCenterCoordinate, 11); return true; } } return false; }, [gisMaps], ); const moveToStableFocus = useCallback( async (mapView: InftwtResultMapViewResponse) => { if ((mapView.oilqPipeCount ?? 0) > 0) { fitToRenderedFeatures(mapView); return; } const activeRow = activeResultRowRef.current; const movedByRegion = moveByRegionCodes( activeRow?.ctpvCd ?? mapView.analysis?.ctpvCd, activeRow?.sggCd ?? mapView.analysis?.sggCd, ); if (movedByRegion) return; fitToRenderedFeatures(mapView); }, [fitToRenderedFeatures, moveByRegionCodes], ); const renderMapView = useCallback( async (mapView: InftwtResultMapViewResponse) => { const zoneSource = ensureZoneLayer(); const pipeSource = ensurePipeLayer(); if (!zoneSource || !pipeSource) return; clearLayers(); const zoneDataProjection = resolveFeatureCollectionProjection(mapView.zoneGeometry); const zoneFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.zoneGeometry), { dataProjection: zoneDataProjection, featureProjection: GisApp.TO_PROJ, }); zoneFeatures.forEach((feature: any) => feature.setStyle(zoneStyle)); zoneSource.addFeatures(zoneFeatures); zoneSource.changed?.(); zoneLayerRef.current?.setVisible?.(true); zoneLayerRef.current?.changed?.(); const pipeDataProjection = resolveFeatureCollectionProjection(mapView.pipeGeometry); const pipeFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.pipeGeometry), { dataProjection: pipeDataProjection, featureProjection: GisApp.TO_PROJ, }); pipeFeatures.forEach((feature: any) => { const hasOilq = Boolean(feature.get?.("hasOilq")); feature.setStyle(buildPipeStyle(hasOilq)); }); pipeSource.addFeatures(pipeFeatures); pipeSource.changed?.(); pipeLayerRef.current?.setVisible?.(true); pipeLayerRef.current?.changed?.(); gisMaps.current?.renderSync?.(); console.info("移⑥엯??FE overlay ?뚮뜑 寃곌낵", { focusTargetType: mapView.focusTargetType, zoneFeatureCount: zoneFeatures.length, pipeFeatureCount: pipeFeatures.length, }); await moveToStableFocus(mapView); }, [clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus], ); const handleRowSelect = useCallback( async (row: InftwtResultRow) => { try { activeResultRowRef.current = row; setFeatureData(null); setIsOpen(false); setIsSearchModalOpen(false); const cachedMapView = mapViewCacheRef.current.get(row.inftwtAnlsSn); let mapView = cachedMapView; if (!mapView) { const payload = await getRawJson(`/sewerage-service/api/v1/inftwtAnlsRslt/mapView/${row.inftwtAnlsSn}`); mapView = (payload?.data ?? payload) as InftwtResultMapViewResponse; mapViewCacheRef.current.set(row.inftwtAnlsSn, mapView); } if (typeof window !== "undefined") { window.dispatchEvent( new CustomEvent(INFTWT_PROC_SEARCH_EVENT, { detail: { ctpvCd: row.ctpvCd ?? "0", sggCd: row.sggCd ?? "0", swtrmtFcltNm: row.swtrmtFcltNm ?? "", suppressMapMove: (mapView.oilqPipeCount ?? 0) > 0, }, }), ); } setActiveMapView(mapView); void renderMapView(mapView); } catch (error) { console.error("移⑥엯??遺꾩꽍寃곌낵 吏€???곌퀎 以??ㅻ쪟:", error); alert("침입수 분석결과를 지도에 연계하지 못했습니다."); } }, [alert, renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen], ); useEffect(() => { const handlePopupMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; if (event.data?.type !== INFTWT_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return; void handleRowSelect(event.data.row as InftwtResultRow); }; const handleStorageEvent = (event: StorageEvent) => { if (event.key !== INFTWT_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return; try { const payload = JSON.parse(event.newValue) as { row?: InftwtResultRow }; if (!payload?.row) return; void handleRowSelect(payload.row); } catch (error) { console.warn("移⑥엯??遺꾩꽍寃곌낵 storage ?숆린??媛믪쓣 ?댁꽍?섏? 紐삵뻽?듬땲??", error); } }; window.addEventListener("message", handlePopupMessage); window.addEventListener("storage", handleStorageEvent); return () => { window.removeEventListener("message", handlePopupMessage); window.removeEventListener("storage", handleStorageEvent); }; }, [handleRowSelect]); useEffect(() => { return () => { clearLayers(); }; }, [clearLayers]); const handleOverlayFeatureSelect = useCallback((data: any) => { if (getOverlayFlag(data) !== INFTWT_RESULT_OVERLAY_TYPE) { return false; } const overlayAnalysisSn = getOverlayAnalysisSn(data); const basePplMngNo = getOverlayBasePplMngNo(data); const analysisPplMngNo = getOverlayAnalysisPplMngNo(data); const currentRow = activeResultRowRef.current; const popupWindow = openWindowPopup("infilChart", { pplMngNo: basePplMngNo || analysisPplMngNo.replace(/^L/, ""), inftwtAnlsSn: overlayAnalysisSn || currentRow?.inftwtAnlsSn || 0, aplcnYm: getOverlayAplcnYm(data) || currentRow?.aplcnYm || activeMapView?.analysis?.aplcnYm || "", analysisInfo: getOverlayAnalysisInfo(data) || currentRow?.anlsInfo || activeMapView?.analysis?.anlsInfo || "", resultOnly: "Y", }); if (!popupWindow) { alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요."); } return true; }, [activeMapView, alert]); return { activeMapView, clearLayers, handleOverlayFeatureSelect, }; };