Merge pull request 'feat(map): S-57 �����ص� �������� �� ��ü �� ���� ���� ���� ����' (#145) from feature/sensitive-resource-layer into develop

This commit is contained in:
jhkang 2026-03-31 18:01:05 +09:00
커밋 d931219169
17개의 변경된 파일694개의 추가작업 그리고 457개의 파일을 삭제

파일 보기

@ -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

파일 보기

@ -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;

파일 보기

@ -4,6 +4,14 @@
## [Unreleased]
### 추가
- 지도: S-57 전자해도(ENC) 오버레이 레이어 추가
- 지도: 전체 탭 지도 배경 토글 통합 (S-57/3D/밝은테마/기본지도)
- 공통: useBaseMapStyle 훅 및 mapStyles 공유 모듈 추가
### 변경
- 지도: 초기 접속 시 기본지도(CartoDB Dark Matter) 표시로 변경 (S-57 기본 off)
## [2026-03-31]
### 추가

파일 보기

@ -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'
@ -17,7 +16,9 @@ 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'
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'
@ -25,245 +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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
},
},
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: '&copy; <a href="https://www.vworld.kr">국토지리정보원 VWorld</a>',
},
'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,
},
},
],
}
// 모델별 색상 매핑
const MODEL_COLORS: Record<PredictionModel, string> = {
'KOSPS': '#06b6d4',
@ -1339,7 +1101,7 @@ export function MapView({
])
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
const currentMapStyle = mapToggles['threeD'] ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE
const currentMapStyle = useBaseMapStyle(lightMode)
return (
<div className="w-full h-full relative">
@ -1369,6 +1131,9 @@ export function MapView({
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
{/* S-57 전자해도 오버레이 (공식 style.json 기반) */}
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{/* WMS 레이어 */}
{wmsLayers.map(layer => (
<Source

파일 보기

@ -0,0 +1,260 @@
import { useEffect, useRef } from 'react';
import { useMap } from '@vis.gl/react-maplibre';
const TILES_BASE = import.meta.env.VITE_API_URL?.replace(/\/api$/, '') || 'http://localhost:3001';
const PROXY_PREFIX = `${TILES_BASE}/api/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<string> | null = null;
interface EncStyleLayer {
id: string;
type: string;
source?: string;
'source-layer'?: string;
filter?: unknown;
layout?: Record<string, unknown>;
paint?: Record<string, unknown>;
minzoom?: number;
maxzoom?: number;
}
interface EncStyle {
layers: EncStyleLayer[];
sources: Record<string, {
type: string;
tiles: string[];
minzoom?: number;
maxzoom?: number;
}>;
}
// style.json 캐시
let cachedStyle: EncStyle | null = null;
async function loadEncStyle(): Promise<EncStyle> {
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<Set<string>> {
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<string>): 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<string>,
): 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<string[]>([]);
const sourcesAddedRef = useRef(false);
const originalGlyphsRef = useRef<string | undefined>(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;
}

파일 보기

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
},
},
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: '&copy; <a href="https://www.vworld.kr">국토지리정보원 VWorld</a>',
},
'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: [],
};

파일 보기

@ -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;
}

파일 보기

@ -55,12 +55,16 @@ const DEFAULT_MAP_TYPES: MapTypeItem[] = [
let measureIdCounter = 0;
export const useMapStore = create<MapState>((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) => ({
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<MapTypeItem[]>('/map-base/active')
@ -72,10 +76,7 @@ export const useMapStore = create<MapState>((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 유지

파일 보기

@ -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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
},
},
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<ZoneKey, { label: string; rows: { key: string; value: st
};
const DispersingZonePanel = () => {
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
const [showConsider, setShowConsider] = useState(true);
const [showRestrict, setShowRestrict] = useState(true);
const [expandedZone, setExpandedZone] = useState<ZoneKey | null>(null);
@ -208,8 +185,9 @@ const DispersingZonePanel = () => {
zoom: MAP_ZOOM,
}}
style={{ width: '100%', height: '100%' }}
mapStyle={MAP_STYLE}
mapStyle={currentMapStyle}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={layers} />
</Map>

파일 보기

@ -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<CctvCameraItem | null>(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() {
<div className="flex-1 overflow-hidden relative">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
mapStyle={MAP_STYLE}
mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{filtered.filter(c => c.lon && c.lat).map(cam => (
<Marker
key={cam.cctvSn}
@ -563,11 +552,12 @@ export function CctvView() {
<div className="w-full shrink-0 relative h-[210px] overflow-hidden">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 5.3 }}
mapStyle={MAP_STYLE}
mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
interactive={true}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{cameras.filter(c => c.lon && c.lat).map(cam => (
<Marker
key={`mini-${cam.cctvSn}`}

파일 보기

@ -1,11 +1,13 @@
import { useState, useEffect, useCallback, 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 { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi'
import type { DroneStreamItem } from '../services/aerialApi'
import { CCTVPlayer } from './CCTVPlayer'
import type { CCTVPlayerHandle } from './CCTVPlayer'
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
import { useMapStore } from '@common/store/mapStore'
/** 함정 위치 + 드론 비행 위치 */
const DRONE_POSITIONS: Record<string, { ship: { lat: number; lon: number }; drone: { lat: number; lon: number } }> = {
@ -14,22 +16,6 @@ const DRONE_POSITIONS: Record<string, { ship: { lat: number; lon: number }; dron
'mokpo-3015': { ship: { lat: 34.7780, lon: 126.3780 }, drone: { lat: 34.8050, lon: 126.4100 } },
}
const DRONE_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 }],
}
export function RealtimeDrone() {
const [streams, setStreams] = useState<DroneStreamItem[]>([])
const [loading, setLoading] = useState(true)
@ -38,6 +24,8 @@ export function RealtimeDrone() {
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([])
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(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() {
<div className="flex-1 overflow-hidden relative">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
mapStyle={DRONE_MAP_STYLE}
mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{streams.map(stream => {
const pos = DRONE_POSITIONS[stream.id]
if (!pos) return null

파일 보기

@ -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<HTMLDivElement>(null)
const loadSatPasses = useCallback(async () => {
@ -410,10 +399,11 @@ export function SatelliteRequest() {
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden relative" style={{ height: 'calc(100vh - 160px)' }}>
<Map
initialViewState={{ longitude: 127.5, latitude: 34.5, zoom: 7 }}
mapStyle={SAT_MAP_STYLE}
mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{/* 선택된 날짜의 촬영 구역 폴리곤 */}
{dateFiltered.map(r => {
const coord = parseCoord(r.zoneCoord)
@ -963,10 +953,11 @@ export function SatelliteRequest() {
<div className="flex-1 relative" style={{ minHeight: 350 }}>
<Map
initialViewState={{ longitude: 128, latitude: 36, zoom: 5.5 }}
mapStyle={SAT_MAP_STYLE}
mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{/* 한국 영역 AOI 박스 */}
<Source id="korea-aoi" type="geojson" data={{
type: 'Feature',

파일 보기

@ -2,8 +2,11 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { Map, useControl } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { PolygonLayer, ScatterplotLayer } from '@deck.gl/layers';
import type { StyleSpecification, MapMouseEvent } from 'maplibre-gl';
import type { MapMouseEvent } 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';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
@ -12,21 +15,6 @@ function DeckGLOverlay({ layers }: { layers: any[] }) {
return null;
}
const AOI_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 }],
};
type WingAITab = 'detect' | 'change' | 'aoi';
@ -104,6 +92,8 @@ interface VesselDetection {
function DetectPanel() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<MismatchStatus | '전체'>('전체');
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() {
<Map
initialViewState={{ longitude: 127.5, latitude: 34.0, zoom: 6.5 }}
style={{ width: '100%', height: '100%' }}
mapStyle={AOI_MAP_STYLE}
mapStyle={currentMapStyle}
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={[
new ScatterplotLayer({
id: 'vessel-detect-markers',
@ -654,6 +645,8 @@ const INITIAL_ZONES: MonitorZone[] = [
function AoiPanel() {
const [zones, setZones] = useState<MonitorZone[]>(INITIAL_ZONES);
const [selectedZone, setSelectedZone] = useState<string | null>(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() {
<Map
initialViewState={{ longitude: 127.8, latitude: 34.5, zoom: 6.5 }}
style={{ width: '100%', height: '100%' }}
mapStyle={AOI_MAP_STYLE}
mapStyle={currentMapStyle}
attributionControl={false}
onClick={handleMapClick}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={deckLayers} />
</Map>
</div>

파일 보기

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
},
},
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({
<div className="w-full h-full relative">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.9, zoom: 7 }}
mapStyle={BASE_STYLE}
mapStyle={currentMapStyle}
className="w-full h-full"
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={[markerLayer]} />
<FlyToController selectedOrg={selectedOrg} />
</Map>

파일 보기

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
},
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() {
<div className="absolute inset-0">
<MapLibre
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
mapStyle={BASE_STYLE}
mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%', background: '#f0f0f0' }}
attributionControl={false}
onClick={(e) => {
@ -633,6 +620,7 @@ export function IncidentsView() {
}}
cursor={(measureMode !== null || dischargeMode) ? 'crosshair' : undefined}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={deckLayers} />
<FlyToController incident={selectedIncident} />
<MeasureOverlay />

파일 보기

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
},
},
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<TooltipState | null>(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)}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={deckLayers} />
<FlyToController selectedSeg={selectedSeg} zones={zones} />
</Map>

파일 보기

@ -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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
},
},
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}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<WeatherMapInner
weatherStations={weatherStations}
enabledLayers={enabledLayers}