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() {