"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { FeatureLike } from "ol/Feature"; import GeoJSON from "ol/format/GeoJSON"; import Map from "ol/Map"; import View from "ol/View"; import { Stroke, Style } from "ol/style"; import TileLayer from "ol/layer/Tile"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import TileWMTS from "ol/source/WMTS"; import WMTSTileGrid from "ol/tilegrid/WMTS"; import { getTopLeft } from "ol/extent"; import { get as getProjection } from "ol/proj"; import { register } from "ol/proj/proj4"; import proj4 from "proj4"; import { getRawJson } from "@/components/lib/api"; type GisUploadPreviewMapProps = { uploadId: string; mngInstCd?: string | null; }; type LayerSetting = { id?: string; lyrSn?: string; lyrNm?: string; lyrTyNm?: string; lyrTypeNm?: string; lyr_type_nm?: string; flterSql?: string | null; minViewSize?: string | null; minZoom?: string | null; lnColor?: string | null; lineColr?: string | null; lnWdth?: string | null; lineWdth?: string | null; drawOrdr?: string | null; exprsYn?: string | null; useYn?: string | null; actlLyrYn?: string | null; children?: LayerSetting[]; }; const MAP_SRS = "EPSG:5179"; const INITIAL_CENTER_5179 = [965000, 1900000]; const INITIAL_ZOOM = 8; const FALLBACK_PIPE_MIN_ZOOM = 13; // OpenLayers는 minZoom 경계값에서 레이어를 숨기므로 자동 포커스는 임계값보다 소폭 크게 설정한다. const MIN_ZOOM_VISIBILITY_OFFSET = 0.01; const NETWORK_LAYER_GROUP = "SN_NETWORK"; const PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; const NGII_WMTS_URL = `/ngii-server/openapi/Gettile.do?apikey=${process.env.NEXT_PUBLIC_NGII_PARAM ?? ""}`; const NGII_RESOLUTIONS = [ 1044.48, 522.24, 261.12, 130.56, 65.28, 32.64, 16.32, 8.16, 4.08, 2.04, 1.02, 0.51, 0.255, ]; const NGII_MATRIX_IDS = [ "L06", "L07", "L08", "L09", "L10", "L11", "L12", "L13", "L14", "L15", "L16", "L17", "L18", ]; let isProjectionRegistered = false; const registerMapProjection = () => { if (isProjectionRegistered) return; proj4.defs( MAP_SRS, "+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs", ); register(proj4); const projection = getProjection(MAP_SRS); projection?.setExtent([-200000.0, -28024123.62, 31824123.62, 4000000.0]); isProjectionRegistered = true; }; const createNgiiBaseLayer = () => { registerMapProjection(); const projection = getProjection(MAP_SRS); const tileGrid = new WMTSTileGrid({ origin: getTopLeft( projection?.getExtent() ?? [ -200000.0, -28024123.62, 31824123.62, 4000000.0, ], ), resolutions: NGII_RESOLUTIONS, matrixIds: NGII_MATRIX_IDS, tileSize: [256, 256], }); const source = new TileWMTS({ projection: MAP_SRS, tileGrid, crossOrigin: "anonymous", requestEncoding: "KVP", layer: "korean_map", matrixSet: MAP_SRS, format: "image/png", style: "korean", }); source.setTileUrlFunction((tileCoord) => { const [z, x, y] = tileCoord; return NGII_WMTS_URL.replace( "{z}", NGII_MATRIX_IDS[z ?? 0] ?? NGII_MATRIX_IDS[0] ?? "L06", ) .replace("{x}", String(x)) .replace("{y}", String(y)); }); return new TileLayer({ source, zIndex: 0, }); }; const flattenLayerSettings = (items: LayerSetting[] = []): LayerSetting[] => items.flatMap((item) => [item, ...flattenLayerSettings(item.children ?? [])]); const getLayerTypeName = (layer: LayerSetting) => String(layer.lyrTyNm ?? layer.lyrTypeNm ?? layer.lyr_type_nm ?? "").trim(); const toFiniteNumber = (value: unknown, fallback: number) => { if (value === null || value === undefined || String(value).trim() === "") return fallback; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }; const normalizeColor = (value: unknown, fallback: string) => { const color = String(value ?? "").trim(); return color || fallback; }; const getLineColor = (layer?: LayerSetting | null) => normalizeColor(layer?.lnColor ?? layer?.lineColr, "#f97316"); const getLayerMinZoom = ( layer: LayerSetting | null | undefined, fallback: number, ) => toFiniteNumber(layer?.minViewSize ?? layer?.minZoom, fallback); const getNetworkPipeLineWidth = (layer?: LayerSetting | null) => Math.max(1, toFiniteNumber(layer?.lnWdth ?? layer?.lineWdth, 1)); const getHighlightedPipeLineWidth = (layer?: LayerSetting | null) => Math.max(3, getNetworkPipeLineWidth(layer) + 2) + 3; const pipePreviewStyle = (layerSetting?: LayerSetting | null) => (feature: FeatureLike) => { const status = feature.get("previewStatus"); if (status !== "CREATED" && status !== "UPDATED") { return new Style({ stroke: new Stroke({ color: getLineColor(layerSetting), width: getNetworkPipeLineWidth(layerSetting), }), }); } const baseColor = status === "CREATED" ? "#16a34a" : "#f97316"; const width = getHighlightedPipeLineWidth(layerSetting); return [ new Style({ stroke: new Stroke({ color: "#ffffff", width: width + 3, }), }), new Style({ stroke: new Stroke({ color: baseColor, width, }), }), ]; }; const unwrapFeatureCollection = (response: any) => response?.data ?? response; const refreshMapSize = (map: Map) => { window.requestAnimationFrame(() => { map.updateSize(); }); }; const getFeatureExtent = (features: ReturnType) => { let minX = Number.POSITIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; let maxY = Number.NEGATIVE_INFINITY; features.forEach((feature) => { const geometry = feature.getGeometry(); if (!geometry) return; const extent = geometry.getExtent(); minX = Math.min(minX, extent[0]!); minY = Math.min(minY, extent[1]!); maxX = Math.max(maxX, extent[2]!); maxY = Math.max(maxY, extent[3]!); }); if (![minX, minY, maxX, maxY].every(Number.isFinite)) { return null; } return [minX, minY, maxX, maxY] as [number, number, number, number]; }; const getFeatureExtentCenter = ( features: ReturnType, ) => { const extent = getFeatureExtent(features); if (!extent) return null; const targetCenter: [number, number] = [ (extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2, ]; return features.reduce<[number, number] | null>((nearestCenter, feature) => { const featureExtent = feature.getGeometry()?.getExtent(); if (!featureExtent) return nearestCenter; const featureCenter: [number, number] = [ (featureExtent[0]! + featureExtent[2]!) / 2, (featureExtent[1]! + featureExtent[3]!) / 2, ]; if (!nearestCenter) return featureCenter; const nearestDistance = (nearestCenter[0] - targetCenter[0]) ** 2 + (nearestCenter[1] - targetCenter[1]) ** 2; const featureDistance = (featureCenter[0] - targetCenter[0]) ** 2 + (featureCenter[1] - targetCenter[1]) ** 2; return featureDistance < nearestDistance ? featureCenter : nearestCenter; }, null); }; export default function GisUploadPreviewMap({ uploadId, mngInstCd, }: GisUploadPreviewMapProps) { const mapElementRef = useRef(null); const mapRef = useRef(null); const pipeSourceRef = useRef(new VectorSource()); const pipeLayerRef = useRef | null>(null); const abortControllerRef = useRef(null); const loadTimerRef = useRef(null); const didFitPreviewRef = useRef(false); const [layerSettings, setLayerSettings] = useState([]); const [isLoading, setIsLoading] = useState(false); const [featureCount, setFeatureCount] = useState(0); const [deletedCount, setDeletedCount] = useState(0); const [truncated, setTruncated] = useState(false); const [message, setMessage] = useState(""); const flatLayerSettings = useMemo( () => flattenLayerSettings(layerSettings), [layerSettings], ); const pipeLayerSetting = useMemo( () => flatLayerSettings.find( (layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE, ) ?? null, [flatLayerSettings], ); const pipeMinZoom = getLayerMinZoom(pipeLayerSetting, FALLBACK_PIPE_MIN_ZOOM); useEffect(() => { let mounted = true; const loadLayerSettings = async () => { try { const networkSettings = await getRawJson( `/sewerage-service/api/v1/map/lyrStngs/${NETWORK_LAYER_GROUP}`, ); const filtered = flattenLayerSettings( Array.isArray(networkSettings) ? networkSettings : [], ) .filter((layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE) .filter( (layer) => (layer.useYn ?? "Y") === "Y" && (layer.exprsYn ?? "Y") === "Y" && (layer.actlLyrYn ?? "Y") === "Y", ); if (mounted) { setLayerSettings(filtered); } } catch (error) { console.error("GIS 업로드 미리보기 레이어 설정 조회 실패:", error); if (mounted) { setLayerSettings([]); } } }; void loadLayerSettings(); return () => { mounted = false; }; }, []); useEffect(() => { let disposed = false; let initializeTimer: number | null = null; const initializeMap = () => { const mapElement = mapElementRef.current; if (disposed || mapRef.current) return; if ( !mapElement || mapElement.clientWidth <= 0 || mapElement.clientHeight <= 0 ) { initializeTimer = window.setTimeout(initializeMap, 100); return; } registerMapProjection(); const pipeLayer = new VectorLayer({ source: pipeSourceRef.current, style: pipePreviewStyle(pipeLayerSetting), minZoom: pipeMinZoom, zIndex: toFiniteNumber(pipeLayerSetting?.drawOrdr, 300), }); const map = new Map({ target: mapElement, layers: [createNgiiBaseLayer(), pipeLayer], view: new View({ center: INITIAL_CENTER_5179, zoom: INITIAL_ZOOM, projection: MAP_SRS, }), controls: [], }); pipeLayerRef.current = pipeLayer; mapRef.current = map; refreshMapSize(map); }; initializeMap(); return () => { disposed = true; if (initializeTimer) { window.clearTimeout(initializeTimer); } abortControllerRef.current?.abort(); if (loadTimerRef.current) { window.clearTimeout(loadTimerRef.current); } mapRef.current?.setTarget(undefined); mapRef.current = null; pipeLayerRef.current = null; pipeSourceRef.current.clear(); }; }, []); useEffect(() => { pipeLayerRef.current?.setStyle(pipePreviewStyle(pipeLayerSetting)); pipeLayerRef.current?.setMinZoom(pipeMinZoom); }, [pipeLayerSetting, pipeMinZoom]); useEffect(() => { const map = mapRef.current; const mapElement = mapElementRef.current; if (!map || !mapElement) return; const resizeObserver = new ResizeObserver(() => { refreshMapSize(map); }); resizeObserver.observe(mapElement); const updateTimer = window.setTimeout(() => { map.updateSize(); }, 0); return () => { window.clearTimeout(updateTimer); resizeObserver.disconnect(); }; }, []); useEffect(() => { didFitPreviewRef.current = false; }, [mngInstCd, uploadId]); const loadPreview = useCallback( async function loadPreviewRequest() { const map = mapRef.current; if (!map || !uploadId) return; map.updateSize(); let size = map.getSize(); if (!size) { map.updateSize(); size = map.getSize(); } if (!size || size[0]! <= 0 || size[1]! <= 0) { if (loadTimerRef.current) { window.clearTimeout(loadTimerRef.current); } loadTimerRef.current = window.setTimeout(() => { void loadPreviewRequest(); }, 150); return; } const extent = map.getView().calculateExtent(size); const previewParams = new URLSearchParams({ bbox: extent.join(","), srs: MAP_SRS, }); if (mngInstCd) { previewParams.set("mngInstCd", mngInstCd); } abortControllerRef.current?.abort(); const abortController = new AbortController(); abortControllerRef.current = abortController; setIsLoading(true); setMessage(""); pipeSourceRef.current.clear(); setFeatureCount(0); setDeletedCount(0); setTruncated(false); try { const previewResponse = await getRawJson( `v1/map/gisUploads/${encodeURIComponent(uploadId)}/preview?${previewParams.toString()}`, { signal: abortController.signal }, ); const previewCollection = unwrapFeatureCollection(previewResponse); const pipeFeatures = new GeoJSON({ dataProjection: MAP_SRS, featureProjection: MAP_SRS, }).readFeatures(previewCollection); pipeSourceRef.current.addFeatures(pipeFeatures); if (!didFitPreviewRef.current && pipeFeatures.length > 0) { const changedFeatures = pipeFeatures.filter( (feature) => feature.get("previewStatus") !== "LATEST", ); const center = getFeatureExtentCenter( changedFeatures.length > 0 ? changedFeatures : pipeFeatures, ); if (center) { map.getView().setCenter(center); map.getView().setZoom(pipeMinZoom + MIN_ZOOM_VISIBILITY_OFFSET); didFitPreviewRef.current = true; } } setFeatureCount(pipeFeatures.length); setDeletedCount(Number(previewCollection?.meta?.deletedCount ?? 0)); setTruncated(Boolean(previewCollection?.meta?.truncated)); } catch (error: any) { if (error?.name === "AbortError") return; console.error("GIS 업로드 미리보기 조회 실패:", error); setMessage("미리보기 데이터를 불러오지 못했습니다."); } finally { if (!abortController.signal.aborted) { setIsLoading(false); } } }, [mngInstCd, pipeMinZoom, uploadId], ); useEffect(() => { const map = mapRef.current; if (!map) return; const scheduleLoad = () => { if (loadTimerRef.current) { window.clearTimeout(loadTimerRef.current); } loadTimerRef.current = window.setTimeout(() => { void loadPreview(); }, 250); }; map.on("moveend", scheduleLoad); scheduleLoad(); return () => { map.un("moveend", scheduleLoad); }; }, [loadPreview]); return (

업로드 이력 미리보기

변경 관로는 업로드 이력 기준으로 강조하고, 변경 없는 관로는 관망도와 동일한 스타일로 표시합니다.

신규 /{" "} 수정 /{" "} 관망도 기본 관로 표출 줌 {pipeMinZoom} 이상 관로 {featureCount.toLocaleString("ko-KR")}건 삭제 미표시 {deletedCount.toLocaleString("ko-KR")}건 {truncated ? ( 조회 상한 초과 ) : null} {isLoading ? 불러오는 중... : null}
{message ? (
{message}
) : null}
); }