From 646fa38f39c4c78daaede3b1e4534c4e91fe404f Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 6 Apr 2026 22:29:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(map):=20SR=20=EB=AF=BC=EA=B0=90=EC=9E=90?= =?UTF-8?q?=EC=9B=90=20=EB=B2=A1=ED=84=B0=ED=83=80=EC=9D=BC=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=A0=88=EC=9D=B4=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SrOverlay: Martin SR 스타일 JSON 기반 동적 벡터타일 레이어 렌더링. srStyles: 레이어 타입별 opacity/color 속성 키 헬퍼. Co-Authored-By: Claude Opus 4.6 --- .../src/common/components/map/SrOverlay.tsx | 329 ++++++++++++++++++ .../src/common/components/map/srStyles.ts | 20 ++ 2 files changed, 349 insertions(+) create mode 100644 frontend/src/common/components/map/SrOverlay.tsx create mode 100644 frontend/src/common/components/map/srStyles.ts diff --git a/frontend/src/common/components/map/SrOverlay.tsx b/frontend/src/common/components/map/SrOverlay.tsx new file mode 100644 index 0000000..508ac21 --- /dev/null +++ b/frontend/src/common/components/map/SrOverlay.tsx @@ -0,0 +1,329 @@ +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; +} diff --git a/frontend/src/common/components/map/srStyles.ts b/frontend/src/common/components/map/srStyles.ts new file mode 100644 index 0000000..dd5d8bd --- /dev/null +++ b/frontend/src/common/components/map/srStyles.ts @@ -0,0 +1,20 @@ +// SR(민감자원) 벡터타일 헬퍼 +// 스타일은 Martin style/sr JSON에서 동적 로드 (SrOverlay에서 사용) + +/** opacity 속성 키를 레이어 타입에 따라 반환 */ +export function getOpacityProp(type: 'fill' | 'line' | 'circle'): string { + switch (type) { + case 'fill': return 'fill-opacity'; + case 'line': return 'line-opacity'; + case 'circle': return 'circle-opacity'; + } +} + +/** color 속성 키를 레이어 타입에 따라 반환 */ +export function getColorProp(type: 'fill' | 'line' | 'circle'): string { + switch (type) { + case 'fill': return 'fill-color'; + case 'line': return 'line-color'; + case 'circle': return 'circle-color'; + } +}