feat(map): 전체 탭 지도 배경 토글 통합 및 기본지도 변경
- 지도 스타일 상수를 mapStyles.ts로 추출 - useBaseMapStyle 훅 생성 (mapToggles 기반 스타일 반환) - 9개 탭 컴포넌트의 하드코딩 스타일을 공유 훅으로 교체 - 각 Map에 S57EncOverlay 추가 - 초기 mapToggles를 모두 false로 변경 (기본지도 표시)
This commit is contained in:
부모
5a792bb53c
커밋
a86188f473
@ -3,7 +3,6 @@ import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/r
|
|||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer } from '@deck.gl/layers'
|
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer } from '@deck.gl/layers'
|
||||||
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
|
||||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { layerDatabase } from '@common/services/layerService'
|
import { layerDatabase } from '@common/services/layerService'
|
||||||
@ -19,6 +18,7 @@ import { useMeasureTool } from '@common/hooks/useMeasureTool'
|
|||||||
import { hexToRgba } from './mapUtils'
|
import { hexToRgba } from './mapUtils'
|
||||||
import { S57EncOverlay } from './S57EncOverlay'
|
import { S57EncOverlay } from './S57EncOverlay'
|
||||||
import { useMapStore } from '@common/store/mapStore'
|
import { useMapStore } from '@common/store/mapStore'
|
||||||
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
|
||||||
|
|
||||||
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'
|
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_CENTER: [number, number] = [37.39, 126.64]
|
||||||
const DEFAULT_ZOOM = 10
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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: '© <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 레이어가 자체적으로 육지/해안/수심 모두 포함)
|
|
||||||
const ENC_EMPTY_STYLE: StyleSpecification = {
|
|
||||||
version: 8,
|
|
||||||
sources: {},
|
|
||||||
layers: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모델별 색상 매핑
|
// 모델별 색상 매핑
|
||||||
const MODEL_COLORS: Record<PredictionModel, string> = {
|
const MODEL_COLORS: Record<PredictionModel, string> = {
|
||||||
'KOSPS': '#06b6d4',
|
'KOSPS': '#06b6d4',
|
||||||
@ -1347,9 +1101,7 @@ export function MapView({
|
|||||||
])
|
])
|
||||||
|
|
||||||
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
|
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
|
||||||
const currentMapStyle = mapToggles['s57']
|
const currentMapStyle = useBaseMapStyle(lightMode)
|
||||||
? ENC_EMPTY_STYLE
|
|
||||||
: mapToggles['threeD'] ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative">
|
<div className="w-full h-full relative">
|
||||||
|
|||||||
247
frontend/src/common/components/map/mapStyles.ts
Normal file
247
frontend/src/common/components/map/mapStyles.ts
Normal file
@ -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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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: '© <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: [],
|
||||||
|
};
|
||||||
12
frontend/src/common/hooks/useBaseMapStyle.ts
Normal file
12
frontend/src/common/hooks/useBaseMapStyle.ts
Normal file
@ -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,7 +55,7 @@ const DEFAULT_MAP_TYPES: MapTypeItem[] = [
|
|||||||
let measureIdCounter = 0;
|
let measureIdCounter = 0;
|
||||||
|
|
||||||
export const useMapStore = create<MapState>((set, get) => ({
|
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,
|
mapTypes: DEFAULT_MAP_TYPES,
|
||||||
toggleMap: (key) =>
|
toggleMap: (key) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
@ -76,10 +76,7 @@ export const useMapStore = create<MapState>((set, get) => ({
|
|||||||
newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false
|
newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// s57 기본값 유지
|
// 모든 토글 기본 off (기본지도 표시)
|
||||||
if (newToggles['s57'] === undefined && types.find(t => t.mapKey === 's57')) {
|
|
||||||
newToggles['s57'] = true
|
|
||||||
}
|
|
||||||
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } })
|
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } })
|
||||||
} catch {
|
} catch {
|
||||||
// API 실패 시 fallback 유지
|
// API 실패 시 fallback 유지
|
||||||
|
|||||||
@ -3,35 +3,10 @@ import { Map, useControl } from '@vis.gl/react-maplibre';
|
|||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
import { GeoJsonLayer } from '@deck.gl/layers';
|
import { GeoJsonLayer } from '@deck.gl/layers';
|
||||||
import type { Layer } from '@deck.gl/core';
|
import type { Layer } from '@deck.gl/core';
|
||||||
import type { StyleSpecification } from 'maplibre-gl';
|
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||||
// CartoDB Dark Matter 스타일
|
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||||||
const MAP_STYLE: StyleSpecification = {
|
import { useMapStore } from '@common/store/mapStore';
|
||||||
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:
|
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: 'carto-dark-layer',
|
|
||||||
type: 'raster',
|
|
||||||
source: 'carto-dark',
|
|
||||||
minzoom: 0,
|
|
||||||
maxzoom: 22,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAP_CENTER: [number, number] = [127.5, 36.0];
|
const MAP_CENTER: [number, number] = [127.5, 36.0];
|
||||||
const MAP_ZOOM = 5.5;
|
const MAP_ZOOM = 5.5;
|
||||||
@ -83,6 +58,8 @@ const ZONE_INFO: Record<ZoneKey, { label: string; rows: { key: string; value: st
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DispersingZonePanel = () => {
|
const DispersingZonePanel = () => {
|
||||||
|
const currentMapStyle = useBaseMapStyle();
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||||
const [showConsider, setShowConsider] = useState(true);
|
const [showConsider, setShowConsider] = useState(true);
|
||||||
const [showRestrict, setShowRestrict] = useState(true);
|
const [showRestrict, setShowRestrict] = useState(true);
|
||||||
const [expandedZone, setExpandedZone] = useState<ZoneKey | null>(null);
|
const [expandedZone, setExpandedZone] = useState<ZoneKey | null>(null);
|
||||||
@ -208,8 +185,9 @@ const DispersingZonePanel = () => {
|
|||||||
zoom: MAP_ZOOM,
|
zoom: MAP_ZOOM,
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
mapStyle={MAP_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
<DeckGLOverlay layers={layers} />
|
<DeckGLOverlay layers={layers} />
|
||||||
</Map>
|
</Map>
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Map, Marker, Popup } from '@vis.gl/react-maplibre'
|
import { Map, Marker, Popup } from '@vis.gl/react-maplibre'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { fetchCctvCameras } from '../services/aerialApi'
|
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 type { CctvCameraItem } from '../services/aerialApi'
|
||||||
import { CCTVPlayer } from './CCTVPlayer'
|
import { CCTVPlayer } from './CCTVPlayer'
|
||||||
import type { CCTVPlayerHandle } 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`;
|
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 = [
|
const cctvFavorites = [
|
||||||
{ name: '여수항 해무관측', reason: '유출 사고 인접' },
|
{ name: '여수항 해무관측', reason: '유출 사고 인접' },
|
||||||
@ -101,6 +87,8 @@ export function CctvView() {
|
|||||||
const [mapPopup, setMapPopup] = useState<CctvCameraItem | null>(null)
|
const [mapPopup, setMapPopup] = useState<CctvCameraItem | null>(null)
|
||||||
const [viewMode, setViewMode] = useState<'list' | 'map'>('map')
|
const [viewMode, setViewMode] = useState<'list' | 'map'>('map')
|
||||||
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
|
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
|
||||||
|
const currentMapStyle = useBaseMapStyle()
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||||
|
|
||||||
/** 지도 모드이거나, 리스트 모드에서 카메라 미선택 시 지도 표시 */
|
/** 지도 모드이거나, 리스트 모드에서 카메라 미선택 시 지도 표시 */
|
||||||
const showMap = viewMode === 'map' && activeCells.length === 0
|
const showMap = viewMode === 'map' && activeCells.length === 0
|
||||||
@ -440,10 +428,11 @@ export function CctvView() {
|
|||||||
<div className="flex-1 overflow-hidden relative">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
<Map
|
<Map
|
||||||
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
|
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
|
||||||
mapStyle={MAP_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
{filtered.filter(c => c.lon && c.lat).map(cam => (
|
{filtered.filter(c => c.lon && c.lat).map(cam => (
|
||||||
<Marker
|
<Marker
|
||||||
key={cam.cctvSn}
|
key={cam.cctvSn}
|
||||||
@ -563,11 +552,12 @@ export function CctvView() {
|
|||||||
<div className="w-full shrink-0 relative h-[210px] overflow-hidden">
|
<div className="w-full shrink-0 relative h-[210px] overflow-hidden">
|
||||||
<Map
|
<Map
|
||||||
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 5.3 }}
|
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 5.3 }}
|
||||||
mapStyle={MAP_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
interactive={true}
|
interactive={true}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
{cameras.filter(c => c.lon && c.lat).map(cam => (
|
{cameras.filter(c => c.lon && c.lat).map(cam => (
|
||||||
<Marker
|
<Marker
|
||||||
key={`mini-${cam.cctvSn}`}
|
key={`mini-${cam.cctvSn}`}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Map, Marker, Popup } from '@vis.gl/react-maplibre'
|
import { Map, Marker, Popup } from '@vis.gl/react-maplibre'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi'
|
import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi'
|
||||||
import type { DroneStreamItem } from '../services/aerialApi'
|
import type { DroneStreamItem } from '../services/aerialApi'
|
||||||
import { CCTVPlayer } from './CCTVPlayer'
|
import { CCTVPlayer } from './CCTVPlayer'
|
||||||
import type { CCTVPlayerHandle } 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 } }> = {
|
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 } },
|
'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() {
|
export function RealtimeDrone() {
|
||||||
const [streams, setStreams] = useState<DroneStreamItem[]>([])
|
const [streams, setStreams] = useState<DroneStreamItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -38,6 +24,8 @@ export function RealtimeDrone() {
|
|||||||
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([])
|
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([])
|
||||||
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(null)
|
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(null)
|
||||||
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
|
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
|
||||||
|
const currentMapStyle = useBaseMapStyle()
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||||
|
|
||||||
const showMap = activeCells.length === 0
|
const showMap = activeCells.length === 0
|
||||||
|
|
||||||
@ -261,10 +249,11 @@ export function RealtimeDrone() {
|
|||||||
<div className="flex-1 overflow-hidden relative">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
<Map
|
<Map
|
||||||
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
|
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
|
||||||
mapStyle={DRONE_MAP_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
{streams.map(stream => {
|
{streams.map(stream => {
|
||||||
const pos = DRONE_POSITIONS[stream.id]
|
const pos = DRONE_POSITIONS[stream.id]
|
||||||
if (!pos) return null
|
if (!pos) return null
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { Map, Source, Layer } from '@vis.gl/react-maplibre'
|
import { Map, Source, Layer } from '@vis.gl/react-maplibre'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { Marker } from '@vis.gl/react-maplibre'
|
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'
|
import { fetchSatellitePasses } from '../services/aerialApi'
|
||||||
|
|
||||||
const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || ''
|
const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || ''
|
||||||
@ -71,21 +73,6 @@ const up42Satellites = [
|
|||||||
|
|
||||||
// up42Passes — 실시간 패스로 대체됨 (satPasses from API)
|
// 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}) */
|
/** 좌표 문자열 파싱 ("33.24°N 126.50°E" → {lat, lon}) */
|
||||||
function parseCoord(coordStr: string): { lat: number; lon: number } | null {
|
function parseCoord(coordStr: string): { lat: number; lon: number } | null {
|
||||||
@ -119,6 +106,8 @@ export function SatelliteRequest() {
|
|||||||
const satImgBrightness = 100
|
const satImgBrightness = 100
|
||||||
const satShowOverlay = true
|
const satShowOverlay = true
|
||||||
|
|
||||||
|
const currentMapStyle = useBaseMapStyle()
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||||
const modalRef = useRef<HTMLDivElement>(null)
|
const modalRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const loadSatPasses = useCallback(async () => {
|
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)' }}>
|
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden relative" style={{ height: 'calc(100vh - 160px)' }}>
|
||||||
<Map
|
<Map
|
||||||
initialViewState={{ longitude: 127.5, latitude: 34.5, zoom: 7 }}
|
initialViewState={{ longitude: 127.5, latitude: 34.5, zoom: 7 }}
|
||||||
mapStyle={SAT_MAP_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
{/* 선택된 날짜의 촬영 구역 폴리곤 */}
|
{/* 선택된 날짜의 촬영 구역 폴리곤 */}
|
||||||
{dateFiltered.map(r => {
|
{dateFiltered.map(r => {
|
||||||
const coord = parseCoord(r.zoneCoord)
|
const coord = parseCoord(r.zoneCoord)
|
||||||
@ -963,10 +953,11 @@ export function SatelliteRequest() {
|
|||||||
<div className="flex-1 relative" style={{ minHeight: 350 }}>
|
<div className="flex-1 relative" style={{ minHeight: 350 }}>
|
||||||
<Map
|
<Map
|
||||||
initialViewState={{ longitude: 128, latitude: 36, zoom: 5.5 }}
|
initialViewState={{ longitude: 128, latitude: 36, zoom: 5.5 }}
|
||||||
mapStyle={SAT_MAP_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
{/* 한국 영역 AOI 박스 */}
|
{/* 한국 영역 AOI 박스 */}
|
||||||
<Source id="korea-aoi" type="geojson" data={{
|
<Source id="korea-aoi" type="geojson" data={{
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
|
|||||||
@ -2,8 +2,11 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
|||||||
import { Map, useControl } from '@vis.gl/react-maplibre';
|
import { Map, useControl } from '@vis.gl/react-maplibre';
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
import { PolygonLayer, ScatterplotLayer } from '@deck.gl/layers';
|
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 '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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||||
@ -12,21 +15,6 @@ function DeckGLOverlay({ layers }: { layers: any[] }) {
|
|||||||
return null;
|
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';
|
type WingAITab = 'detect' | 'change' | 'aoi';
|
||||||
|
|
||||||
@ -104,6 +92,8 @@ interface VesselDetection {
|
|||||||
function DetectPanel() {
|
function DetectPanel() {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [filterStatus, setFilterStatus] = useState<MismatchStatus | '전체'>('전체');
|
const [filterStatus, setFilterStatus] = useState<MismatchStatus | '전체'>('전체');
|
||||||
|
const currentMapStyle = useBaseMapStyle();
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||||
|
|
||||||
const detections: VesselDetection[] = [
|
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 화물선 등록 → 영상 분석 결과 유조선 선형 + 탱크 구조 탐지' },
|
{ 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
|
<Map
|
||||||
initialViewState={{ longitude: 127.5, latitude: 34.0, zoom: 6.5 }}
|
initialViewState={{ longitude: 127.5, latitude: 34.0, zoom: 6.5 }}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
mapStyle={AOI_MAP_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
<DeckGLOverlay layers={[
|
<DeckGLOverlay layers={[
|
||||||
new ScatterplotLayer({
|
new ScatterplotLayer({
|
||||||
id: 'vessel-detect-markers',
|
id: 'vessel-detect-markers',
|
||||||
@ -654,6 +645,8 @@ const INITIAL_ZONES: MonitorZone[] = [
|
|||||||
function AoiPanel() {
|
function AoiPanel() {
|
||||||
const [zones, setZones] = useState<MonitorZone[]>(INITIAL_ZONES);
|
const [zones, setZones] = useState<MonitorZone[]>(INITIAL_ZONES);
|
||||||
const [selectedZone, setSelectedZone] = useState<string | null>(null);
|
const [selectedZone, setSelectedZone] = useState<string | null>(null);
|
||||||
|
const currentMapStyle = useBaseMapStyle();
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||||
// 드로잉 상태
|
// 드로잉 상태
|
||||||
const [isDrawing, setIsDrawing] = useState(false);
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
const [drawingPoints, setDrawingPoints] = useState<[number, number][]>([]);
|
const [drawingPoints, setDrawingPoints] = useState<[number, number][]>([]);
|
||||||
@ -936,10 +929,11 @@ function AoiPanel() {
|
|||||||
<Map
|
<Map
|
||||||
initialViewState={{ longitude: 127.8, latitude: 34.5, zoom: 6.5 }}
|
initialViewState={{ longitude: 127.8, latitude: 34.5, zoom: 6.5 }}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
mapStyle={AOI_MAP_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
onClick={handleMapClick}
|
onClick={handleMapClick}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
</Map>
|
</Map>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,29 +2,14 @@ import { useMemo, useCallback, useEffect, useRef } from 'react'
|
|||||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import { ScatterplotLayer } from '@deck.gl/layers'
|
import { ScatterplotLayer } from '@deck.gl/layers'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
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 type { AssetOrgCompat } from '../services/assetsApi'
|
||||||
import { typeColor } from './assetTypes'
|
import { typeColor } from './assetTypes'
|
||||||
import { hexToRgba } from '@common/components/map/mapUtils'
|
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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DeckGLOverlay ──────────────────────────────────────
|
// ── DeckGLOverlay ──────────────────────────────────────
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||||
@ -64,6 +49,9 @@ function AssetMap({
|
|||||||
regionFilter,
|
regionFilter,
|
||||||
onRegionFilterChange,
|
onRegionFilterChange,
|
||||||
}: AssetMapProps) {
|
}: AssetMapProps) {
|
||||||
|
const currentMapStyle = useBaseMapStyle()
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(org: AssetOrgCompat) => {
|
(org: AssetOrgCompat) => {
|
||||||
onSelectOrg(org)
|
onSelectOrg(org)
|
||||||
@ -113,10 +101,11 @@ function AssetMap({
|
|||||||
<div className="w-full h-full relative">
|
<div className="w-full h-full relative">
|
||||||
<Map
|
<Map
|
||||||
initialViewState={{ longitude: 127.8, latitude: 35.9, zoom: 7 }}
|
initialViewState={{ longitude: 127.8, latitude: 35.9, zoom: 7 }}
|
||||||
mapStyle={BASE_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
<DeckGLOverlay layers={[markerLayer]} />
|
<DeckGLOverlay layers={[markerLayer]} />
|
||||||
<FlyToController selectedOrg={selectedOrg} />
|
<FlyToController selectedOrg={selectedOrg} />
|
||||||
</Map>
|
</Map>
|
||||||
|
|||||||
@ -3,8 +3,9 @@ import { Map as MapLibre, Popup, useControl, useMap } from '@vis.gl/react-maplib
|
|||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import { ScatterplotLayer, IconLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers'
|
import { ScatterplotLayer, IconLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers'
|
||||||
import { PathStyleExtension } from '@deck.gl/extensions'
|
import { PathStyleExtension } from '@deck.gl/extensions'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
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 { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'
|
||||||
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'
|
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'
|
||||||
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'
|
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]
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [{ id: 'carto-light-layer', type: 'raster', source: 'carto-light' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DeckGLOverlay ──────────────────────────────────────
|
// ── DeckGLOverlay ──────────────────────────────────────
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||||
@ -146,6 +129,10 @@ export function IncidentsView() {
|
|||||||
const [dischargeMode, setDischargeMode] = useState(false)
|
const [dischargeMode, setDischargeMode] = useState(false)
|
||||||
const [dischargeInfo, setDischargeInfo] = useState<{ lat: number; lon: number; distanceNm: number } | null>(null)
|
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
|
// Measure tool
|
||||||
const { handleMeasureClick, measureMode } = useMeasureTool()
|
const { handleMeasureClick, measureMode } = useMeasureTool()
|
||||||
const measureInProgress = useMapStore((s) => s.measureInProgress)
|
const measureInProgress = useMapStore((s) => s.measureInProgress)
|
||||||
@ -616,7 +603,7 @@ export function IncidentsView() {
|
|||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<MapLibre
|
<MapLibre
|
||||||
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
|
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
|
||||||
mapStyle={BASE_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
style={{ width: '100%', height: '100%', background: '#f0f0f0' }}
|
style={{ width: '100%', height: '100%', background: '#f0f0f0' }}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -633,6 +620,7 @@ export function IncidentsView() {
|
|||||||
}}
|
}}
|
||||||
cursor={(measureMode !== null || dischargeMode) ? 'crosshair' : undefined}
|
cursor={(measureMode !== null || dischargeMode) ? 'crosshair' : undefined}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
<FlyToController incident={selectedIncident} />
|
<FlyToController incident={selectedIncident} />
|
||||||
<MeasureOverlay />
|
<MeasureOverlay />
|
||||||
|
|||||||
@ -2,30 +2,15 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
|||||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'
|
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
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 { ScatSegment } from './scatTypes'
|
||||||
import type { ApiZoneItem } from '../services/scatApi'
|
import type { ApiZoneItem } from '../services/scatApi'
|
||||||
import { esiColor } from './scatConstants'
|
import { esiColor } from './scatConstants'
|
||||||
import { hexToRgba } from '@common/components/map/mapUtils'
|
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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScatMapProps {
|
interface ScatMapProps {
|
||||||
segments: ScatSegment[]
|
segments: ScatSegment[]
|
||||||
zones: ApiZoneItem[]
|
zones: ApiZoneItem[]
|
||||||
@ -118,6 +103,9 @@ interface TooltipState {
|
|||||||
|
|
||||||
// ── ScatMap ─────────────────────────────────────────────
|
// ── ScatMap ─────────────────────────────────────────────
|
||||||
function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg, onOpenPopup }: ScatMapProps) {
|
function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg, onOpenPopup }: ScatMapProps) {
|
||||||
|
const currentMapStyle = useBaseMapStyle()
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||||
|
|
||||||
const [zoom, setZoom] = useState(10)
|
const [zoom, setZoom] = useState(10)
|
||||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
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 };
|
return { longitude: 126.55, latitude: 33.38, zoom: 10 };
|
||||||
})()}
|
})()}
|
||||||
mapStyle={BASE_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
onZoom={e => setZoom(e.viewState.zoom)}
|
onZoom={e => setZoom(e.viewState.zoom)}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
<FlyToController selectedSeg={selectedSeg} zones={zones} />
|
<FlyToController selectedSeg={selectedSeg} zones={zones} />
|
||||||
</Map>
|
</Map>
|
||||||
|
|||||||
@ -2,8 +2,11 @@ import { useState, useMemo, useCallback } from 'react'
|
|||||||
import { Map, Marker, useControl } from '@vis.gl/react-maplibre'
|
import { Map, Marker, useControl } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import type { Layer } from '@deck.gl/core'
|
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 '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 { WeatherRightPanel } from './WeatherRightPanel'
|
||||||
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
|
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
|
||||||
// import { OceanForecastOverlay } from './OceanForecastOverlay'
|
// import { OceanForecastOverlay } from './OceanForecastOverlay'
|
||||||
@ -82,33 +85,6 @@ const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => {
|
|||||||
return forecasts
|
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:
|
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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_CENTER: [number, number] = [127.8, 36.5] // [lng, lat]
|
||||||
const WEATHER_MAP_ZOOM = 7
|
const WEATHER_MAP_ZOOM = 7
|
||||||
@ -226,6 +202,8 @@ function WeatherMapInner({
|
|||||||
|
|
||||||
export function WeatherView() {
|
export function WeatherView() {
|
||||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
||||||
|
const currentMapStyle = useBaseMapStyle()
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||||
|
|
||||||
|
|
||||||
// const {
|
// const {
|
||||||
@ -373,11 +351,12 @@ export function WeatherView() {
|
|||||||
latitude: WEATHER_MAP_CENTER[1],
|
latitude: WEATHER_MAP_CENTER[1],
|
||||||
zoom: WEATHER_MAP_ZOOM,
|
zoom: WEATHER_MAP_ZOOM,
|
||||||
}}
|
}}
|
||||||
mapStyle={WEATHER_MAP_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
onClick={handleMapClick}
|
onClick={handleMapClick}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
<WeatherMapInner
|
<WeatherMapInner
|
||||||
weatherStations={weatherStations}
|
weatherStations={weatherStations}
|
||||||
enabledLayers={enabledLayers}
|
enabledLayers={enabledLayers}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user