import { useEffect, useRef, useCallback, useState } from 'react'; import { useMap } from '@vis.gl/react-maplibre'; import { API_BASE_URL } from '../../services/api'; import { useLayerTree } from '../../hooks/useLayers'; import type { Layer } from '../../services/layerService'; import { getOpacityProp, getColorProp } from './srStyles'; const SR_SOURCE_ID = 'sr'; const PROXY_PREFIX = `${API_BASE_URL}/tiles`; // MapLibre 내부 요청은 절대 URL이 필요 const ABSOLUTE_PREFIX = API_BASE_URL.startsWith('http') ? PROXY_PREFIX : `${window.location.origin}${PROXY_PREFIX}`; // ─── SR 스타일 JSON (Martin style/sr) ─── interface SrStyleLayer { id: string; type: string; source: string; 'source-layer': string; paint?: Record; layout?: Record; filter?: unknown; minzoom?: number; maxzoom?: number; } interface SrStyle { sources: Record; layers: SrStyleLayer[]; } let cachedStyle: SrStyle | null = null; async function loadSrStyle(): Promise { if (cachedStyle) return cachedStyle; const res = await fetch(`${PROXY_PREFIX}/sr/style`); if (!res.ok) throw new Error(`SR style fetch failed: ${res.status}`); cachedStyle = await res.json(); return cachedStyle!; } // ─── 헬퍼: wmsLayer(mpc:XXX)에서 코드 추출 ─── function extractCode(wmsLayer: string): string | null { // mpc:468 → '468', mpc:386_spr → '386', mpc:kcg → 'kcg', mpc:kcg_ofi → 'kcg_ofi' const match = wmsLayer.match(/^mpc:(.+?)(?:_(spr|sum|fal|win|apr))?$/); return match ? match[1] : null; } // ─── layerTree → SR 매핑 구축 ─── interface SrMapping { layerCd: string; // DB LAYER_CD (예: 'LYR001002001004005') code: string; // mpc: 뒤 코드 (예: '468', 'kcg', '3') name: string; // DB 레이어명 (예: '갯벌', '경찰청', '군산') } // ─── source-layer → DB layerCd 매칭 ─── function matchSourceLayer(sourceLayer: string, mappings: SrMapping[]): string[] { // 1차: 숫자 접두사 매칭 (468_갯벌 → code '468') const numMatch = sourceLayer.match(/^(\d+)/); if (numMatch) { const code = numMatch[1]; const matched = mappings.filter((m) => m.code === code); if (matched.length > 0) return matched.map((m) => m.layerCd); } // 2차: 이름 정확 일치 (경찰청 = 경찰청) const exactMatch = mappings.filter((m) => sourceLayer === m.name); if (exactMatch.length > 0) return exactMatch.map((m) => m.layerCd); // 3차: 접미사 일치 (해경관할구역-군산 → name '군산') const suffixMatch = mappings.filter( (m) => sourceLayer.endsWith(`-${m.name}`) || sourceLayer.endsWith(`_${m.name}`), ); if (suffixMatch.length > 0) return suffixMatch.map((m) => m.layerCd); return []; } function buildSrMappings(layers: Layer[]): SrMapping[] { const result: SrMapping[] = []; function traverse(nodes: Layer[]) { for (const node of nodes) { if (node.wmsLayer) { const code = extractCode(node.wmsLayer); if (code) { result.push({ layerCd: node.id, code, name: node.name }); } } if (node.children) traverse(node.children); } } traverse(layers); return result; } // ─── 컴포넌트 ─── interface SrOverlayProps { enabledLayers: Set; opacity?: number; layerColors?: Record; } export function SrOverlay({ enabledLayers, opacity = 100, layerColors }: SrOverlayProps) { const { current: mapRef } = useMap(); const { data: layerTree } = useLayerTree(); const addedLayersRef = useRef>(new Set()); const sourceAddedRef = useRef(false); const [style, setStyle] = useState(cachedStyle); // 스타일 JSON 로드 (최초 1회) useEffect(() => { if (style) return; loadSrStyle() .then(setStyle) .catch((err) => console.error('[SrOverlay] SR 스타일 로드 실패:', err)); }, [style]); const ensureSource = useCallback((map: maplibregl.Map) => { if (sourceAddedRef.current) return; if (map.getSource(SR_SOURCE_ID)) { sourceAddedRef.current = true; return; } map.addSource(SR_SOURCE_ID, { type: 'vector', tiles: [`${ABSOLUTE_PREFIX}/sr/{z}/{x}/{y}`], maxzoom: 14, }); sourceAddedRef.current = true; }, []); const removeAll = useCallback((map: maplibregl.Map) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any if (!(map as any).style) { addedLayersRef.current.clear(); sourceAddedRef.current = false; return; } for (const id of addedLayersRef.current) { if (map.getLayer(id)) map.removeLayer(id); } addedLayersRef.current.clear(); if (map.getSource(SR_SOURCE_ID)) map.removeSource(SR_SOURCE_ID); sourceAddedRef.current = false; }, []); // enabledLayers 변경 시 레이어 동기화 useEffect(() => { const map = mapRef?.getMap(); if (!map || !layerTree || !style) return; const mappings = buildSrMappings(layerTree); // source-layer → DB layerCd[] 매핑 const sourceLayerToIds = new Map(); for (const sl of style.layers) { const ids = matchSourceLayer(sl['source-layer'], mappings); if (ids.length > 0) sourceLayerToIds.set(sl['source-layer'], ids); } // 커스텀 색상 조회 (source-layer name 기반) const getCustomColor = (sourceLayer: string): string | undefined => { const ids = sourceLayerToIds.get(sourceLayer); if (!ids) return undefined; for (const id of ids) { const c = layerColors?.[id]; if (c) return c; } return undefined; }; // style JSON 레이어 중 활성화된 DB 레이어에 해당하는 스타일 레이어 필터 const enabledStyleLayers = style.layers.filter((sl) => { const ids = sourceLayerToIds.get(sl['source-layer']); return ids && ids.some((id) => enabledLayers.has(id)); }); const syncLayers = () => { ensureSource(map); const activeLayerIds = new Set(); // 활성화된 레이어 추가 또는 visible 설정 for (const sl of enabledStyleLayers) { const layerId = `sr-${sl.id}`; activeLayerIds.add(layerId); const customColor = getCustomColor(sl['source-layer']); const layerType = sl.type as 'fill' | 'line' | 'circle'; if (map.getLayer(layerId)) { map.setLayoutProperty(layerId, 'visibility', 'visible'); // 기존 레이어에 커스텀 색상 적용 const colorProp = getColorProp(layerType); if (customColor) { map.setPaintProperty(layerId, colorProp, customColor); } else { const orig = sl.paint?.[colorProp]; if (orig !== undefined) map.setPaintProperty(layerId, colorProp, orig); } } else { const opacityValue = opacity / 100; const opacityProp = getOpacityProp(layerType); const paint = { ...sl.paint, [opacityProp]: opacityValue }; // 커스텀 색상 적용 if (customColor) { const colorProp = getColorProp(layerType); paint[colorProp] = customColor; if (sl.type === 'fill') { paint['fill-outline-color'] = customColor; } } try { map.addLayer({ id: layerId, type: sl.type, source: SR_SOURCE_ID, 'source-layer': sl['source-layer'], paint, layout: { visibility: 'visible', ...sl.layout }, ...(sl.filter ? { filter: sl.filter } : {}), ...(sl.minzoom !== undefined && { minzoom: sl.minzoom }), ...(sl.maxzoom !== undefined && { maxzoom: sl.maxzoom }), } as maplibregl.AddLayerObject); addedLayersRef.current.add(layerId); } catch (err) { console.warn(`[SrOverlay] 레이어 추가 실패 (${sl.id}):`, err); } } } // 비활성화된 레이어 숨김 for (const layerId of addedLayersRef.current) { if (!activeLayerIds.has(layerId)) { if (map.getLayer(layerId)) { map.setLayoutProperty(layerId, 'visibility', 'none'); } } } }; if (map.isStyleLoaded()) { syncLayers(); } else { map.once('style.load', syncLayers); return () => { map.off('style.load', syncLayers); }; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabledLayers, layerTree, style, mapRef, layerColors]); // opacity 변경 시 paint 업데이트 useEffect(() => { const map = mapRef?.getMap(); if (!map || !style) return; const opacityValue = opacity / 100; for (const layerId of addedLayersRef.current) { if (!map.getLayer(layerId)) continue; const originalId = layerId.replace(/^sr-/, ''); const sl = style.layers.find((l) => l.id === originalId); if (sl) { const prop = getOpacityProp(sl.type as 'fill' | 'line' | 'circle'); map.setPaintProperty(layerId, prop, opacityValue); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [opacity, mapRef]); // layerColors 변경 시 paint 업데이트 useEffect(() => { const map = mapRef?.getMap(); if (!map || !style || !layerTree) return; const mappings = buildSrMappings(layerTree); const sourceLayerToIds = new Map(); for (const sl of style.layers) { const ids = matchSourceLayer(sl['source-layer'], mappings); if (ids.length > 0) sourceLayerToIds.set(sl['source-layer'], ids); } const getCustomColor = (sourceLayer: string): string | undefined => { const ids = sourceLayerToIds.get(sourceLayer); if (!ids) return undefined; for (const id of ids) { const c = layerColors?.[id]; if (c) return c; } return undefined; }; for (const layerId of addedLayersRef.current) { if (!map.getLayer(layerId)) continue; const originalId = layerId.replace(/^sr-/, ''); const sl = style.layers.find((l) => l.id === originalId); if (!sl) continue; const customColor = getCustomColor(sl['source-layer']); const layerType = sl.type as 'fill' | 'line' | 'circle'; const colorProp = getColorProp(layerType); if (customColor) { map.setPaintProperty(layerId, colorProp, customColor); } else { const orig = sl.paint?.[colorProp]; if (orig !== undefined) map.setPaintProperty(layerId, colorProp, orig as string); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [layerColors, mapRef, style, layerTree]); // cleanup on unmount useEffect(() => { const map = mapRef?.getMap(); if (!map) return; return () => { removeAll(map); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [mapRef]); return null; }