import { useEffect, useRef } from 'react'; import { useMap } from '@vis.gl/react-maplibre'; import { API_BASE_URL } from '../../services/api'; const PROXY_PREFIX = `${API_BASE_URL}/tiles/enc`; const ENC_SPRITE_ID = 'enc-s57'; const ENC_SOURCE_ID = 'enc-s57'; const GLOBE_SOURCE_ID = 'enc-globe'; // sprite JSON에 정의된 아이콘 이름 캐시 (프리픽스 판별용) let spriteIconNames: Set | null = null; interface EncStyleLayer { id: string; type: string; source?: string; 'source-layer'?: string; filter?: unknown; layout?: Record; paint?: Record; minzoom?: number; maxzoom?: number; } interface EncStyle { layers: EncStyleLayer[]; sources: Record; } // style.json 캐시 let cachedStyle: EncStyle | null = null; async function loadEncStyle(): Promise { if (cachedStyle) return cachedStyle; const res = await fetch(`${PROXY_PREFIX}/style`); if (!res.ok) throw new Error(`ENC style fetch failed: ${res.status}`); cachedStyle = await res.json(); return cachedStyle!; } // sprite JSON 로드 → 아이콘 이름 세트 async function loadSpriteNames(): Promise> { if (spriteIconNames) return spriteIconNames; const res = await fetch(`${PROXY_PREFIX}/sprite/sprite@2x.json`); if (!res.ok) throw new Error(`Sprite JSON fetch failed: ${res.status}`); const json = await res.json(); spriteIconNames = new Set(Object.keys(json)); return spriteIconNames; } // sprite 존재 여부 확인 function hasSprite(map: maplibregl.Map, id: string): boolean { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const sprites = (map.style as any)?.getSprite?.(); return Array.isArray(sprites) && sprites.some((s: { id: string }) => s.id === id); } catch { return false; } } // ─── 아이콘/패턴 이름에 sprite 프리픽스 부착 ─── // addSprite('enc-s57', url)로 등록하면 이미지는 'enc-s57:NAME' 으로 참조해야 함 // style.json은 'NAME' 으로 참조하므로 변환 필요 const SPRITE_PREFIX = `${ENC_SPRITE_ID}:`; function prefixIconValue(value: unknown, iconNames: Set): unknown { if (typeof value === 'string') { // sprite에 정의된 아이콘 이름이면 프리픽스 부착 if (iconNames.has(value)) return `${SPRITE_PREFIX}${value}`; return value; } if (Array.isArray(value)) { // MapLibre expression: 재귀적으로 문자열 요소 변환 return value.map(item => prefixIconValue(item, iconNames)); } return value; } function prefixLayerIcons( layer: EncStyleLayer, iconNames: Set, ): EncStyleLayer { const result = { ...layer }; // layout: icon-image if (result.layout?.['icon-image']) { result.layout = { ...result.layout, 'icon-image': prefixIconValue(result.layout['icon-image'], iconNames), }; } // paint: fill-pattern if (result.paint?.['fill-pattern']) { result.paint = { ...result.paint, 'fill-pattern': prefixIconValue(result.paint['fill-pattern'], iconNames), }; } // paint: background-pattern if (result.paint?.['background-pattern']) { result.paint = { ...result.paint, 'background-pattern': prefixIconValue(result.paint['background-pattern'], iconNames), }; } return result; } // ─── 컴포넌트 ─── interface S57EncOverlayProps { visible: boolean; } export function S57EncOverlay({ visible }: S57EncOverlayProps) { const { current: mapRef } = useMap(); const addedLayersRef = useRef([]); const sourcesAddedRef = useRef(false); const originalGlyphsRef = useRef(undefined); useEffect(() => { const map = mapRef?.getMap(); if (!map) return; if (visible) { addEncLayers(map); } else { removeEncLayers(map); } return () => { if (map) removeEncLayers(map); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible, mapRef]); async function addEncLayers(map: maplibregl.Map) { if (sourcesAddedRef.current) return; try { const [style, iconNames] = await Promise.all([loadEncStyle(), loadSpriteNames()]); // glyphs URL을 ENC 프록시로 교체 (ENC symbol 레이어용 폰트) // eslint-disable-next-line @typescript-eslint/no-explicit-any const styleObj = map.style as any; originalGlyphsRef.current = styleObj.glyphs; styleObj.glyphs = `${PROXY_PREFIX}/font/{fontstack}/{range}`; // sprite 등록 (중복 방지) if (!hasSprite(map, ENC_SPRITE_ID)) { const spriteUrl = PROXY_PREFIX.startsWith('http') ? `${PROXY_PREFIX}/sprite/sprite` : `${window.location.origin}${PROXY_PREFIX}/sprite/sprite`; map.addSprite(ENC_SPRITE_ID, spriteUrl); } // sources 등록 (타일 URL을 프록시로 치환) if (!map.getSource(GLOBE_SOURCE_ID)) { const globeSrc = style.sources['globe']; map.addSource(GLOBE_SOURCE_ID, { type: 'vector', tiles: [`${PROXY_PREFIX}/globe/{z}/{x}/{y}`], minzoom: globeSrc?.minzoom ?? 0, maxzoom: globeSrc?.maxzoom ?? 4, }); } if (!map.getSource(ENC_SOURCE_ID)) { const encSrc = style.sources['enc']; map.addSource(ENC_SOURCE_ID, { type: 'vector', tiles: [`${PROXY_PREFIX}/{z}/{x}/{y}`], minzoom: encSrc?.minzoom ?? 4, maxzoom: encSrc?.maxzoom ?? 17, }); } // layers 등록 (background 포함 — ENC_EMPTY_STYLE 사용 시 배경 필요) const layerIds: string[] = []; for (const rawLayer of style.layers) { if (map.getLayer(rawLayer.id)) continue; // 아이콘/패턴 참조에 sprite 프리픽스 부착 const layer = prefixLayerIcons(rawLayer, iconNames); // source 이름을 프록시 source ID로 매핑 (background 타입은 source 없음) const mappedSource = layer.source === 'globe' ? GLOBE_SOURCE_ID : layer.source === 'enc' ? ENC_SOURCE_ID : layer.source; // eslint-disable-next-line @typescript-eslint/no-explicit-any const layerSpec: any = { id: layer.id, type: layer.type, }; if (mappedSource) layerSpec.source = mappedSource; if (layer['source-layer']) layerSpec['source-layer'] = layer['source-layer']; if (layer.filter) layerSpec.filter = layer.filter; if (layer.layout) layerSpec.layout = layer.layout; if (layer.paint) layerSpec.paint = layer.paint; if (layer.minzoom !== undefined) layerSpec.minzoom = layer.minzoom; if (layer.maxzoom !== undefined) layerSpec.maxzoom = layer.maxzoom; map.addLayer(layerSpec as maplibregl.AddLayerObject); layerIds.push(layer.id); } addedLayersRef.current = layerIds; sourcesAddedRef.current = true; } catch (err) { console.error('[S57EncOverlay] ENC 레이어 추가 실패:', err); } } function removeEncLayers(map: maplibregl.Map) { if (!sourcesAddedRef.current) return; // 맵 스타일이 이미 파괴된 경우 (탭 전환 등 언마운트 시) ref만 정리 // eslint-disable-next-line @typescript-eslint/no-explicit-any if (!(map as any).style) { addedLayersRef.current = []; sourcesAddedRef.current = false; return; } // layers 제거 (역순) for (const id of [...addedLayersRef.current].reverse()) { if (map.getLayer(id)) { map.removeLayer(id); } } addedLayersRef.current = []; // sources 제거 if (map.getSource(ENC_SOURCE_ID)) map.removeSource(ENC_SOURCE_ID); if (map.getSource(GLOBE_SOURCE_ID)) map.removeSource(GLOBE_SOURCE_ID); // sprite 제거 if (hasSprite(map, ENC_SPRITE_ID)) { map.removeSprite(ENC_SPRITE_ID); } // glyphs URL 복원 if (originalGlyphsRef.current !== undefined) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (map.style as any).glyphs = originalGlyphsRef.current; } sourcesAddedRef.current = false; } return null; }