diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 7af3ce8..03381a9 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,6 +1,6 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-30", + "applied_date": "2026-03-31", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true diff --git a/backend/src/routes/tiles.ts b/backend/src/routes/tiles.ts index 3b46048..0494823 100644 --- a/backend/src/routes/tiles.ts +++ b/backend/src/routes/tiles.ts @@ -3,6 +3,35 @@ import { Router } from 'express'; const router = Router(); const VWORLD_API_KEY = process.env.VWORLD_API_KEY || ''; +const ENC_UPSTREAM = 'https://tiles.gcnautical.com'; + +// ─── 공통 프록시 헬퍼 ─── + +async function proxyUpstream(upstreamUrl: string, res: import('express').Response, fallbackContentType = 'application/octet-stream') { + try { + const upstream = await fetch(upstreamUrl, { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' }, + }); + + if (!upstream.ok) { + res.status(upstream.status).end(); + return; + } + + const contentType = upstream.headers.get('content-type') || fallbackContentType; + const cacheControl = upstream.headers.get('cache-control') || 'public, max-age=86400'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', cacheControl); + + const buffer = await upstream.arrayBuffer(); + res.end(Buffer.from(buffer)); + } catch { + res.status(502).json({ error: '타일 서버 연결 실패' }); + } +} + +// ─── VWorld 위성타일 ─── // GET /api/tiles/vworld/:z/:y/:x — VWorld WMTS 위성타일 프록시 (CORS 우회) // VWorld는 브라우저 직접 요청에 CORS 헤더를 반환하지 않으므로 서버에서 중계 @@ -22,28 +51,56 @@ router.get('/vworld/:z/:y/:x', async (req, res) => { } const tileUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/${z}/${y}/${x}.jpeg`; + await proxyUpstream(tileUrl, res, 'image/jpeg'); +}); - try { - const upstream = await fetch(tileUrl, { - headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' }, - }); +// ─── S-57 전자해도 (ENC) ─── +// tiles.gcnautical.com CORS 제한 우회를 위한 프록시 엔드포인트 그룹 - if (!upstream.ok) { - res.status(upstream.status).end(); - return; - } +// GET /api/tiles/enc/style — 공식 style.json 프록시 +router.get('/enc/style', async (_req, res) => { + await proxyUpstream(`${ENC_UPSTREAM}/style/nautical`, res, 'application/json'); +}); - const contentType = upstream.headers.get('content-type') || 'image/jpeg'; - const cacheControl = upstream.headers.get('cache-control') || 'public, max-age=86400'; - - res.setHeader('Content-Type', contentType); - res.setHeader('Cache-Control', cacheControl); - - const buffer = await upstream.arrayBuffer(); - res.end(Buffer.from(buffer)); - } catch { - res.status(502).json({ error: 'VWorld 타일 서버 연결 실패' }); +// GET /api/tiles/enc/sprite/:file — sprite JSON/PNG 프록시 (sprite.json, sprite.png, sprite@2x.json, sprite@2x.png) +router.get('/enc/sprite/:file', async (req, res) => { + const { file } = req.params; + if (!/^sprite(@2x)?\.(json|png)$/.test(file)) { + res.status(400).json({ error: '잘못된 sprite 파일명' }); + return; } + const fallbackCt = file.endsWith('.png') ? 'image/png' : 'application/json'; + await proxyUpstream(`${ENC_UPSTREAM}/sprite/${file}`, res, fallbackCt); +}); + +// GET /api/tiles/enc/font/:fontstack/:range — glyphs(PBF) 프록시 +router.get('/enc/font/:fontstack/:range', async (req, res) => { + const { fontstack, range } = req.params; + if (!/^[\w\s%-]+$/.test(fontstack) || !/^\d+-\d+$/.test(range)) { + res.status(400).json({ error: '잘못된 폰트 요청' }); + return; + } + await proxyUpstream(`${ENC_UPSTREAM}/font/${fontstack}/${range}`, res, 'application/x-protobuf'); +}); + +// GET /api/tiles/enc/globe/:z/:x/:y — globe 벡터타일 프록시 (저줌 레벨용) +router.get('/enc/globe/:z/:x/:y', async (req, res) => { + const { z, x, y } = req.params; + if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) { + res.status(400).json({ error: '잘못된 타일 좌표' }); + return; + } + await proxyUpstream(`${ENC_UPSTREAM}/globe/${z}/${x}/${y}`, res, 'application/x-protobuf'); +}); + +// GET /api/tiles/enc/:z/:x/:y — ENC 벡터타일 프록시 (표준 XYZ 순서) +router.get('/enc/:z/:x/:y', async (req, res) => { + const { z, x, y } = req.params; + if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) { + res.status(400).json({ error: '잘못된 타일 좌표' }); + return; + } + await proxyUpstream(`${ENC_UPSTREAM}/enc/${z}/${x}/${y}`, res, 'application/x-protobuf'); }); export default router; diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 3af8aa1..11563d0 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -17,6 +17,7 @@ import { buildMeasureLayers } from './measureLayers' import { MeasureOverlay } from './MeasureOverlay' import { useMeasureTool } from '@common/hooks/useMeasureTool' import { hexToRgba } from './mapUtils' +import { S57EncOverlay } from './S57EncOverlay' import { useMapStore } from '@common/store/mapStore' const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080' @@ -264,6 +265,13 @@ const SATELLITE_3D_STYLE: StyleSpecification = { ], } +// S-57 전자해도 전용 빈 스타일 (ENC 레이어가 자체적으로 육지/해안/수심 모두 포함) +const ENC_EMPTY_STYLE: StyleSpecification = { + version: 8, + sources: {}, + layers: [], +} + // 모델별 색상 매핑 const MODEL_COLORS: Record = { 'KOSPS': '#06b6d4', @@ -1339,7 +1347,9 @@ export function MapView({ ]) // 3D 모드 / 밝은 톤에 따른 지도 스타일 전환 - const currentMapStyle = mapToggles['threeD'] ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE + const currentMapStyle = mapToggles['s57'] + ? ENC_EMPTY_STYLE + : mapToggles['threeD'] ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE return (
@@ -1369,6 +1379,9 @@ export function MapView({ {/* 예측 완료 시 궤적 전체 범위로 fitBounds */} + {/* S-57 전자해도 오버레이 (공식 style.json 기반) */} + + {/* WMS 레이어 */} {wmsLayers.map(layer => ( | 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)) { + map.addSprite(ENC_SPRITE_ID, `${PROXY_PREFIX}/sprite/sprite`); + } + + // 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; +} diff --git a/frontend/src/common/store/mapStore.ts b/frontend/src/common/store/mapStore.ts index 1c48250..452ef9b 100644 --- a/frontend/src/common/store/mapStore.ts +++ b/frontend/src/common/store/mapStore.ts @@ -58,9 +58,13 @@ export const useMapStore = create((set, get) => ({ mapToggles: { s57: true, s101: false, threeD: false, satellite: false }, mapTypes: DEFAULT_MAP_TYPES, toggleMap: (key) => - set((s) => ({ - mapToggles: { ...s.mapToggles, [key]: !s.mapToggles[key] }, - })), + set((s) => { + const isCurrentlyOn = s.mapToggles[key]; + const allOff: MapToggles = { s57: false, s101: false, threeD: false, satellite: false }; + return { + mapToggles: isCurrentlyOn ? allOff : { ...allOff, [key]: true }, + }; + }), loadMapTypes: async () => { try { const res = await api.get('/map-base/active')