From 5a792bb53c449b7cfa77e0d83186a07516db0fec Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Tue, 31 Mar 2026 16:56:02 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat(map):=20S-57=20=EC=A0=84=EC=9E=90?= =?UTF-8?q?=ED=95=B4=EB=8F=84(ENC)=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ENC 타일 프록시 엔드포인트 추가 (style, sprite, font, globe, enc 벡터타일) - S57EncOverlay 컴포넌트 구현 (공식 style.json 기반 레이어 동적 추가/제거) - 맵 토글 라디오 버튼 방식으로 변경 (한 번에 하나만 활성화) - 언마운트 시 map.style 파괴 상태 안전 처리 --- .claude/workflow-version.json | 2 +- backend/src/routes/tiles.ts | 93 +++++-- .../src/common/components/map/MapView.tsx | 15 +- .../common/components/map/S57EncOverlay.tsx | 260 ++++++++++++++++++ frontend/src/common/store/mapStore.ts | 10 +- 5 files changed, 357 insertions(+), 23 deletions(-) create mode 100644 frontend/src/common/components/map/S57EncOverlay.tsx 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') From a86188f4734023af32cef3f7cc2b5b453004f896 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Tue, 31 Mar 2026 17:56:40 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat(map):=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=ED=83=AD=20=EC=A7=80=EB=8F=84=20=EB=B0=B0=EA=B2=BD=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지도 스타일 상수를 mapStyles.ts로 추출 - useBaseMapStyle 훅 생성 (mapToggles 기반 스타일 반환) - 9개 탭 컴포넌트의 하드코딩 스타일을 공유 훅으로 교체 - 각 Map에 S57EncOverlay 추가 - 초기 mapToggles를 모두 false로 변경 (기본지도 표시) --- .../src/common/components/map/MapView.tsx | 252 +----------------- .../src/common/components/map/mapStyles.ts | 247 +++++++++++++++++ frontend/src/common/hooks/useBaseMapStyle.ts | 12 + frontend/src/common/store/mapStore.ts | 7 +- .../admin/components/DispersingZonePanel.tsx | 36 +-- .../src/tabs/aerial/components/CctvView.tsx | 28 +- .../tabs/aerial/components/RealtimeDrone.tsx | 25 +- .../aerial/components/SatelliteRequest.tsx | 27 +- .../src/tabs/aerial/components/WingAI.tsx | 30 +-- .../src/tabs/assets/components/AssetMap.tsx | 27 +- .../incidents/components/IncidentsView.tsx | 28 +- frontend/src/tabs/scat/components/ScatMap.tsx | 27 +- .../tabs/weather/components/WeatherView.tsx | 37 +-- 13 files changed, 339 insertions(+), 444 deletions(-) create mode 100644 frontend/src/common/components/map/mapStyles.ts create mode 100644 frontend/src/common/hooks/useBaseMapStyle.ts diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 11563d0..7ecf92e 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -3,7 +3,6 @@ import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/r import { MapboxOverlay } from '@deck.gl/mapbox' import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer } from '@deck.gl/layers' import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core' -import type { StyleSpecification } from 'maplibre-gl' import type { MapLayerMouseEvent } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { layerDatabase } from '@common/services/layerService' @@ -19,6 +18,7 @@ import { useMeasureTool } from '@common/hooks/useMeasureTool' import { hexToRgba } from './mapUtils' import { S57EncOverlay } from './S57EncOverlay' import { useMapStore } from '@common/store/mapStore' +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080' @@ -26,252 +26,6 @@ const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:80 const DEFAULT_CENTER: [number, number] = [37.39, 126.64] const DEFAULT_ZOOM = 10 -// CartoDB Dark Matter 스타일 -const BASE_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - attribution: '© OpenStreetMap © CARTO', - }, - }, - layers: [ - { - id: 'carto-dark-layer', - type: 'raster', - source: 'carto-dark', - minzoom: 0, - maxzoom: 22, - }, - ], -} - -// MarineTraffic 스타일 — 깔끔한 연한 회색 육지 + 흰색 바다 + 한글 라벨 -const LIGHT_STYLE: StyleSpecification = { - version: 8, - glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', - sources: { - 'ofm-chart': { - type: 'vector', - url: 'https://tiles.openfreemap.org/planet', - }, - }, - layers: [ - // ── 배경 = 육지 (연한 회색) ── - { - id: 'land-bg', - type: 'background', - paint: { 'background-color': '#e8e8e8' }, - }, - // ── 바다/호수/강 = water 레이어 (파란색) ── - { - id: 'water', - type: 'fill', - source: 'ofm-chart', - 'source-layer': 'water', - paint: { 'fill-color': '#a8cce0' }, - }, - // ── 주요 도로 (zoom 9+) ── - { - id: 'roads-major', - type: 'line', - source: 'ofm-chart', - 'source-layer': 'transportation', - minzoom: 9, - filter: ['in', 'class', 'motorway', 'trunk', 'primary'], - paint: { - 'line-color': '#c0c0c0', - 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.4, 14, 1.5], - }, - }, - // ── 보조 도로 (zoom 12+) ── - { - id: 'roads-secondary', - type: 'line', - source: 'ofm-chart', - 'source-layer': 'transportation', - minzoom: 12, - filter: ['in', 'class', 'secondary', 'tertiary'], - paint: { - 'line-color': '#cccccc', - 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 0.3, 14, 1], - }, - }, - // ── 건물 (zoom 14+) ── - { - id: 'buildings', - type: 'fill', - source: 'ofm-chart', - 'source-layer': 'building', - minzoom: 14, - paint: { 'fill-color': '#cbcbcb', 'fill-opacity': 0.5 }, - }, - // ── 국경선 ── - { - id: 'boundaries-country', - type: 'line', - source: 'ofm-chart', - 'source-layer': 'boundary', - filter: ['==', 'admin_level', 2], - paint: { 'line-color': '#999999', 'line-width': 1, 'line-dasharray': [4, 2] }, - }, - // ── 시도 경계 (zoom 5+) ── - { - id: 'boundaries-province', - type: 'line', - source: 'ofm-chart', - 'source-layer': 'boundary', - minzoom: 5, - filter: ['==', 'admin_level', 4], - paint: { 'line-color': '#bbbbbb', 'line-width': 0.5, 'line-dasharray': [3, 2] }, - }, - // ── 국가/시도 라벨 (한글) ── - { - id: 'place-labels-major', - type: 'symbol', - source: 'ofm-chart', - 'source-layer': 'place', - minzoom: 3, - filter: ['in', 'class', 'country', 'state'], - layout: { - 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], - 'text-font': ['Open Sans Bold'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 3, 11, 8, 16], - 'text-max-width': 8, - }, - paint: { - 'text-color': '#555555', - 'text-halo-color': '#ffffff', - 'text-halo-width': 2, - }, - }, - { - id: 'place-labels-city', - type: 'symbol', - source: 'ofm-chart', - 'source-layer': 'place', - minzoom: 5, - filter: ['in', 'class', 'city', 'town'], - layout: { - 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], - 'text-font': ['Open Sans Regular'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 5, 10, 12, 14], - 'text-max-width': 7, - }, - paint: { - 'text-color': '#666666', - 'text-halo-color': '#ffffff', - 'text-halo-width': 1.5, - }, - }, - // ── 해양 지명 (water_name) ── - { - id: 'water-labels', - type: 'symbol', - source: 'ofm-chart', - 'source-layer': 'water_name', - layout: { - 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], - 'text-font': ['Open Sans Italic'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 3, 10, 8, 14], - 'text-max-width': 10, - 'text-letter-spacing': 0.15, - }, - paint: { - 'text-color': '#8899aa', - 'text-halo-color': 'rgba(168,204,224,0.7)', - 'text-halo-width': 1, - }, - }, - // ── 마을/소지명 (zoom 10+) ── - { - id: 'place-labels-village', - type: 'symbol', - source: 'ofm-chart', - 'source-layer': 'place', - minzoom: 10, - filter: ['in', 'class', 'village', 'suburb', 'hamlet'], - layout: { - 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], - 'text-font': ['Open Sans Regular'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 9, 14, 12], - 'text-max-width': 6, - }, - paint: { - 'text-color': '#777777', - 'text-halo-color': '#ffffff', - 'text-halo-width': 1, - }, - }, - ], -} - -// 3D 위성 스타일 (VWorld 위성 배경 + OpenFreeMap 건물 extrusion) -// VWorld WMTS: {z}/{y}/{x} (row/col 순서) -// OpenFreeMap: CORS 허용, 전 세계 OSM 벡터타일 (building: render_height 포함) -const SATELLITE_3D_STYLE: StyleSpecification = { - version: 8, - sources: { - 'vworld-satellite': { - type: 'raster', - tiles: ['/api/tiles/vworld/{z}/{y}/{x}.jpeg'], - tileSize: 256, - attribution: '© 국토지리정보원 VWorld', - }, - 'ofm': { - type: 'vector', - url: 'https://tiles.openfreemap.org/planet', - }, - }, - layers: [ - { - id: 'satellite-base', - type: 'raster', - source: 'vworld-satellite', - minzoom: 0, - maxzoom: 22, - }, - { - id: 'roads-3d', - type: 'line', - source: 'ofm', - 'source-layer': 'transportation', - filter: ['in', ['get', 'class'], ['literal', ['motorway', 'trunk', 'primary', 'secondary']]], - paint: { - 'line-color': 'rgba(255,255,200,0.3)', - 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 16, 3], - }, - }, - { - id: '3d-buildings', - type: 'fill-extrusion', - source: 'ofm', - 'source-layer': 'building', - minzoom: 13, - filter: ['!=', ['get', 'hide_3d'], true], - paint: { - 'fill-extrusion-color': '#c8b99a', - 'fill-extrusion-height': ['max', ['coalesce', ['get', 'render_height'], 0], 3], - 'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0], - 'fill-extrusion-opacity': 0.85, - }, - }, - ], -} - -// S-57 전자해도 전용 빈 스타일 (ENC 레이어가 자체적으로 육지/해안/수심 모두 포함) -const ENC_EMPTY_STYLE: StyleSpecification = { - version: 8, - sources: {}, - layers: [], -} - // 모델별 색상 매핑 const MODEL_COLORS: Record = { 'KOSPS': '#06b6d4', @@ -1347,9 +1101,7 @@ export function MapView({ ]) // 3D 모드 / 밝은 톤에 따른 지도 스타일 전환 - const currentMapStyle = mapToggles['s57'] - ? ENC_EMPTY_STYLE - : mapToggles['threeD'] ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE + const currentMapStyle = useBaseMapStyle(lightMode) return (
diff --git a/frontend/src/common/components/map/mapStyles.ts b/frontend/src/common/components/map/mapStyles.ts new file mode 100644 index 0000000..c71398b --- /dev/null +++ b/frontend/src/common/components/map/mapStyles.ts @@ -0,0 +1,247 @@ +import type { StyleSpecification } from 'maplibre-gl'; + +// CartoDB Dark Matter 스타일 +export const BASE_STYLE: StyleSpecification = { + version: 8, + sources: { + 'carto-dark': { + type: 'raster', + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + ], + tileSize: 256, + attribution: '© OpenStreetMap © CARTO', + }, + }, + layers: [ + { + id: 'carto-dark-layer', + type: 'raster', + source: 'carto-dark', + minzoom: 0, + maxzoom: 22, + }, + ], +}; + +// MarineTraffic 스타일 — 깔끔한 연한 회색 육지 + 흰색 바다 + 한글 라벨 +export const LIGHT_STYLE: StyleSpecification = { + version: 8, + glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', + sources: { + 'ofm-chart': { + type: 'vector', + url: 'https://tiles.openfreemap.org/planet', + }, + }, + layers: [ + // ── 배경 = 육지 (연한 회색) ── + { + id: 'land-bg', + type: 'background', + paint: { 'background-color': '#e8e8e8' }, + }, + // ── 바다/호수/강 = water 레이어 (파란색) ── + { + id: 'water', + type: 'fill', + source: 'ofm-chart', + 'source-layer': 'water', + paint: { 'fill-color': '#a8cce0' }, + }, + // ── 주요 도로 (zoom 9+) ── + { + id: 'roads-major', + type: 'line', + source: 'ofm-chart', + 'source-layer': 'transportation', + minzoom: 9, + filter: ['in', 'class', 'motorway', 'trunk', 'primary'], + paint: { + 'line-color': '#c0c0c0', + 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.4, 14, 1.5], + }, + }, + // ── 보조 도로 (zoom 12+) ── + { + id: 'roads-secondary', + type: 'line', + source: 'ofm-chart', + 'source-layer': 'transportation', + minzoom: 12, + filter: ['in', 'class', 'secondary', 'tertiary'], + paint: { + 'line-color': '#cccccc', + 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 0.3, 14, 1], + }, + }, + // ── 건물 (zoom 14+) ── + { + id: 'buildings', + type: 'fill', + source: 'ofm-chart', + 'source-layer': 'building', + minzoom: 14, + paint: { 'fill-color': '#cbcbcb', 'fill-opacity': 0.5 }, + }, + // ── 국경선 ── + { + id: 'boundaries-country', + type: 'line', + source: 'ofm-chart', + 'source-layer': 'boundary', + filter: ['==', 'admin_level', 2], + paint: { 'line-color': '#999999', 'line-width': 1, 'line-dasharray': [4, 2] }, + }, + // ── 시도 경계 (zoom 5+) ── + { + id: 'boundaries-province', + type: 'line', + source: 'ofm-chart', + 'source-layer': 'boundary', + minzoom: 5, + filter: ['==', 'admin_level', 4], + paint: { 'line-color': '#bbbbbb', 'line-width': 0.5, 'line-dasharray': [3, 2] }, + }, + // ── 국가/시도 라벨 (한글) ── + { + id: 'place-labels-major', + type: 'symbol', + source: 'ofm-chart', + 'source-layer': 'place', + minzoom: 3, + filter: ['in', 'class', 'country', 'state'], + layout: { + 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], + 'text-font': ['Open Sans Bold'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 3, 11, 8, 16], + 'text-max-width': 8, + }, + paint: { + 'text-color': '#555555', + 'text-halo-color': '#ffffff', + 'text-halo-width': 2, + }, + }, + { + id: 'place-labels-city', + type: 'symbol', + source: 'ofm-chart', + 'source-layer': 'place', + minzoom: 5, + filter: ['in', 'class', 'city', 'town'], + layout: { + 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], + 'text-font': ['Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 5, 10, 12, 14], + 'text-max-width': 7, + }, + paint: { + 'text-color': '#666666', + 'text-halo-color': '#ffffff', + 'text-halo-width': 1.5, + }, + }, + // ── 해양 지명 (water_name) ── + { + id: 'water-labels', + type: 'symbol', + source: 'ofm-chart', + 'source-layer': 'water_name', + layout: { + 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], + 'text-font': ['Open Sans Italic'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 3, 10, 8, 14], + 'text-max-width': 10, + 'text-letter-spacing': 0.15, + }, + paint: { + 'text-color': '#8899aa', + 'text-halo-color': 'rgba(168,204,224,0.7)', + 'text-halo-width': 1, + }, + }, + // ── 마을/소지명 (zoom 10+) ── + { + id: 'place-labels-village', + type: 'symbol', + source: 'ofm-chart', + 'source-layer': 'place', + minzoom: 10, + filter: ['in', 'class', 'village', 'suburb', 'hamlet'], + layout: { + 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], + 'text-font': ['Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 9, 14, 12], + 'text-max-width': 6, + }, + paint: { + 'text-color': '#777777', + 'text-halo-color': '#ffffff', + 'text-halo-width': 1, + }, + }, + ], +}; + +// 3D 위성 스타일 (VWorld 위성 배경 + OpenFreeMap 건물 extrusion) +// VWorld WMTS: {z}/{y}/{x} (row/col 순서) +// OpenFreeMap: CORS 허용, 전 세계 OSM 벡터타일 (building: render_height 포함) +export const SATELLITE_3D_STYLE: StyleSpecification = { + version: 8, + sources: { + 'vworld-satellite': { + type: 'raster', + tiles: ['/api/tiles/vworld/{z}/{y}/{x}.jpeg'], + tileSize: 256, + attribution: '© 국토지리정보원 VWorld', + }, + 'ofm': { + type: 'vector', + url: 'https://tiles.openfreemap.org/planet', + }, + }, + layers: [ + { + id: 'satellite-base', + type: 'raster', + source: 'vworld-satellite', + minzoom: 0, + maxzoom: 22, + }, + { + id: 'roads-3d', + type: 'line', + source: 'ofm', + 'source-layer': 'transportation', + filter: ['in', ['get', 'class'], ['literal', ['motorway', 'trunk', 'primary', 'secondary']]], + paint: { + 'line-color': 'rgba(255,255,200,0.3)', + 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 16, 3], + }, + }, + { + id: '3d-buildings', + type: 'fill-extrusion', + source: 'ofm', + 'source-layer': 'building', + minzoom: 13, + filter: ['!=', ['get', 'hide_3d'], true], + paint: { + 'fill-extrusion-color': '#c8b99a', + 'fill-extrusion-height': ['max', ['coalesce', ['get', 'render_height'], 0], 3], + 'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0], + 'fill-extrusion-opacity': 0.85, + }, + }, + ], +}; + +// S-57 전자해도 전용 빈 스타일 (ENC 레이어가 자체적으로 육지/해안/수심 모두 포함) +export const ENC_EMPTY_STYLE: StyleSpecification = { + version: 8, + sources: {}, + layers: [], +}; diff --git a/frontend/src/common/hooks/useBaseMapStyle.ts b/frontend/src/common/hooks/useBaseMapStyle.ts new file mode 100644 index 0000000..25f31f6 --- /dev/null +++ b/frontend/src/common/hooks/useBaseMapStyle.ts @@ -0,0 +1,12 @@ +import type { StyleSpecification } from 'maplibre-gl'; +import { useMapStore } from '@common/store/mapStore'; +import { BASE_STYLE, LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@common/components/map/mapStyles'; + +export function useBaseMapStyle(lightMode = false): StyleSpecification { + const mapToggles = useMapStore((s) => s.mapToggles); + + if (mapToggles.s57) return ENC_EMPTY_STYLE; + if (mapToggles.threeD) return SATELLITE_3D_STYLE; + if (lightMode) return LIGHT_STYLE; + return BASE_STYLE; +} diff --git a/frontend/src/common/store/mapStore.ts b/frontend/src/common/store/mapStore.ts index 452ef9b..09cf81a 100644 --- a/frontend/src/common/store/mapStore.ts +++ b/frontend/src/common/store/mapStore.ts @@ -55,7 +55,7 @@ const DEFAULT_MAP_TYPES: MapTypeItem[] = [ let measureIdCounter = 0; export const useMapStore = create((set, get) => ({ - mapToggles: { s57: true, s101: false, threeD: false, satellite: false }, + mapToggles: { s57: false, s101: false, threeD: false, satellite: false }, mapTypes: DEFAULT_MAP_TYPES, toggleMap: (key) => set((s) => { @@ -76,10 +76,7 @@ export const useMapStore = create((set, get) => ({ newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false } } - // s57 기본값 유지 - if (newToggles['s57'] === undefined && types.find(t => t.mapKey === 's57')) { - newToggles['s57'] = true - } + // 모든 토글 기본 off (기본지도 표시) set({ mapTypes: types, mapToggles: { ...current, ...newToggles } }) } catch { // API 실패 시 fallback 유지 diff --git a/frontend/src/tabs/admin/components/DispersingZonePanel.tsx b/frontend/src/tabs/admin/components/DispersingZonePanel.tsx index 685e407..aaffa69 100644 --- a/frontend/src/tabs/admin/components/DispersingZonePanel.tsx +++ b/frontend/src/tabs/admin/components/DispersingZonePanel.tsx @@ -3,35 +3,10 @@ import { Map, useControl } from '@vis.gl/react-maplibre'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { GeoJsonLayer } from '@deck.gl/layers'; import type { Layer } from '@deck.gl/core'; -import type { StyleSpecification } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; - -// CartoDB Dark Matter 스타일 -const MAP_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', - ], - tileSize: 256, - attribution: - '© OpenStreetMap © CARTO', - }, - }, - layers: [ - { - id: 'carto-dark-layer', - type: 'raster', - source: 'carto-dark', - minzoom: 0, - maxzoom: 22, - }, - ], -}; +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; +import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; +import { useMapStore } from '@common/store/mapStore'; const MAP_CENTER: [number, number] = [127.5, 36.0]; const MAP_ZOOM = 5.5; @@ -83,6 +58,8 @@ const ZONE_INFO: Record { + const currentMapStyle = useBaseMapStyle(); + const mapToggles = useMapStore((s) => s.mapToggles); const [showConsider, setShowConsider] = useState(true); const [showRestrict, setShowRestrict] = useState(true); const [expandedZone, setExpandedZone] = useState(null); @@ -208,8 +185,9 @@ const DispersingZonePanel = () => { zoom: MAP_ZOOM, }} style={{ width: '100%', height: '100%' }} - mapStyle={MAP_STYLE} + mapStyle={currentMapStyle} > + diff --git a/frontend/src/tabs/aerial/components/CctvView.tsx b/frontend/src/tabs/aerial/components/CctvView.tsx index 4f0f583..843ee5c 100644 --- a/frontend/src/tabs/aerial/components/CctvView.tsx +++ b/frontend/src/tabs/aerial/components/CctvView.tsx @@ -1,8 +1,10 @@ import { useState, useCallback, useEffect, useRef } from 'react' import { Map, Marker, Popup } from '@vis.gl/react-maplibre' -import type { StyleSpecification } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { fetchCctvCameras } from '../services/aerialApi' +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' +import { S57EncOverlay } from '@common/components/map/S57EncOverlay' +import { useMapStore } from '@common/store/mapStore' import type { CctvCameraItem } from '../services/aerialApi' import { CCTVPlayer } from './CCTVPlayer' import type { CCTVPlayerHandle } from './CCTVPlayer' @@ -20,22 +22,6 @@ function kbsCctvUrl(cctvId: number): string { return `/api/aerial/cctv/kbs-hls/${cctvId}/stream.m3u8`; } -/** 지도 스타일 (CartoDB Dark Matter) */ -const MAP_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - }, - }, - layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }], -} const cctvFavorites = [ { name: '여수항 해무관측', reason: '유출 사고 인접' }, @@ -101,6 +87,8 @@ export function CctvView() { const [mapPopup, setMapPopup] = useState(null) const [viewMode, setViewMode] = useState<'list' | 'map'>('map') const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]) + const currentMapStyle = useBaseMapStyle() + const mapToggles = useMapStore((s) => s.mapToggles) /** 지도 모드이거나, 리스트 모드에서 카메라 미선택 시 지도 표시 */ const showMap = viewMode === 'map' && activeCells.length === 0 @@ -440,10 +428,11 @@ export function CctvView() {
+ {filtered.filter(c => c.lon && c.lat).map(cam => ( + {cameras.filter(c => c.lon && c.lat).map(cam => ( = { @@ -14,22 +16,6 @@ const DRONE_POSITIONS: Record([]) const [loading, setLoading] = useState(true) @@ -38,6 +24,8 @@ export function RealtimeDrone() { const [activeCells, setActiveCells] = useState([]) const [mapPopup, setMapPopup] = useState(null) const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]) + const currentMapStyle = useBaseMapStyle() + const mapToggles = useMapStore((s) => s.mapToggles) const showMap = activeCells.length === 0 @@ -261,10 +249,11 @@ export function RealtimeDrone() {
+ {streams.map(stream => { const pos = DRONE_POSITIONS[stream.id] if (!pos) return null diff --git a/frontend/src/tabs/aerial/components/SatelliteRequest.tsx b/frontend/src/tabs/aerial/components/SatelliteRequest.tsx index 6345108..b8987a4 100644 --- a/frontend/src/tabs/aerial/components/SatelliteRequest.tsx +++ b/frontend/src/tabs/aerial/components/SatelliteRequest.tsx @@ -1,8 +1,10 @@ import { useState, useRef, useEffect, useCallback } from 'react' import { Map, Source, Layer } from '@vis.gl/react-maplibre' -import type { StyleSpecification } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { Marker } from '@vis.gl/react-maplibre' +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' +import { S57EncOverlay } from '@common/components/map/S57EncOverlay' +import { useMapStore } from '@common/store/mapStore' import { fetchSatellitePasses } from '../services/aerialApi' const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || '' @@ -71,21 +73,6 @@ const up42Satellites = [ // up42Passes — 실시간 패스로 대체됨 (satPasses from API) -const SAT_MAP_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - }, - }, - layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }], -} /** 좌표 문자열 파싱 ("33.24°N 126.50°E" → {lat, lon}) */ function parseCoord(coordStr: string): { lat: number; lon: number } | null { @@ -119,6 +106,8 @@ export function SatelliteRequest() { const satImgBrightness = 100 const satShowOverlay = true + const currentMapStyle = useBaseMapStyle() + const mapToggles = useMapStore((s) => s.mapToggles) const modalRef = useRef(null) const loadSatPasses = useCallback(async () => { @@ -410,10 +399,11 @@ export function SatelliteRequest() {
+ {/* 선택된 날짜의 촬영 구역 폴리곤 */} {dateFiltered.map(r => { const coord = parseCoord(r.zoneCoord) @@ -963,10 +953,11 @@ export function SatelliteRequest() {
+ {/* 한국 영역 AOI 박스 */} (null); const [filterStatus, setFilterStatus] = useState('전체'); + const currentMapStyle = useBaseMapStyle(); + const mapToggles = useMapStore((s) => s.mapToggles); const detections: VesselDetection[] = [ { id: 'VD-001', mmsi: '440123456', vesselName: 'OCEAN GLORY', aisType: '화물선', detectedType: '유조선', mismatch: true, status: '불일치', confidence: '94.2%', coord: '33.24°N 126.50°E', lon: 126.50, lat: 33.24, time: '14:23', detail: 'AIS 화물선 등록 → 영상 분석 결과 유조선 선형 + 탱크 구조 탐지' }, @@ -159,9 +149,10 @@ function DetectPanel() { + (INITIAL_ZONES); const [selectedZone, setSelectedZone] = useState(null); + const currentMapStyle = useBaseMapStyle(); + const mapToggles = useMapStore((s) => s.mapToggles); // 드로잉 상태 const [isDrawing, setIsDrawing] = useState(false); const [drawingPoints, setDrawingPoints] = useState<[number, number][]>([]); @@ -936,10 +929,11 @@ function AoiPanel() { +
diff --git a/frontend/src/tabs/assets/components/AssetMap.tsx b/frontend/src/tabs/assets/components/AssetMap.tsx index 536939f..a71b435 100644 --- a/frontend/src/tabs/assets/components/AssetMap.tsx +++ b/frontend/src/tabs/assets/components/AssetMap.tsx @@ -2,29 +2,14 @@ import { useMemo, useCallback, useEffect, useRef } from 'react' import { Map, useControl, useMap } from '@vis.gl/react-maplibre' import { MapboxOverlay } from '@deck.gl/mapbox' import { ScatterplotLayer } from '@deck.gl/layers' -import type { StyleSpecification } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' +import { S57EncOverlay } from '@common/components/map/S57EncOverlay' +import { useMapStore } from '@common/store/mapStore' import type { AssetOrgCompat } from '../services/assetsApi' import { typeColor } from './assetTypes' import { hexToRgba } from '@common/components/map/mapUtils' -const BASE_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - attribution: '© OSM © CARTO', - }, - }, - layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }], -} - // ── DeckGLOverlay ────────────────────────────────────── // eslint-disable-next-line @typescript-eslint/no-explicit-any function DeckGLOverlay({ layers }: { layers: any[] }) { @@ -64,6 +49,9 @@ function AssetMap({ regionFilter, onRegionFilterChange, }: AssetMapProps) { + const currentMapStyle = useBaseMapStyle() + const mapToggles = useMapStore((s) => s.mapToggles) + const handleClick = useCallback( (org: AssetOrgCompat) => { onSelectOrg(org) @@ -113,10 +101,11 @@ function AssetMap({
+ diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx index ef6516f..b0f7f73 100755 --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -3,8 +3,9 @@ import { Map as MapLibre, Popup, useControl, useMap } from '@vis.gl/react-maplib import { MapboxOverlay } from '@deck.gl/mapbox' import { ScatterplotLayer, IconLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers' import { PathStyleExtension } from '@deck.gl/extensions' -import type { StyleSpecification } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' +import { S57EncOverlay } from '@common/components/map/S57EncOverlay' import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel' import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel' import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData' @@ -43,24 +44,6 @@ function getCategoryColor(index: number): [number, number, number] { return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length] } -// ── CartoDB Positron 베이스맵 (밝은 테마) ──────────────── -const BASE_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-light': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - attribution: '© OpenStreetMap', - }, - }, - layers: [{ id: 'carto-light-layer', type: 'raster', source: 'carto-light' }], -} - // ── DeckGLOverlay ────────────────────────────────────── // eslint-disable-next-line @typescript-eslint/no-explicit-any function DeckGLOverlay({ layers }: { layers: any[] }) { @@ -146,6 +129,10 @@ export function IncidentsView() { const [dischargeMode, setDischargeMode] = useState(false) const [dischargeInfo, setDischargeInfo] = useState<{ lat: number; lon: number; distanceNm: number } | null>(null) + // Map style & toggles + const currentMapStyle = useBaseMapStyle(true) + const mapToggles = useMapStore((s) => s.mapToggles) + // Measure tool const { handleMeasureClick, measureMode } = useMeasureTool() const measureInProgress = useMapStore((s) => s.measureInProgress) @@ -616,7 +603,7 @@ export function IncidentsView() {
{ @@ -633,6 +620,7 @@ export function IncidentsView() { }} cursor={(measureMode !== null || dischargeMode) ? 'crosshair' : undefined} > + diff --git a/frontend/src/tabs/scat/components/ScatMap.tsx b/frontend/src/tabs/scat/components/ScatMap.tsx index dfe87c2..54f7e3d 100644 --- a/frontend/src/tabs/scat/components/ScatMap.tsx +++ b/frontend/src/tabs/scat/components/ScatMap.tsx @@ -2,30 +2,15 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react' import { Map, useControl, useMap } from '@vis.gl/react-maplibre' import { MapboxOverlay } from '@deck.gl/mapbox' import { PathLayer, ScatterplotLayer } from '@deck.gl/layers' -import type { StyleSpecification } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' +import { S57EncOverlay } from '@common/components/map/S57EncOverlay' +import { useMapStore } from '@common/store/mapStore' import type { ScatSegment } from './scatTypes' import type { ApiZoneItem } from '../services/scatApi' import { esiColor } from './scatConstants' import { hexToRgba } from '@common/components/map/mapUtils' -const BASE_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - attribution: '© OSM © CARTO', - }, - }, - layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }], -} - interface ScatMapProps { segments: ScatSegment[] zones: ApiZoneItem[] @@ -118,6 +103,9 @@ interface TooltipState { // ── ScatMap ───────────────────────────────────────────── function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg, onOpenPopup }: ScatMapProps) { + const currentMapStyle = useBaseMapStyle() + const mapToggles = useMapStore((s) => s.mapToggles) + const [zoom, setZoom] = useState(10) const [tooltip, setTooltip] = useState(null) @@ -266,11 +254,12 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg } return { longitude: 126.55, latitude: 33.38, zoom: 10 }; })()} - mapStyle={BASE_STYLE} + mapStyle={currentMapStyle} className="w-full h-full" attributionControl={false} onZoom={e => setZoom(e.viewState.zoom)} > + diff --git a/frontend/src/tabs/weather/components/WeatherView.tsx b/frontend/src/tabs/weather/components/WeatherView.tsx index 40a7450..653ac49 100755 --- a/frontend/src/tabs/weather/components/WeatherView.tsx +++ b/frontend/src/tabs/weather/components/WeatherView.tsx @@ -2,8 +2,11 @@ import { useState, useMemo, useCallback } from 'react' import { Map, Marker, useControl } from '@vis.gl/react-maplibre' import { MapboxOverlay } from '@deck.gl/mapbox' import type { Layer } from '@deck.gl/core' -import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl' +import type { MapLayerMouseEvent } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' +import { S57EncOverlay } from '@common/components/map/S57EncOverlay' +import { useMapStore } from '@common/store/mapStore' import { WeatherRightPanel } from './WeatherRightPanel' import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay' // import { OceanForecastOverlay } from './OceanForecastOverlay' @@ -82,33 +85,6 @@ const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => { return forecasts } -// CartoDB Dark Matter 스타일 (기존 WeatherView와 동일) -const WEATHER_MAP_STYLE: StyleSpecification = { - version: 8, - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', - ], - tileSize: 256, - attribution: - '© OpenStreetMap © CARTO', - }, - }, - layers: [ - { - id: 'carto-dark-layer', - type: 'raster', - source: 'carto-dark', - minzoom: 0, - maxzoom: 22, - }, - ], -} - // 한국 해역 중심 좌표 (한반도 중앙) const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5] // [lng, lat] const WEATHER_MAP_ZOOM = 7 @@ -226,6 +202,8 @@ function WeatherMapInner({ export function WeatherView() { const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS) + const currentMapStyle = useBaseMapStyle() + const mapToggles = useMapStore((s) => s.mapToggles) // const { @@ -373,11 +351,12 @@ export function WeatherView() { latitude: WEATHER_MAP_CENTER[1], zoom: WEATHER_MAP_ZOOM, }} - mapStyle={WEATHER_MAP_STYLE} + mapStyle={currentMapStyle} className="w-full h-full" onClick={handleMapClick} attributionControl={false} > + Date: Tue, 31 Mar 2026 17:58:42 +0900 Subject: [PATCH 03/13] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 9f222ff..afe9f13 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,14 @@ ## [Unreleased] +### 추가 +- 지도: S-57 전자해도(ENC) 오버레이 레이어 추가 +- 지도: 전체 탭 지도 배경 토글 통합 (S-57/3D/밝은테마/기본지도) +- 공통: useBaseMapStyle 훅 및 mapStyles 공유 모듈 추가 + +### 변경 +- 지도: 초기 접속 시 기본지도(CartoDB Dark Matter) 표시로 변경 (S-57 기본 off) + ## [2026-03-31] ### 추가 From 0bf7587a1bbd67691021d04438d30b7088df8e93 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Tue, 31 Mar 2026 18:04:06 +0900 Subject: [PATCH 04/13] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index afe9f13..d87f4ec 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-31.2] + ### 추가 - 지도: S-57 전자해도(ENC) 오버레이 레이어 추가 - 지도: 전체 탭 지도 배경 토글 통합 (S-57/3D/밝은테마/기본지도) From 0da3adb793b454eb58459e8b179ccd58f753e5a7 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 1 Apr 2026 08:27:41 +0900 Subject: [PATCH 05/13] =?UTF-8?q?fix(map):=20S57EncOverlay=20API=20URL?= =?UTF-8?q?=EC=9D=84=20=EA=B3=B5=EC=9C=A0=20API=5FBASE=5FURL=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VITE_API_URL 하드코딩 제거 → @common/services/api의 API_BASE_URL 사용 --- frontend/src/common/components/map/S57EncOverlay.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/common/components/map/S57EncOverlay.tsx b/frontend/src/common/components/map/S57EncOverlay.tsx index 2408037..9211754 100644 --- a/frontend/src/common/components/map/S57EncOverlay.tsx +++ b/frontend/src/common/components/map/S57EncOverlay.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef } from 'react'; import { useMap } from '@vis.gl/react-maplibre'; +import { API_BASE_URL } from '../../services/api'; -const TILES_BASE = import.meta.env.VITE_API_URL?.replace(/\/api$/, '') || 'http://localhost:3001'; -const PROXY_PREFIX = `${TILES_BASE}/api/tiles/enc`; +const PROXY_PREFIX = `${API_BASE_URL}/tiles/enc`; const ENC_SPRITE_ID = 'enc-s57'; const ENC_SOURCE_ID = 'enc-s57'; From 7d2a889e11c5efc18a03a97ca3c7b6f82f8ce2e4 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 1 Apr 2026 08:30:35 +0900 Subject: [PATCH 06/13] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index d87f4ec..0dfd1a8 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 수정 +- 지도: S57EncOverlay API URL을 공유 API_BASE_URL로 통합 + ## [2026-03-31.2] ### 추가 From a719130f20e77ee780a9af5ff42dfba24404f849 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 1 Apr 2026 08:36:16 +0900 Subject: [PATCH 07/13] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-04-01)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 0dfd1a8..0c83840 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,26 +4,22 @@ ## [Unreleased] +## [2026-04-01] + ### 수정 - 지도: S57EncOverlay API URL을 공유 API_BASE_URL로 통합 -## [2026-03-31.2] +## [2026-03-31] ### 추가 - 지도: S-57 전자해도(ENC) 오버레이 레이어 추가 - 지도: 전체 탭 지도 배경 토글 통합 (S-57/3D/밝은테마/기본지도) - 공통: useBaseMapStyle 훅 및 mapStyles 공유 모듈 추가 - -### 변경 -- 지도: 초기 접속 시 기본지도(CartoDB Dark Matter) 표시로 변경 (S-57 기본 off) - -## [2026-03-31] - -### 추가 - 다크/라이트 테마 전환 기능 (TopBar 퀵메뉴에서 토글) - themeStore (Zustand) 테마 상태 관리 + localStorage 영속화 ### 변경 +- 지도: 초기 접속 시 기본지도(CartoDB Dark Matter) 표시로 변경 (S-57 기본 off) - 디자인 시스템 토큰 시맨틱 네이밍 전환 (하드코딩 색상 → CSS 변수) - PretendardGOV 폰트 적용 - 라이트 테마 CSS 변수 오버라이드 및 컴포넌트별 스타일 대응 From a474cf6d1d10042db539a3eed711a3da7ff02c6e Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 1 Apr 2026 08:55:26 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix(map):=20S57=20ENC=20sprite=20URL?= =?UTF-8?q?=EC=97=90=20origin=20=ED=94=84=EB=A6=AC=ED=94=BD=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/common/components/map/S57EncOverlay.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/common/components/map/S57EncOverlay.tsx b/frontend/src/common/components/map/S57EncOverlay.tsx index 9211754..4384d45 100644 --- a/frontend/src/common/components/map/S57EncOverlay.tsx +++ b/frontend/src/common/components/map/S57EncOverlay.tsx @@ -158,7 +158,10 @@ export function S57EncOverlay({ visible }: S57EncOverlayProps) { // sprite 등록 (중복 방지) if (!hasSprite(map, ENC_SPRITE_ID)) { - map.addSprite(ENC_SPRITE_ID, `${PROXY_PREFIX}/sprite/sprite`); + 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을 프록시로 치환) From 5ae838c3a9c4a05203ca35a14f55ff974370baae Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 1 Apr 2026 08:56:58 +0900 Subject: [PATCH 09/13] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 0dfd1a8..9d3ea14 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -6,6 +6,7 @@ ### 수정 - 지도: S57EncOverlay API URL을 공유 API_BASE_URL로 통합 +- 지도: S57 ENC sprite URL에 상대경로일 때 origin 프리픽스 추가 ## [2026-03-31.2] From dafd6cc1ac53168bddd0aa5ecbad6cd6a868d6a3 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 1 Apr 2026 09:15:58 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix(map):=20S57=20ENC=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=A0=88=EC=9D=B4=20=ED=83=80=EC=9D=BC/sprite/glyphs?= =?UTF-8?q?=20URL=EC=9D=84=20=EC=A0=88=EB=8C=80=EA=B2=BD=EB=A1=9C=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 24 +------------------ .../common/components/map/S57EncOverlay.tsx | 15 ++++++------ .../reports/components/OilSpreadMapPanel.tsx | 2 +- 3 files changed, 10 insertions(+), 31 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 868df2d..441dc35 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,29 +5,7 @@ }, "permissions": { "allow": [ - "Bash(npm run *)", - "Bash(npm install *)", - "Bash(npm test *)", - "Bash(npx *)", - "Bash(node *)", - "Bash(git status)", - "Bash(git diff *)", - "Bash(git log *)", - "Bash(git branch *)", - "Bash(git checkout *)", - "Bash(git add *)", - "Bash(git commit *)", - "Bash(git pull *)", - "Bash(git fetch *)", - "Bash(git merge *)", - "Bash(git stash *)", - "Bash(git remote *)", - "Bash(git config *)", - "Bash(git rev-parse *)", - "Bash(git show *)", - "Bash(git tag *)", - "Bash(curl -s *)", - "Bash(fnm *)" + "Bash(*)" ], "deny": [ "Bash(git push --force*)", diff --git a/frontend/src/common/components/map/S57EncOverlay.tsx b/frontend/src/common/components/map/S57EncOverlay.tsx index 4384d45..692f336 100644 --- a/frontend/src/common/components/map/S57EncOverlay.tsx +++ b/frontend/src/common/components/map/S57EncOverlay.tsx @@ -3,6 +3,10 @@ import { useMap } from '@vis.gl/react-maplibre'; import { API_BASE_URL } from '../../services/api'; const PROXY_PREFIX = `${API_BASE_URL}/tiles/enc`; +// MapLibre 내부 요청(sprite, tiles, glyphs)은 절대 URL이 필요 +const ABSOLUTE_PREFIX = API_BASE_URL.startsWith('http') + ? `${API_BASE_URL}/tiles/enc` + : `${window.location.origin}${API_BASE_URL}/tiles/enc`; const ENC_SPRITE_ID = 'enc-s57'; const ENC_SOURCE_ID = 'enc-s57'; @@ -154,14 +158,11 @@ export function S57EncOverlay({ visible }: S57EncOverlayProps) { // 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}`; + styleObj.glyphs = `${ABSOLUTE_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); + map.addSprite(ENC_SPRITE_ID, `${ABSOLUTE_PREFIX}/sprite/sprite`); } // sources 등록 (타일 URL을 프록시로 치환) @@ -169,7 +170,7 @@ export function S57EncOverlay({ visible }: S57EncOverlayProps) { const globeSrc = style.sources['globe']; map.addSource(GLOBE_SOURCE_ID, { type: 'vector', - tiles: [`${PROXY_PREFIX}/globe/{z}/{x}/{y}`], + tiles: [`${ABSOLUTE_PREFIX}/globe/{z}/{x}/{y}`], minzoom: globeSrc?.minzoom ?? 0, maxzoom: globeSrc?.maxzoom ?? 4, }); @@ -179,7 +180,7 @@ export function S57EncOverlay({ visible }: S57EncOverlayProps) { const encSrc = style.sources['enc']; map.addSource(ENC_SOURCE_ID, { type: 'vector', - tiles: [`${PROXY_PREFIX}/{z}/{x}/{y}`], + tiles: [`${ABSOLUTE_PREFIX}/{z}/{x}/{y}`], minzoom: encSrc?.minzoom ?? 4, maxzoom: encSrc?.maxzoom ?? 17, }); diff --git a/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx b/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx index 06167f8..763e796 100644 --- a/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx +++ b/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx @@ -46,7 +46,7 @@ const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlot
{/* 지도 + 캡처 오버레이 */} -
+
Date: Wed, 1 Apr 2026 09:17:03 +0900 Subject: [PATCH 11/13] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 9d3ea14..a55b0c3 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -7,6 +7,7 @@ ### 수정 - 지도: S57EncOverlay API URL을 공유 API_BASE_URL로 통합 - 지도: S57 ENC sprite URL에 상대경로일 때 origin 프리픽스 추가 +- 지도: S57 ENC 오버레이 타일/sprite/glyphs URL을 절대경로로 변환 (운영환경 상대경로 대응) ## [2026-03-31.2] From 08bcfbf24d4b52c48f257700e8c9a3ed7894b19c Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 1 Apr 2026 19:08:34 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix(map):=20S57=20ENC=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=A0=88=EC=9D=B4=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=99=84=EB=A3=8C=20=EB=8C=80=EA=B8=B0=20?= =?UTF-8?q?=ED=9B=84=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/common/components/map/S57EncOverlay.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/src/common/components/map/S57EncOverlay.tsx b/frontend/src/common/components/map/S57EncOverlay.tsx index 692f336..0ad2e72 100644 --- a/frontend/src/common/components/map/S57EncOverlay.tsx +++ b/frontend/src/common/components/map/S57EncOverlay.tsx @@ -137,14 +137,21 @@ export function S57EncOverlay({ visible }: S57EncOverlayProps) { if (!map) return; if (visible) { - addEncLayers(map); + const doAdd = () => addEncLayers(map); + + if (map.isStyleLoaded()) { + doAdd(); + } else { + map.once('style.load', doAdd); + } + + return () => { + map.off('style.load', doAdd); + removeEncLayers(map); + }; } else { removeEncLayers(map); } - - return () => { - if (map) removeEncLayers(map); - }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible, mapRef]); From 6803fc156c7c31a3d117c4e4ab25d1c787d95042 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 1 Apr 2026 19:11:21 +0900 Subject: [PATCH 13/13] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index a55b0c3..2c1e1b3 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -5,6 +5,7 @@ ## [Unreleased] ### 수정 +- 지도: S57 ENC 오버레이 스타일 로드 완료 대기 후 레이어 추가 - 지도: S57EncOverlay API URL을 공유 API_BASE_URL로 통합 - 지도: S57 ENC sprite URL에 상대경로일 때 origin 프리픽스 추가 - 지도: S57 ENC 오버레이 타일/sprite/glyphs URL을 절대경로로 변환 (운영환경 상대경로 대응)