release: 2026-04-02 (229건 커밋) #159

병합
dnlee develop 에서 main 로 7 commits 를 머지했습니다 2026-04-02 16:54:57 +09:00
19개의 변경된 파일3338개의 추가작업 그리고 6402개의 파일을 삭제
Showing only changes of commit 76ab75f561 - Show all commits

파일 보기

@ -5,29 +5,7 @@
},
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(npm install *)",
"Bash(npm test *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git pull *)",
"Bash(git fetch *)",
"Bash(git merge *)",
"Bash(git stash *)",
"Bash(git remote *)",
"Bash(git config *)",
"Bash(git rev-parse *)",
"Bash(git show *)",
"Bash(git tag *)",
"Bash(curl -s *)",
"Bash(fnm *)"
"Bash(*)"
],
"deny": [
"Bash(git push --force*)",

파일 보기

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

파일 보기

@ -11,13 +11,25 @@
- 구조 탭: RescueView
- 하드코딩 색상(#hex, rgba) → CSS 변수 전환, 그라데이션 → 단색, fontFamily/fontSize → Tailwind 토큰
## [2026-04-01]
### 수정
- 지도: S57 ENC 오버레이 스타일 로드 완료 대기 후 레이어 추가
- 지도: S57EncOverlay API URL을 공유 API_BASE_URL로 통합
- 지도: S57 ENC sprite URL에 상대경로일 때 origin 프리픽스 추가
- 지도: S57 ENC 오버레이 타일/sprite/glyphs URL을 절대경로로 변환 (운영환경 상대경로 대응)
## [2026-03-31]
### 추가
- 지도: S-57 전자해도(ENC) 오버레이 레이어 추가
- 지도: 전체 탭 지도 배경 토글 통합 (S-57/3D/밝은테마/기본지도)
- 공통: useBaseMapStyle 훅 및 mapStyles 공유 모듈 추가
- 다크/라이트 테마 전환 기능 (TopBar 퀵메뉴에서 토글)
- themeStore (Zustand) 테마 상태 관리 + localStorage 영속화
### 변경
- 지도: 초기 접속 시 기본지도(CartoDB Dark Matter) 표시로 변경 (S-57 기본 off)
- 디자인 시스템 토큰 시맨틱 네이밍 전환 (하드코딩 색상 → CSS 변수)
- PretendardGOV 폰트 적용
- 라이트 테마 CSS 변수 오버라이드 및 컴포넌트별 스타일 대응

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,274 @@
import { useEffect, useRef } from 'react';
import { useMap } from '@vis.gl/react-maplibre';
import { API_BASE_URL } from '../../services/api';
const PROXY_PREFIX = `${API_BASE_URL}/tiles/enc`;
// MapLibre 내부 요청(sprite, tiles, glyphs)은 절대 URL이 필요
const ABSOLUTE_PREFIX = API_BASE_URL.startsWith('http')
? `${API_BASE_URL}/tiles/enc`
: `${window.location.origin}${API_BASE_URL}/tiles/enc`;
const ENC_SPRITE_ID = 'enc-s57';
const ENC_SOURCE_ID = 'enc-s57';
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) {
const doAdd = () => addEncLayers(map);
if (map.isStyleLoaded()) {
doAdd();
} else {
map.once('style.load', doAdd);
}
return () => {
map.off('style.load', doAdd);
removeEncLayers(map);
};
} else {
removeEncLayers(map);
}
// 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 = `${ABSOLUTE_PREFIX}/font/{fontstack}/{range}`;
// sprite 등록 (중복 방지)
if (!hasSprite(map, ENC_SPRITE_ID)) {
map.addSprite(ENC_SPRITE_ID, `${ABSOLUTE_PREFIX}/sprite/sprite`);
}
// sources 등록 (타일 URL을 프록시로 치환)
if (!map.getSource(GLOBE_SOURCE_ID)) {
const globeSrc = style.sources['globe'];
map.addSource(GLOBE_SOURCE_ID, {
type: 'vector',
tiles: [`${ABSOLUTE_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: [`${ABSOLUTE_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,248 @@
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,17 @@
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;
}

파일 보기

@ -1,6 +1,6 @@
import { create } from 'zustand';
import { api } from '../services/api';
import { haversineDistance, polygonAreaKm2 } from '../utils/geo';
import { create } from 'zustand'
import { api } from '../services/api'
import { haversineDistance, polygonAreaKm2 } from '../utils/geo'
export interface MapTypeItem {
mapKey: string;
@ -46,37 +46,38 @@ interface MapState {
}
const DEFAULT_MAP_TYPES: MapTypeItem[] = [
{ mapKey: 's57', mapNm: 'S-57 전자해도', mapLevelCd: 'S-57' },
{ mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' },
{ mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' },
{ mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' },
];
{ mapKey: 's57', mapNm: 'S-57 전자해도', mapLevelCd: 'S-57' },
{ mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' },
{ mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' },
{ mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' },
]
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');
const types = res.data;
const current = get().mapToggles;
const newToggles: Partial<MapToggles> = {};
const res = await api.get<MapTypeItem[]>('/map-base/active')
const types = res.data
const current = get().mapToggles
const newToggles: Partial<MapToggles> = {}
for (const t of types) {
if (t.mapKey in current) {
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 기본값 유지
if (newToggles['s57'] === undefined && types.find((t) => t.mapKey === 's57')) {
newToggles['s57'] = true;
}
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } });
// 모든 토글 기본 off (기본지도 표시)
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } })
} catch {
// API 실패 시 fallback 유지
}
@ -87,7 +88,8 @@ export const useMapStore = create<MapState>((set, get) => ({
measureInProgress: [],
measurements: [],
setMeasureMode: (mode) => set({ measureMode: mode, measureInProgress: [] }),
setMeasureMode: (mode) =>
set({ measureMode: mode, measureInProgress: [] }),
addMeasurePoint: (pt) => {
const { measureMode, measureInProgress } = get();
@ -97,10 +99,7 @@ export const useMapStore = create<MapState>((set, get) => ({
const dist = haversineDistance(next[0], next[1]);
const id = `measure-${++measureIdCounter}`;
set((s) => ({
measurements: [
...s.measurements,
{ id, mode: 'distance', points: [next[0], next[1]], value: dist },
],
measurements: [...s.measurements, { id, mode: 'distance', points: [next[0], next[1]], value: dist }],
measureInProgress: [],
}));
} else {
@ -117,10 +116,7 @@ export const useMapStore = create<MapState>((set, get) => ({
const area = polygonAreaKm2(measureInProgress);
const id = `measure-${++measureIdCounter}`;
set((s) => ({
measurements: [
...s.measurements,
{ id, mode: 'area', points: [...measureInProgress], value: area },
],
measurements: [...s.measurements, { id, mode: 'area', points: [...measureInProgress], value: area }],
measureInProgress: [],
}));
},
@ -128,5 +124,6 @@ export const useMapStore = create<MapState>((set, get) => ({
removeMeasurement: (id) =>
set((s) => ({ measurements: s.measurements.filter((m) => m.id !== id) })),
clearAllMeasurements: () => set({ measurements: [], measureInProgress: [], measureMode: null }),
}));
clearAllMeasurements: () =>
set({ measurements: [], measureInProgress: [], measureMode: null }),
}))

파일 보기

@ -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;
@ -82,6 +57,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);
@ -209,8 +186,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>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,314 +1,200 @@
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 { useState, useEffect, useCallback, useRef } from 'react'
import { Map, Marker, Popup } from '@vis.gl/react-maplibre'
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 } }
> = {
'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.11, lon: 129.11 } },
'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.48, lon: 126.56 } },
'mokpo-3015': { ship: { lat: 34.778, lon: 126.378 }, drone: { lat: 34.805, lon: 126.41 } },
};
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 },
],
};
const DRONE_POSITIONS: Record<string, { ship: { lat: number; lon: number }; drone: { lat: number; lon: number } }> = {
'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.1100, lon: 129.1100 } },
'incheon-3008': { ship: { lat: 37.4541, lon: 126.5986 }, drone: { lat: 37.4800, lon: 126.5600 } },
'mokpo-3015': { ship: { lat: 34.7780, lon: 126.3780 }, drone: { lat: 34.8050, lon: 126.4100 } },
}
export function RealtimeDrone() {
const [streams, setStreams] = useState<DroneStreamItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null);
const [gridMode, setGridMode] = useState(1);
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([]);
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(null);
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]);
const [streams, setStreams] = useState<DroneStreamItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null)
const [gridMode, setGridMode] = useState(1)
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;
const showMap = activeCells.length === 0
const loadStreams = useCallback(async () => {
try {
const items = await fetchDroneStreams();
setStreams(items);
const items = await fetchDroneStreams()
setStreams(items)
// Update selected stream and active cells with latest status
setSelectedStream((prev) => (prev ? (items.find((s) => s.id === prev.id) ?? prev) : prev));
setActiveCells((prev) => prev.map((cell) => items.find((s) => s.id === cell.id) ?? cell));
setSelectedStream(prev => prev ? items.find(s => s.id === prev.id) ?? prev : prev)
setActiveCells(prev => prev.map(cell => items.find(s => s.id === cell.id) ?? cell))
} catch {
// Fallback: show configured streams as idle
setStreams([
{
id: 'busan-1501',
name: '1501함 드론',
shipName: '부산서 1501함',
droneModel: 'DJI M300 RTK',
ip: '10.26.7.213',
rtspUrl: 'rtsp://10.26.7.213:554/stream0',
region: '부산',
status: 'idle',
hlsUrl: null,
error: null,
},
{
id: 'incheon-3008',
name: '3008함 드론',
shipName: '인천서 3008함',
droneModel: 'DJI M30T',
ip: '10.26.5.21',
rtspUrl: 'rtsp://10.26.5.21:554/stream0',
region: '인천',
status: 'idle',
hlsUrl: null,
error: null,
},
{
id: 'mokpo-3015',
name: '3015함 드론',
shipName: '목포서 3015함',
droneModel: 'DJI Mavic 3E',
ip: '10.26.7.85',
rtspUrl: 'rtsp://10.26.7.85:554/stream0',
region: '목포',
status: 'idle',
hlsUrl: null,
error: null,
},
]);
{ id: 'busan-1501', name: '1501함 드론', shipName: '부산서 1501함', droneModel: 'DJI M300 RTK', ip: '10.26.7.213', rtspUrl: 'rtsp://10.26.7.213:554/stream0', region: '부산', status: 'idle', hlsUrl: null, error: null },
{ id: 'incheon-3008', name: '3008함 드론', shipName: '인천서 3008함', droneModel: 'DJI M30T', ip: '10.26.5.21', rtspUrl: 'rtsp://10.26.5.21:554/stream0', region: '인천', status: 'idle', hlsUrl: null, error: null },
{ id: 'mokpo-3015', name: '3015함 드론', shipName: '목포서 3015함', droneModel: 'DJI Mavic 3E', ip: '10.26.7.85', rtspUrl: 'rtsp://10.26.7.85:554/stream0', region: '목포', status: 'idle', hlsUrl: null, error: null },
])
} finally {
setLoading(false);
setLoading(false)
}
}, []);
}, [])
useEffect(() => {
loadStreams();
}, [loadStreams]);
loadStreams()
}, [loadStreams])
// Poll status every 3 seconds when any stream is starting
useEffect(() => {
const hasStarting = streams.some((s) => s.status === 'starting');
if (!hasStarting) return;
const timer = setInterval(loadStreams, 3000);
return () => clearInterval(timer);
}, [streams, loadStreams]);
const hasStarting = streams.some(s => s.status === 'starting')
if (!hasStarting) return
const timer = setInterval(loadStreams, 3000)
return () => clearInterval(timer)
}, [streams, loadStreams])
const handleStartStream = async (id: string) => {
try {
await startDroneStreamApi(id);
await startDroneStreamApi(id)
// Immediately update to 'starting' state
setStreams((prev) =>
prev.map((s) => (s.id === id ? { ...s, status: 'starting' as const, error: null } : s)),
);
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'starting' as const, error: null } : s))
// Poll for status update
setTimeout(loadStreams, 2000);
setTimeout(loadStreams, 2000)
} catch {
setStreams((prev) =>
prev.map((s) =>
s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s,
),
);
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s))
}
};
}
const handleStopStream = async (id: string) => {
try {
await stopDroneStreamApi(id);
setStreams((prev) =>
prev.map((s) =>
s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s,
),
);
setActiveCells((prev) => prev.filter((c) => c.id !== id));
await stopDroneStreamApi(id)
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s))
setActiveCells(prev => prev.filter(c => c.id !== id))
} catch {
// ignore
}
};
}
const handleSelectStream = (stream: DroneStreamItem) => {
setSelectedStream(stream);
setSelectedStream(stream)
if (stream.status === 'streaming' && stream.hlsUrl) {
if (gridMode === 1) {
setActiveCells([stream]);
setActiveCells([stream])
} else {
setActiveCells((prev) => {
if (prev.length < gridMode && !prev.find((c) => c.id === stream.id))
return [...prev, stream];
return prev;
});
setActiveCells(prev => {
if (prev.length < gridMode && !prev.find(c => c.id === stream.id)) return [...prev, stream]
return prev
})
}
}
};
}
const statusInfo = (status: string) => {
switch (status) {
case 'streaming':
return { label: '송출중', color: 'var(--color-success)', bg: 'rgba(34,197,94,.12)' };
case 'starting':
return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' };
case 'error':
return { label: '오류', color: 'var(--color-danger)', bg: 'rgba(239,68,68,.12)' };
default:
return { label: '대기', color: 'var(--fg-disabled)', bg: 'rgba(255,255,255,.06)' };
case 'streaming': return { label: '송출중', color: 'var(--color-success)', bg: 'rgba(34,197,94,.12)' }
case 'starting': return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' }
case 'error': return { label: '오류', color: 'var(--color-danger)', bg: 'rgba(239,68,68,.12)' }
default: return { label: '대기', color: 'var(--fg-disabled)', bg: 'rgba(255,255,255,.06)' }
}
};
}
const gridCols = gridMode === 1 ? 1 : 2;
const totalCells = gridMode;
const gridCols = gridMode === 1 ? 1 : 2
const totalCells = gridMode
return (
<div
className="flex h-full overflow-hidden"
style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}
>
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
{/* 좌측: 드론 스트림 목록 */}
<div className="flex flex-col overflow-hidden bg-bg-surface border-r border-stroke w-[260px] min-w-[260px]">
{/* 헤더 */}
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
<span
className="w-[7px] h-[7px] rounded-full inline-block"
style={{
background: streams.some((s) => s.status === 'streaming')
? 'var(--color-success)'
: 'var(--fg-disabled)',
}}
/>
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: streams.some(s => s.status === 'streaming') ? 'var(--color-success)' : 'var(--fg-disabled)' }} />
</div>
<button
onClick={loadStreams}
className="px-2 py-0.5 text-[9px] font-korean bg-bg-card border border-stroke rounded text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
>
</button>
</div>
<div className="text-[9px] text-fg-disabled font-korean">
ViewLink RTSP ·
></button>
</div>
<div className="text-[9px] text-fg-disabled font-korean">ViewLink RTSP · </div>
</div>
{/* 드론 스트림 카드 */}
<div
className="flex-1 overflow-y-auto"
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
>
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
{loading ? (
<div className="px-3.5 py-4 text-[11px] text-fg-disabled font-korean">
...
</div>
) : (
streams.map((stream) => {
const si = statusInfo(stream.status);
const isSelected = selectedStream?.id === stream.id;
return (
<div
key={stream.id}
onClick={() => handleSelectStream(stream)}
className="px-3.5 py-3 border-b cursor-pointer transition-colors"
style={{
borderColor: 'rgba(255,255,255,.04)',
background: isSelected ? 'rgba(6,182,212,.08)' : 'transparent',
}}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<div className="text-sm">🚁</div>
<div>
<div className="text-[11px] font-semibold text-fg font-korean">
{stream.shipName}{' '}
<span className="text-[9px] text-color-accent font-semibold">
({stream.droneModel})
</span>
</div>
<div className="text-[9px] text-fg-disabled font-mono">{stream.ip}</div>
</div>
<div className="px-3.5 py-4 text-[11px] text-fg-disabled font-korean"> ...</div>
) : streams.map(stream => {
const si = statusInfo(stream.status)
const isSelected = selectedStream?.id === stream.id
return (
<div
key={stream.id}
onClick={() => handleSelectStream(stream)}
className="px-3.5 py-3 border-b cursor-pointer transition-colors"
style={{
borderColor: 'rgba(255,255,255,.04)',
background: isSelected ? 'rgba(6,182,212,.08)' : 'transparent',
}}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<div className="text-sm">🚁</div>
<div>
<div className="text-[11px] font-semibold text-fg font-korean">{stream.shipName} <span className="text-[9px] text-color-accent font-semibold">({stream.droneModel})</span></div>
<div className="text-[9px] text-fg-disabled font-mono">{stream.ip}</div>
</div>
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded-full"
style={{ background: si.bg, color: si.color }}
>
{si.label}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-fg-disabled font-korean px-1.5 py-0.5 rounded bg-bg-card">
{stream.region}
</span>
<span className="text-[8px] text-fg-disabled font-mono px-1.5 py-0.5 rounded bg-bg-card">
RTSP :554
</span>
</div>
{stream.error && (
<div className="mt-1.5 text-[8px] text-color-danger font-korean px-1.5 py-1 rounded bg-[rgba(239,68,68,.06)]">
{stream.error}
</div>
)}
{/* 시작/중지 버튼 */}
<div className="mt-2 flex gap-1.5">
{stream.status === 'idle' || stream.status === 'error' ? (
<button
onClick={(e) => {
e.stopPropagation();
handleStartStream(stream.id);
}}
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
style={{
background: 'rgba(34,197,94,.1)',
borderColor: 'rgba(34,197,94,.3)',
color: 'var(--color-success)',
}}
>
</button>
) : (
<button
onClick={(e) => {
e.stopPropagation();
handleStopStream(stream.id);
}}
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
style={{
background: 'rgba(239,68,68,.1)',
borderColor: 'rgba(239,68,68,.3)',
color: 'var(--color-danger)',
}}
>
</button>
)}
</div>
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded-full"
style={{ background: si.bg, color: si.color }}
>{si.label}</span>
</div>
);
})
)}
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-fg-disabled font-korean px-1.5 py-0.5 rounded bg-bg-card">{stream.region}</span>
<span className="text-[8px] text-fg-disabled font-mono px-1.5 py-0.5 rounded bg-bg-card">RTSP :554</span>
</div>
{stream.error && (
<div className="mt-1.5 text-[8px] text-color-danger font-korean px-1.5 py-1 rounded bg-[rgba(239,68,68,.06)]">
{stream.error}
</div>
)}
{/* 시작/중지 버튼 */}
<div className="mt-2 flex gap-1.5">
{stream.status === 'idle' || stream.status === 'error' ? (
<button
onClick={(e) => { e.stopPropagation(); handleStartStream(stream.id) }}
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
style={{ background: 'rgba(34,197,94,.1)', borderColor: 'rgba(34,197,94,.3)', color: 'var(--color-success)' }}
> </button>
) : (
<button
onClick={(e) => { e.stopPropagation(); handleStopStream(stream.id) }}
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
style={{ background: 'rgba(239,68,68,.1)', borderColor: 'rgba(239,68,68,.3)', color: 'var(--color-danger)' }}
> </button>
)}
</div>
</div>
)
})}
</div>
{/* 하단 안내 */}
<div className="px-3 py-2 border-t border-stroke bg-bg-elevated shrink-0">
<div className="text-[8px] text-fg-disabled font-korean leading-relaxed">
RTSP . ViewLink .
RTSP .
ViewLink .
</div>
</div>
</div>
@ -322,35 +208,13 @@ export function RealtimeDrone() {
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
</div>
{selectedStream?.status === 'streaming' && (
<div
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0"
style={{
background: 'rgba(34,197,94,.14)',
border: '1px solid rgba(34,197,94,.35)',
color: 'var(--color-success)',
}}
>
<span
className="w-[5px] h-[5px] rounded-full inline-block animate-pulse"
style={{ background: 'var(--color-success)' }}
/>
LIVE
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(34,197,94,.14)', border: '1px solid rgba(34,197,94,.35)', color: 'var(--color-success)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-success)' }} />LIVE
</div>
)}
{selectedStream?.status === 'starting' && (
<div
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0"
style={{
background: 'rgba(6,182,212,.14)',
border: '1px solid rgba(6,182,212,.35)',
color: 'var(--color-accent)',
}}
>
<span
className="w-[5px] h-[5px] rounded-full inline-block animate-pulse"
style={{ background: 'var(--color-accent)' }}
/>
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(6,182,212,.14)', border: '1px solid rgba(6,182,212,.35)', color: 'var(--color-accent)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-accent)' }} />
</div>
)}
</div>
@ -360,31 +224,23 @@ export function RealtimeDrone() {
{[
{ mode: 1, icon: '▣', label: '1화면' },
{ mode: 4, icon: '⊞', label: '4분할' },
].map((g) => (
].map(g => (
<button
key={g.mode}
onClick={() => {
setGridMode(g.mode);
setActiveCells((prev) => prev.slice(0, g.mode));
}}
onClick={() => { setGridMode(g.mode); setActiveCells(prev => prev.slice(0, g.mode)) }}
title={g.label}
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
style={
gridMode === g.mode
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
style={gridMode === g.mode
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
}
>
{g.icon}
</button>
>{g.icon}</button>
))}
</div>
<button
onClick={() => playerRefs.current.forEach((r) => r?.capture())}
onClick={() => playerRefs.current.forEach(r => r?.capture())}
className="px-2.5 py-1 bg-bg-card border border-stroke rounded-[5px] text-fg-sub text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-surface-hover transition-colors"
>
📷
</button>
>📷 </button>
</div>
</div>
@ -393,145 +249,53 @@ 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}
>
{streams.map((stream) => {
const pos = DRONE_POSITIONS[stream.id];
if (!pos) return null;
const statusColor =
stream.status === 'streaming'
? '#22c55e'
: stream.status === 'starting'
? '#06b6d4'
: stream.status === 'error'
? '#ef4444'
: '#94a3b8';
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{streams.map(stream => {
const pos = DRONE_POSITIONS[stream.id]
if (!pos) return null
const statusColor = stream.status === 'streaming' ? '#22c55e' : stream.status === 'starting' ? '#06b6d4' : stream.status === 'error' ? '#ef4444' : '#94a3b8'
return (
<Marker
key={stream.id}
longitude={(pos.ship.lon + pos.drone.lon) / 2}
latitude={(pos.ship.lat + pos.drone.lat) / 2}
anchor="center"
onClick={(e) => {
e.originalEvent.stopPropagation();
setMapPopup(stream);
}}
onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(stream) }}
>
<div className="cursor-pointer group" title={stream.shipName}>
<svg
width="130"
height="85"
viewBox="0 0 130 85"
fill="none"
className="drop-shadow-lg transition-transform group-hover:scale-105"
style={{ overflow: 'visible' }}
>
<svg width="130" height="85" viewBox="0 0 130 85" fill="none" className="drop-shadow-lg transition-transform group-hover:scale-105" style={{ overflow: 'visible' }}>
{/* 연결선 (점선) */}
<line
x1="28"
y1="52"
x2="88"
y2="30"
stroke={statusColor}
strokeWidth="1.2"
strokeDasharray="4 3"
opacity="0.5"
/>
<line x1="28" y1="52" x2="88" y2="30" stroke={statusColor} strokeWidth="1.2" strokeDasharray="4 3" opacity="0.5" />
{/* ── 함정: MarineTraffic 스타일 삼각형 (선수 방향 위) ── */}
<polygon points="28,38 18,58 38,58" fill={statusColor} opacity="0.85" />
<polygon
points="28,38 18,58 38,58"
fill="none"
stroke="#fff"
strokeWidth="0.8"
opacity="0.5"
/>
<polygon points="28,38 18,58 38,58" fill="none" stroke="#fff" strokeWidth="0.8" opacity="0.5" />
{/* 함정명 라벨 */}
<rect x="3" y="61" width="50" height="13" rx="3" fill="rgba(0,0,0,.75)" />
<text
x="28"
y="70.5"
textAnchor="middle"
fill="#fff"
fontSize="7"
fontFamily="sans-serif"
fontWeight="bold"
>
{stream.shipName.replace(/서 /, ' ')}
</text>
<text x="28" y="70.5" textAnchor="middle" fill="#fff" fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.shipName.replace(/서 /, ' ')}</text>
{/* ── 드론: 쿼드콥터 아이콘 ── */}
{/* 외곽 원 */}
<circle
cx="88"
cy="30"
r="18"
fill="rgba(10,14,24,.7)"
stroke={statusColor}
strokeWidth="1.5"
/>
<circle cx="88" cy="30" r="18" fill="rgba(10,14,24,.7)" stroke={statusColor} strokeWidth="1.5" />
{/* X자 팔 */}
<line
x1="76"
y1="18"
x2="100"
y2="42"
stroke={statusColor}
strokeWidth="1.2"
opacity="0.5"
/>
<line
x1="100"
y1="18"
x2="76"
y2="42"
stroke={statusColor}
strokeWidth="1.2"
opacity="0.5"
/>
<line x1="76" y1="18" x2="100" y2="42" stroke={statusColor} strokeWidth="1.2" opacity="0.5" />
<line x1="100" y1="18" x2="76" y2="42" stroke={statusColor} strokeWidth="1.2" opacity="0.5" />
{/* 프로펠러 4개 (회전 애니메이션) */}
<ellipse cx="76" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform
attributeName="transform"
type="rotate"
from="0 76 18"
to="360 76 18"
dur="1.5s"
repeatCount="indefinite"
/>
<animateTransform attributeName="transform" type="rotate" from="0 76 18" to="360 76 18" dur="1.5s" repeatCount="indefinite" />
</ellipse>
<ellipse cx="100" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 18"
to="-360 100 18"
dur="1.5s"
repeatCount="indefinite"
/>
<animateTransform attributeName="transform" type="rotate" from="0 100 18" to="-360 100 18" dur="1.5s" repeatCount="indefinite" />
</ellipse>
<ellipse cx="76" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform
attributeName="transform"
type="rotate"
from="0 76 42"
to="-360 76 42"
dur="1.5s"
repeatCount="indefinite"
/>
<animateTransform attributeName="transform" type="rotate" from="0 76 42" to="-360 76 42" dur="1.5s" repeatCount="indefinite" />
</ellipse>
<ellipse cx="100" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 42"
to="360 100 42"
dur="1.5s"
repeatCount="indefinite"
/>
<animateTransform attributeName="transform" type="rotate" from="0 100 42" to="360 100 42" dur="1.5s" repeatCount="indefinite" />
</ellipse>
{/* 본체 */}
<circle cx="88" cy="30" r="6" fill={statusColor} opacity="0.8" />
@ -541,31 +305,16 @@ export function RealtimeDrone() {
{/* 송출중 REC LED */}
{stream.status === 'streaming' && (
<circle cx="100" cy="16" r="3" fill="#ef4444">
<animate
attributeName="opacity"
values="1;0.2;1"
dur="1s"
repeatCount="indefinite"
/>
<animate attributeName="opacity" values="1;0.2;1" dur="1s" repeatCount="indefinite" />
</circle>
)}
{/* 드론 모델명 */}
<rect x="65" y="51" width="46" height="12" rx="3" fill="rgba(0,0,0,.75)" />
<text
x="88"
y="60"
textAnchor="middle"
fill={statusColor}
fontSize="7"
fontFamily="sans-serif"
fontWeight="bold"
>
{stream.droneModel.split(' ').slice(-1)[0]}
</text>
<text x="88" y="60" textAnchor="middle" fill={statusColor} fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.droneModel.split(' ').slice(-1)[0]}</text>
</svg>
</div>
</Marker>
);
)
})}
{/* 드론 클릭 팝업 */}
{mapPopup && DRONE_POSITIONS[mapPopup.id] && (
@ -578,102 +327,56 @@ export function RealtimeDrone() {
offset={36}
className="cctv-dark-popup"
>
<div
className="p-2.5"
style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}
>
<div className="p-2.5" style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-sm">🚁</span>
<div className="text-[11px] font-bold text-fg">{mapPopup.shipName}</div>
</div>
<div className="text-[9px] text-fg-disabled mb-0.5">{mapPopup.droneModel}</div>
<div className="text-[8px] text-fg-disabled font-mono mb-2">
{mapPopup.ip} · {mapPopup.region}
</div>
<div className="text-[8px] text-fg-disabled font-mono mb-2">{mapPopup.ip} · {mapPopup.region}</div>
<div className="flex items-center gap-1.5 mb-2">
<span
className="text-[8px] font-bold px-1.5 py-px rounded-full"
style={{
background: statusInfo(mapPopup.status).bg,
color: statusInfo(mapPopup.status).color,
}}
>
{statusInfo(mapPopup.status).label}
</span>
<span className="text-[8px] font-bold px-1.5 py-px rounded-full"
style={{ background: statusInfo(mapPopup.status).bg, color: statusInfo(mapPopup.status).color }}
> {statusInfo(mapPopup.status).label}</span>
</div>
{mapPopup.status === 'idle' || mapPopup.status === 'error' ? (
<button
onClick={() => {
handleStartStream(mapPopup.id);
handleSelectStream(mapPopup);
setMapPopup(null);
}}
onClick={() => { handleStartStream(mapPopup.id); handleSelectStream(mapPopup); setMapPopup(null) }}
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
style={{
background: 'rgba(34,197,94,.15)',
borderColor: 'rgba(34,197,94,.3)',
color: '#4ade80',
}}
>
</button>
style={{ background: 'rgba(34,197,94,.15)', borderColor: 'rgba(34,197,94,.3)', color: '#4ade80' }}
> </button>
) : mapPopup.status === 'streaming' ? (
<button
onClick={() => {
handleSelectStream(mapPopup);
setMapPopup(null);
}}
onClick={() => { handleSelectStream(mapPopup); setMapPopup(null) }}
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
style={{
background: 'rgba(6,182,212,.15)',
borderColor: 'rgba(6,182,212,.3)',
color: '#67e8f9',
}}
>
</button>
style={{ background: 'rgba(6,182,212,.15)', borderColor: 'rgba(6,182,212,.3)', color: '#67e8f9' }}
> </button>
) : (
<div className="text-[9px] text-color-accent font-korean text-center animate-pulse">
...
</div>
<div className="text-[9px] text-color-accent font-korean text-center animate-pulse"> ...</div>
)}
</div>
</Popup>
)}
</Map>
{/* 지도 위 안내 배지 */}
<div
className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-[10px] font-bold font-korean z-10"
style={{
background: 'rgba(0,0,0,.7)',
color: 'rgba(255,255,255,.7)',
backdropFilter: 'blur(4px)',
}}
>
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-[10px] font-bold font-korean z-10"
style={{ background: 'rgba(0,0,0,.7)', color: 'rgba(255,255,255,.7)', backdropFilter: 'blur(4px)' }}>
🚁 ({streams.length})
</div>
</div>
) : (
<div
className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
style={{
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
}}
>
}}>
{Array.from({ length: totalCells }).map((_, i) => {
const stream = activeCells[i];
const stream = activeCells[i]
return (
<div
key={i}
className="relative flex items-center justify-center overflow-hidden bg-bg-base"
style={{ border: '1px solid var(--stroke-light)' }}
>
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-bg-base" style={{ border: '1px solid var(--stroke-light)' }}>
{stream && stream.status === 'streaming' && stream.hlsUrl ? (
<CCTVPlayer
ref={(el) => {
playerRefs.current[i] = el;
}}
ref={el => { playerRefs.current[i] = el }}
cameraNm={stream.shipName}
streamUrl={stream.hlsUrl}
sttsCd="LIVE"
@ -684,55 +387,36 @@ export function RealtimeDrone() {
) : stream && stream.status === 'starting' ? (
<div className="flex flex-col items-center justify-center gap-2">
<div className="text-lg opacity-40 animate-pulse">🚁</div>
<div className="text-[10px] text-color-accent font-korean animate-pulse">
RTSP ...
</div>
<div className="text-[10px] text-color-accent font-korean animate-pulse">RTSP ...</div>
<div className="text-[8px] text-fg-disabled font-mono">{stream.ip}:554</div>
</div>
) : stream && stream.status === 'error' ? (
<div className="flex flex-col items-center justify-center gap-2">
<div className="text-lg opacity-30"></div>
<div className="text-[10px] text-color-danger font-korean"> </div>
<div className="text-[8px] text-fg-disabled font-korean max-w-[200px] text-center">
{stream.error}
</div>
<div className="text-[8px] text-fg-disabled font-korean max-w-[200px] text-center">{stream.error}</div>
<button
onClick={() => handleStartStream(stream.id)}
className="mt-1 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
>
</button>
></button>
</div>
) : (
<div className="text-[10px] text-fg-disabled font-korean opacity-40">
{streams.length > 0
? '스트림을 시작하고 선택하세요'
: '드론 스트림을 선택하세요'}
{streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'}
</div>
)}
</div>
);
)
})}
</div>
)}
{/* 하단 정보 바 */}
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-stroke bg-bg-elevated shrink-0">
<div className="text-[10px] text-fg-disabled font-korean">
: <b className="text-fg">{selectedStream?.shipName ?? ''}</b>
</div>
<div className="text-[10px] text-fg-disabled font-korean">
IP:{' '}
<span className="text-color-accent font-mono text-[9px]">
{selectedStream?.ip ?? ''}
</span>
</div>
<div className="text-[10px] text-fg-disabled font-korean">
: <span className="text-fg-sub">{selectedStream?.region ?? ''}</span>
</div>
<div className="ml-auto text-[9px] text-fg-disabled font-korean">
RTSP HLS · ViewLink
</div>
<div className="text-[10px] text-fg-disabled font-korean">: <b className="text-fg">{selectedStream?.shipName ?? ''}</b></div>
<div className="text-[10px] text-fg-disabled font-korean">IP: <span className="text-color-accent font-mono text-[9px]">{selectedStream?.ip ?? ''}</span></div>
<div className="text-[10px] text-fg-disabled font-korean">: <span className="text-fg-sub">{selectedStream?.region ?? ''}</span></div>
<div className="ml-auto text-[9px] text-fg-disabled font-korean">RTSP HLS · ViewLink </div>
</div>
</div>
@ -743,10 +427,7 @@ export function RealtimeDrone() {
📋
</div>
<div
className="flex-1 overflow-y-auto px-3 py-2.5"
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
>
<div className="flex-1 overflow-y-auto px-3 py-2.5" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
{selectedStream ? (
<div className="flex flex-col gap-1.5">
{[
@ -759,10 +440,7 @@ export function RealtimeDrone() {
['프로토콜', 'RTSP → HLS'],
['상태', statusInfo(selectedStream.status).label],
].map(([k, v], i) => (
<div
key={i}
className="flex justify-between px-2 py-1 bg-bg-base rounded text-[9px]"
>
<div key={i} className="flex justify-between px-2 py-1 bg-bg-base rounded text-[9px]">
<span className="text-fg-disabled font-korean">{k}</span>
<span className="font-mono text-fg">{v}</span>
</div>
@ -770,9 +448,7 @@ export function RealtimeDrone() {
{selectedStream.hlsUrl && (
<div className="px-2 py-1 bg-bg-base rounded text-[8px]">
<div className="text-fg-disabled font-korean mb-0.5">HLS URL</div>
<div className="font-mono text-color-accent break-all">
{selectedStream.hlsUrl}
</div>
<div className="font-mono text-color-accent break-all">{selectedStream.hlsUrl}</div>
</div>
)}
</div>
@ -784,23 +460,13 @@ export function RealtimeDrone() {
<div className="mt-3 pt-2.5 border-t border-stroke">
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">🔗 </div>
<div className="flex flex-col gap-1.5">
<div
className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]"
style={{ border: '1px solid rgba(6,182,212,.2)' }}
>
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(6,182,212,.2)' }}>
<span className="text-[9px] text-fg-sub font-korean">ViewLink 3.5</span>
<span className="text-[9px] font-bold" style={{ color: 'var(--color-accent)' }}>
RTSP
</span>
<span className="text-[9px] font-bold" style={{ color: 'var(--color-accent)' }}> RTSP</span>
</div>
<div
className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]"
style={{ border: '1px solid rgba(59,130,246,.2)' }}
>
<div className="flex items-center justify-between px-2 py-1.5 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
<span className="text-[9px] text-fg-sub font-korean">FFmpeg </span>
<span className="text-[9px] font-bold" style={{ color: 'var(--blue, #3b82f6)' }}>
RTSPHLS
</span>
<span className="text-[9px] font-bold" style={{ color: 'var(--blue, #3b82f6)' }}>RTSPHLS</span>
</div>
</div>
</div>
@ -811,21 +477,9 @@ export function RealtimeDrone() {
<div className="grid grid-cols-2 gap-1.5">
{[
{ label: '전체', value: streams.length, color: 'text-fg' },
{
label: '송출중',
value: streams.filter((s) => s.status === 'streaming').length,
color: 'text-color-success',
},
{
label: '연결중',
value: streams.filter((s) => s.status === 'starting').length,
color: 'text-color-accent',
},
{
label: '오류',
value: streams.filter((s) => s.status === 'error').length,
color: 'text-color-danger',
},
{ label: '송출중', value: streams.filter(s => s.status === 'streaming').length, color: 'text-color-success' },
{ label: '연결중', value: streams.filter(s => s.status === 'starting').length, color: 'text-color-accent' },
{ label: '오류', value: streams.filter(s => s.status === 'error').length, color: 'text-color-danger' },
].map((item, i) => (
<div key={i} className="px-2 py-1.5 bg-bg-base rounded text-center">
<div className="text-[8px] text-fg-disabled font-korean">{item.label}</div>
@ -837,5 +491,5 @@ export function RealtimeDrone() {
</div>
</div>
</div>
);
)
}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,61 +1,45 @@
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 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' }],
};
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 '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'
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
// ── FlyTo Controller ────────────────────────────────────
function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) {
const { current: map } = useMap();
const prevIdRef = useRef<number | undefined>(undefined);
const { current: map } = useMap()
const prevIdRef = useRef<number | undefined>(undefined)
useEffect(() => {
if (!map) return;
if (!map) return
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedOrg.id) {
map.flyTo({ center: [selectedOrg.lng, selectedOrg.lat], zoom: 10, duration: 800 });
map.flyTo({ center: [selectedOrg.lng, selectedOrg.lat], zoom: 10, duration: 800 })
}
prevIdRef.current = selectedOrg.id;
}, [map, selectedOrg]);
prevIdRef.current = selectedOrg.id
}, [map, selectedOrg])
return null;
return null
}
interface AssetMapProps {
organizations: AssetOrgCompat[];
selectedOrg: AssetOrgCompat;
onSelectOrg: (o: AssetOrgCompat) => void;
regionFilter: string;
onRegionFilterChange: (v: string) => void;
organizations: AssetOrgCompat[]
selectedOrg: AssetOrgCompat
onSelectOrg: (o: AssetOrgCompat) => void
regionFilter: string
onRegionFilterChange: (v: string) => void
}
function AssetMap({
@ -65,12 +49,15 @@ function AssetMap({
regionFilter,
onRegionFilterChange,
}: AssetMapProps) {
const currentMapStyle = useBaseMapStyle()
const mapToggles = useMapStore((s) => s.mapToggles)
const handleClick = useCallback(
(org: AssetOrgCompat) => {
onSelectOrg(org);
onSelectOrg(org)
},
[onSelectOrg],
);
)
const markerLayer = useMemo(() => {
return new ScatterplotLayer({
@ -78,19 +65,19 @@ function AssetMap({
data: orgs,
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
getRadius: (d: AssetOrgCompat) => {
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7;
const isSelected = selectedOrg.id === d.id;
return isSelected ? baseRadius + 4 : baseRadius;
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7
const isSelected = selectedOrg.id === d.id
return isSelected ? baseRadius + 4 : baseRadius
},
getFillColor: (d: AssetOrgCompat) => {
const tc = typeColor(d.type);
const isSelected = selectedOrg.id === d.id;
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178);
const tc = typeColor(d.type)
const isSelected = selectedOrg.id === d.id
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178)
},
getLineColor: (d: AssetOrgCompat) => {
const tc = typeColor(d.type);
const isSelected = selectedOrg.id === d.id;
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200);
const tc = typeColor(d.type)
const isSelected = selectedOrg.id === d.id
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200)
},
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
stroked: true,
@ -99,7 +86,7 @@ function AssetMap({
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: AssetOrgCompat }) => {
if (info.object) handleClick(info.object);
if (info.object) handleClick(info.object)
},
updateTriggers: {
getRadius: [selectedOrg.id],
@ -107,17 +94,18 @@ function AssetMap({
getLineColor: [selectedOrg.id],
getLineWidth: [selectedOrg.id],
},
});
}, [orgs, selectedOrg, handleClick]);
})
}, [orgs, selectedOrg, handleClick])
return (
<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>
@ -131,7 +119,7 @@ function AssetMap({
{ value: '중부', label: '중부청' },
{ value: '동해', label: '동해청' },
{ value: '제주', label: '제주청' },
].map((r) => (
].map(r => (
<button
key={r.value}
onClick={() => onRegionFilterChange(r.value)}
@ -163,16 +151,13 @@ function AssetMap({
{ color: '#6b7280', label: '기타' },
].map((item, i) => (
<div key={i} className="flex items-center gap-1.5 mb-0.5 last:mb-0">
<span
className="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0"
style={{ background: item.color }}
/>
<span className="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0" style={{ background: item.color }} />
<span className="text-[10px] text-fg-sub font-korean">{item.label}</span>
</div>
))}
</div>
</div>
);
)
}
export default AssetMap;
export default AssetMap

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -39,21 +39,14 @@ const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlot
<div className="flex items-center gap-1.5 mb-1.5">
<span
className="text-[11px] font-bold font-korean px-2 py-0.5 rounded"
style={{
background: 'rgba(6,182,212,0.12)',
color: '#06b6d4',
border: '1px solid rgba(6,182,212,0.25)',
}}
style={{ background: 'rgba(6,182,212,0.12)', color: '#06b6d4', border: '1px solid rgba(6,182,212,0.25)' }}
>
{label}
</span>
</div>
{/* 지도 + 캡처 오버레이 */}
<div
className="relative rounded-lg border border-stroke overflow-hidden"
style={{ height: '300px' }}
>
<div className="relative rounded-lg border border-stroke overflow-hidden" style={{ aspectRatio: '16/9' }}>
<MapView
center={mapData.center}
zoom={mapData.zoom}
@ -73,18 +66,12 @@ const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlot
<div className="absolute top-2 right-2 z-10" style={{ width: '180px' }}>
<div
className="rounded-lg overflow-hidden"
style={{
border: '1px solid rgba(6,182,212,0.5)',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
}}
style={{ border: '1px solid rgba(6,182,212,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)' }}
>
<img src={captured} alt={`${label} 캡처`} className="w-full block" />
<div
className="flex items-center justify-between px-2 py-1"
style={{
background: 'rgba(15,23,42,0.85)',
borderTop: '1px solid rgba(6,182,212,0.3)',
}}
style={{ background: 'rgba(15,23,42,0.85)', borderTop: '1px solid rgba(6,182,212,0.3)' }}
>
<span className="text-[9px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
📷

파일 보기

@ -1,88 +1,66 @@
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 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' }],
};
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 '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'
interface ScatMapProps {
segments: ScatSegment[];
zones: ApiZoneItem[];
selectedSeg: ScatSegment;
jurisdictionFilter: string;
onSelectSeg: (s: ScatSegment) => void;
onOpenPopup: (idx: number) => void;
segments: ScatSegment[]
zones: ApiZoneItem[]
selectedSeg: ScatSegment
jurisdictionFilter: string
onSelectSeg: (s: ScatSegment) => void
onOpenPopup: (idx: number) => void
}
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
function FlyToController({
selectedSeg,
zones,
}: {
selectedSeg: ScatSegment;
zones: ApiZoneItem[];
}) {
const { current: map } = useMap();
const prevIdRef = useRef<number | undefined>(undefined);
const prevZonesLenRef = useRef<number>(0);
function FlyToController({ selectedSeg, zones }: { selectedSeg: ScatSegment; zones: ApiZoneItem[] }) {
const { current: map } = useMap()
const prevIdRef = useRef<number | undefined>(undefined)
const prevZonesLenRef = useRef<number>(0)
// 선택 구간 변경 시
useEffect(() => {
if (!map) return;
if (!map) return
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) {
map.flyTo({ center: [selectedSeg.lng, selectedSeg.lat], zoom: 12, duration: 600 });
map.flyTo({ center: [selectedSeg.lng, selectedSeg.lat], zoom: 12, duration: 600 })
}
prevIdRef.current = selectedSeg.id;
}, [map, selectedSeg]);
prevIdRef.current = selectedSeg.id
}, [map, selectedSeg])
// 관할해경(zones) 변경 시 지도 중심 이동
useEffect(() => {
if (!map || zones.length === 0) return;
if (prevZonesLenRef.current === zones.length) return;
prevZonesLenRef.current = zones.length;
const validZones = zones.filter((z) => z.latCenter && z.lngCenter);
if (validZones.length === 0) return;
const avgLat = validZones.reduce((a, z) => a + z.latCenter, 0) / validZones.length;
const avgLng = validZones.reduce((a, z) => a + z.lngCenter, 0) / validZones.length;
map.flyTo({ center: [avgLng, avgLat], zoom: 9, duration: 800 });
}, [map, zones]);
if (!map || zones.length === 0) return
if (prevZonesLenRef.current === zones.length) return
prevZonesLenRef.current = zones.length
const validZones = zones.filter(z => z.latCenter && z.lngCenter)
if (validZones.length === 0) return
const avgLat = validZones.reduce((a, z) => a + z.latCenter, 0) / validZones.length
const avgLng = validZones.reduce((a, z) => a + z.lngCenter, 0) / validZones.length
map.flyTo({ center: [avgLng, avgLat], zoom: 9, duration: 800 })
}, [map, zones])
return null;
return null
}
// ── 줌 기반 스케일 계산 ─────────────────────────────────
function getZoomScale(zoom: number) {
const zScale = Math.max(0, zoom - 9) / 5;
const zScale = Math.max(0, zoom - 9) / 5
return {
polyWidth: 1 + zScale * 4,
selPolyWidth: 2 + zScale * 5,
@ -90,7 +68,7 @@ function getZoomScale(zoom: number) {
halfLenScale: 0.15 + zScale * 0.85,
markerRadius: Math.round(6 + zScale * 16),
showStatusMarker: zoom >= 11,
};
}
}
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
@ -100,50 +78,46 @@ function buildSegCoords(
halfLenScale: number,
segments: ScatSegment[],
): [number, number][] {
const idx = segments.indexOf(seg);
const prev = idx > 0 ? segments[idx - 1] : seg;
const next = idx < segments.length - 1 ? segments[idx + 1] : seg;
const dlat = next.lat - prev.lat;
const dlng = next.lng - prev.lng;
const dist = Math.sqrt(dlat * dlat + dlng * dlng);
const nDlat = dist > 0 ? dlat / dist : 0;
const nDlng = dist > 0 ? dlng / dist : 1;
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale;
const idx = segments.indexOf(seg)
const prev = idx > 0 ? segments[idx - 1] : seg
const next = idx < segments.length - 1 ? segments[idx + 1] : seg
const dlat = next.lat - prev.lat
const dlng = next.lng - prev.lng
const dist = Math.sqrt(dlat * dlat + dlng * dlng)
const nDlat = dist > 0 ? dlat / dist : 0
const nDlng = dist > 0 ? dlng / dist : 1
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale
return [
[seg.lng - nDlng * halfLen, seg.lat - nDlat * halfLen],
[seg.lng, seg.lat],
[seg.lng + nDlng * halfLen, seg.lat + nDlat * halfLen],
];
]
}
// ── 툴팁 상태 ───────────────────────────────────────────
interface TooltipState {
x: number;
y: number;
seg: ScatSegment;
x: number
y: number
seg: ScatSegment
}
// ── ScatMap ─────────────────────────────────────────────
function ScatMap({
segments,
zones,
selectedSeg,
jurisdictionFilter,
onSelectSeg,
onOpenPopup,
}: ScatMapProps) {
const [zoom, setZoom] = useState(10);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
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)
const handleClick = useCallback(
(seg: ScatSegment) => {
onSelectSeg(seg);
onOpenPopup(seg.id);
onSelectSeg(seg)
onOpenPopup(seg.id)
},
[onSelectSeg, onOpenPopup],
);
)
const zs = useMemo(() => getZoomScale(zoom), [zoom]);
const zs = useMemo(() => getZoomScale(zoom), [zoom])
// 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정
// const coastlineLayer = useMemo(
@ -179,7 +153,7 @@ function ScatMap({
},
}),
[selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
);
)
// ESI 색상 세그먼트 폴리라인
const segPathLayer = useMemo(
@ -189,9 +163,9 @@ function ScatMap({
data: segments,
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
getColor: (d: ScatSegment) => {
const isSelected = selectedSeg.id === d.id;
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum);
return hexToRgba(hexCol, isSelected ? 242 : 178);
const isSelected = selectedSeg.id === d.id
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum)
return hexToRgba(hexCol, isSelected ? 242 : 178)
},
getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth),
capRounded: true,
@ -200,13 +174,13 @@ function ScatMap({
pickable: true,
onHover: (info: { object?: ScatSegment; x: number; y: number }) => {
if (info.object) {
setTooltip({ x: info.x, y: info.y, seg: info.object });
setTooltip({ x: info.x, y: info.y, seg: info.object })
} else {
setTooltip(null);
setTooltip(null)
}
},
onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object);
if (info.object) handleClick(info.object)
},
updateTriggers: {
getColor: [selectedSeg.id],
@ -215,25 +189,25 @@ function ScatMap({
},
}),
[segments, selectedSeg, zs, handleClick],
);
)
// 조사 상태 마커 (줌 >= 11 시 표시)
const markerLayer = useMemo(() => {
if (!zs.showStatusMarker) return null;
if (!zs.showStatusMarker) return null
return new ScatterplotLayer({
id: 'scat-status-markers',
data: segments,
getPosition: (d: ScatSegment) => [d.lng, d.lat],
getRadius: zs.markerRadius,
getFillColor: (d: ScatSegment) => {
if (d.status === '완료') return [34, 197, 94, 51];
if (d.status === '진행중') return [234, 179, 8, 51];
return [100, 116, 139, 51];
if (d.status === '완료') return [34, 197, 94, 51]
if (d.status === '진행중') return [234, 179, 8, 51]
return [100, 116, 139, 51]
},
getLineColor: (d: ScatSegment) => {
if (d.status === '완료') return [34, 197, 94, 200];
if (d.status === '진행중') return [234, 179, 8, 200];
return [100, 116, 139, 200];
if (d.status === '완료') return [34, 197, 94, 200]
if (d.status === '진행중') return [234, 179, 8, 200]
return [100, 116, 139, 200]
},
getLineWidth: 1,
stroked: true,
@ -242,32 +216,32 @@ function ScatMap({
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object);
if (info.object) handleClick(info.object)
},
updateTriggers: {
getRadius: [zs.markerRadius],
},
});
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick]);
})
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers: any[] = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layers: any[] = [glowLayer, segPathLayer];
if (markerLayer) layers.push(markerLayer);
return layers;
}, [glowLayer, segPathLayer, markerLayer]);
const layers: any[] = [glowLayer, segPathLayer]
if (markerLayer) layers.push(markerLayer)
return layers
}, [glowLayer, segPathLayer, markerLayer])
const doneCount = segments.filter((s) => s.status === '완료').length;
const progCount = segments.filter((s) => s.status === '진행중').length;
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0);
const doneLen = segments.filter((s) => s.status === '완료').reduce((a, s) => a + s.lengthM, 0);
const doneCount = segments.filter(s => s.status === '완료').length
const progCount = segments.filter(s => s.status === '진행중').length
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0)
const doneLen = segments.filter(s => s.status === '완료').reduce((a, s) => a + s.lengthM, 0)
const highSens = segments
.filter((s) => s.sensitivity === '최상' || s.sensitivity === '상')
.reduce((a, s) => a + s.lengthM, 0);
const donePct = Math.round((doneCount / segments.length) * 100);
const progPct = Math.round((progCount / segments.length) * 100);
const notPct = 100 - donePct - progPct;
.filter(s => s.sensitivity === '최상' || s.sensitivity === '상')
.reduce((a, s) => a + s.lengthM, 0)
const donePct = Math.round((doneCount / segments.length) * 100)
const progPct = Math.round((progCount / segments.length) * 100)
const notPct = 100 - donePct - progPct
return (
<div className="absolute inset-0 overflow-hidden">
@ -280,11 +254,12 @@ function ScatMap({
}
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)}
onZoom={e => setZoom(e.viewState.zoom)}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={deckLayers} />
<FlyToController selectedSeg={selectedSeg} zones={zones} />
</Map>
@ -335,9 +310,7 @@ function ScatMap({
<div className="absolute top-3.5 right-3.5 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
{/* ESI Legend */}
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-[10px] font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
ESI
</div>
<div className="text-[10px] font-bold uppercase tracking-wider text-fg-disabled mb-2.5">ESI </div>
{[
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
@ -349,10 +322,7 @@ function ScatMap({
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
].map((item, i) => (
<div key={i} className="flex items-center gap-2 py-1 text-[11px]">
<span
className="w-3.5 h-1.5 rounded-sm flex-shrink-0"
style={{ background: item.color }}
/>
<span className="w-3.5 h-1.5 rounded-sm flex-shrink-0" style={{ background: item.color }} />
<span className="text-fg-sub font-korean">{item.label}</span>
<span className="ml-auto font-mono text-[10px] text-fg">{item.esi}</span>
</div>
@ -361,22 +331,11 @@ function ScatMap({
{/* Progress */}
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-[10px] font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
</div>
<div className="text-[10px] font-bold uppercase tracking-wider text-fg-disabled mb-2.5"> </div>
<div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
<div
className="h-full transition-all duration-500"
style={{ width: `${donePct}%`, background: 'var(--color-success)' }}
/>
<div
className="h-full transition-all duration-500"
style={{ width: `${progPct}%`, background: 'var(--color-warning)' }}
/>
<div
className="h-full transition-all duration-500"
style={{ width: `${notPct}%`, background: 'var(--stroke-default)' }}
/>
<div className="h-full transition-all duration-500" style={{ width: `${donePct}%`, background: 'var(--color-success)' }} />
<div className="h-full transition-all duration-500" style={{ width: `${progPct}%`, background: 'var(--color-warning)' }} />
<div className="h-full transition-all duration-500" style={{ width: `${notPct}%`, background: 'var(--stroke-default)' }} />
</div>
<div className="flex justify-between mt-1">
<span className="text-[9px] font-mono text-color-success"> {donePct}%</span>
@ -388,21 +347,11 @@ function ScatMap({
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--color-success)'],
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--color-danger)'],
[
'방제 우선 구간',
`${segments.filter((s) => s.sensitivity === '최상').length}`,
'var(--color-warning)',
],
['방제 우선 구간', `${segments.filter(s => s.sensitivity === '최상').length}`, 'var(--color-warning)'],
].map(([label, val, color], i) => (
<div
key={i}
className="flex justify-between py-1.5 border-b border-[rgba(30,42,74,0.3)] last:border-b-0 text-[11px]"
>
<div key={i} className="flex justify-between py-1.5 border-b border-[rgba(30,42,74,0.3)] last:border-b-0 text-[11px]">
<span className="text-fg-sub font-korean">{label}</span>
<span
className="font-mono font-medium text-[11px]"
style={{ color: color || undefined }}
>
<span className="font-mono font-medium text-[11px]" style={{ color: color || undefined }}>
{val}
</span>
</div>
@ -424,7 +373,7 @@ function ScatMap({
</span>
</div> */}
</div>
);
)
}
export default ScatMap;
export default ScatMap

파일 보기

@ -1,52 +1,55 @@
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 'maplibre-gl/dist/maplibre-gl.css';
import { WeatherRightPanel } from './WeatherRightPanel';
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay';
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 { 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'
// import { useOceanCurrentLayers } from './OceanCurrentLayer'
import { useWaterTemperatureLayers } from './WaterTemperatureLayer';
import { WindParticleLayer } from './WindParticleLayer';
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer';
import { useWeatherData } from '../hooks/useWeatherData';
import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
import { WindParticleLayer } from './WindParticleLayer'
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
import { useWeatherData } from '../hooks/useWeatherData'
// import { useOceanForecast } from '../hooks/useOceanForecast'
import { WeatherMapControls } from './WeatherMapControls';
import { degreesToCardinal } from '../services/weatherUtils';
import { WeatherMapControls } from './WeatherMapControls'
import { degreesToCardinal } from '../services/weatherUtils'
type TimeOffset = '0' | '3' | '6' | '9';
type TimeOffset = '0' | '3' | '6' | '9'
interface WeatherStation {
id: string;
name: string;
location: { lat: number; lon: number };
id: string
name: string
location: { lat: number; lon: number }
wind: {
speed: number;
direction: number;
speed_1k: number;
speed_3k: number;
};
speed: number
direction: number
speed_1k: number
speed_3k: number
}
wave: {
height: number;
period: number;
};
height: number
period: number
}
temperature: {
current: number;
feelsLike: number;
};
pressure: number;
visibility: number;
salinity?: number;
current: number
feelsLike: number
}
pressure: number
visibility: number
salinity?: number
}
interface WeatherForecast {
time: string;
hour: string;
icon: string;
temperature: number;
windSpeed: number;
time: string
hour: string
icon: string
temperature: number
windSpeed: number
}
// Base weather station locations
@ -61,77 +64,50 @@ const BASE_STATIONS = [
{ id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } },
{ id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } },
{ id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } },
];
]
// Generate forecast data based on time offset
const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => {
const baseHour = parseInt(timeOffset);
const forecasts: WeatherForecast[] = [];
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️'];
const baseHour = parseInt(timeOffset)
const forecasts: WeatherForecast[] = []
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️']
for (let i = 0; i < 5; i++) {
const hour = baseHour + i * 3;
const hour = baseHour + i * 3
forecasts.push({
time: `+${hour}`,
hour: `${hour}`,
icon: icons[i % icons.length],
temperature: Math.floor(Math.random() * 5) + 5,
windSpeed: Math.floor(Math.random() * 5) + 6,
});
})
}
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,
},
],
};
return forecasts
}
// 한국 해역 중심 좌표 (한반도 중앙)
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat]
const WEATHER_MAP_ZOOM = 7;
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5] // [lng, lat]
const WEATHER_MAP_ZOOM = 7
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
/**
* WeatherMapInner Map (useMap / useControl )
*/
interface WeatherMapInnerProps {
weatherStations: WeatherStation[];
enabledLayers: Set<string>;
selectedStationId: string | null;
onStationClick: (station: WeatherStation) => void;
mapCenter: [number, number];
mapZoom: number;
clickedLocation: { lat: number; lon: number } | null;
weatherStations: WeatherStation[]
enabledLayers: Set<string>
selectedStationId: string | null
onStationClick: (station: WeatherStation) => void
mapCenter: [number, number]
mapZoom: number
clickedLocation: { lat: number; lon: number } | null
}
function WeatherMapInner({
@ -148,8 +124,8 @@ function WeatherMapInner({
weatherStations,
enabledLayers,
selectedStationId,
onStationClick,
);
onStationClick
)
// const oceanCurrentLayers = useOceanCurrentLayers({
// visible: enabledLayers.has('oceanCurrent'),
// opacity: 0.7,
@ -157,12 +133,12 @@ function WeatherMapInner({
const waterTempLayers = useWaterTemperatureLayers({
visible: enabledLayers.has('waterTemperature'),
opacity: 0.5,
});
})
const deckLayers = useMemo(
() => [...waterTempLayers, ...weatherDeckLayers],
[waterTempLayers, weatherDeckLayers],
);
[waterTempLayers, weatherDeckLayers]
)
return (
<>
@ -177,7 +153,9 @@ function WeatherMapInner({
/> */}
{/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */}
<OceanCurrentParticleLayer visible={enabledLayers.has('oceanCurrentParticle')} />
<OceanCurrentParticleLayer
visible={enabledLayers.has('oceanCurrentParticle')}
/>
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
<WeatherMapOverlay
@ -188,11 +166,18 @@ function WeatherMapInner({
/>
{/* 바람 파티클 애니메이션 (Canvas 직접 조작) */}
<WindParticleLayer visible={enabledLayers.has('windParticle')} stations={weatherStations} />
<WindParticleLayer
visible={enabledLayers.has('windParticle')}
stations={weatherStations}
/>
{/* 클릭 위치 마커 */}
{clickedLocation && (
<Marker longitude={clickedLocation.lon} latitude={clickedLocation.lat} anchor="bottom">
<Marker
longitude={clickedLocation.lon}
latitude={clickedLocation.lat}
anchor="bottom"
>
<div className="flex flex-col items-center pointer-events-none">
{/* 펄스 링 */}
<div className="relative flex items-center justify-center">
@ -212,11 +197,14 @@ function WeatherMapInner({
{/* 줌 컨트롤 */}
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
</>
);
)
}
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 {
// selectedForecast,
@ -226,55 +214,58 @@ export function WeatherView() {
// selectForecast,
// } = useOceanForecast('KOREA')
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0');
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null);
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0')
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null)
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
null,
);
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']));
null
)
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
// const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
// 첫 관측소 자동 선택 (파생 값)
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null;
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
const handleStationClick = useCallback((station: WeatherStation) => {
setSelectedStation(station);
setSelectedLocation(null);
}, []);
const handleStationClick = useCallback(
(station: WeatherStation) => {
setSelectedStation(station)
setSelectedLocation(null)
},
[]
)
const handleMapClick = useCallback(
(e: MapLayerMouseEvent) => {
const { lat, lng } = e.lngLat;
if (weatherStations.length === 0) return;
const { lat, lng } = e.lngLat
if (weatherStations.length === 0) return
// 가장 가까운 관측소 선택
const nearestStation = weatherStations.reduce((nearest, station) => {
const distance = Math.sqrt(
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lng, 2),
);
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lng, 2)
)
const nearestDistance = Math.sqrt(
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2),
);
return distance < nearestDistance ? station : nearest;
}, weatherStations[0]);
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2)
)
return distance < nearestDistance ? station : nearest
}, weatherStations[0])
setSelectedStation(nearestStation);
setSelectedLocation({ lat, lon: lng });
setSelectedStation(nearestStation)
setSelectedLocation({ lat, lon: lng })
},
[weatherStations],
);
[weatherStations]
)
const toggleLayer = useCallback((layer: string) => {
setEnabledLayers((prev) => {
const next = new Set(prev);
const next = new Set(prev)
if (next.has(layer)) {
next.delete(layer);
next.delete(layer)
} else {
next.add(layer);
next.add(layer)
}
return next;
});
}, []);
return next
})
}, [])
const weatherData = selectedStation
? {
@ -310,7 +301,7 @@ export function WeatherView() {
alert: '풍랑주의보 예상 08:00~',
forecast: generateForecast(timeOffset),
}
: null;
: null
return (
<div className="flex flex-1 overflow-hidden">
@ -360,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}
@ -377,10 +369,7 @@ export function WeatherView() {
</Map>
{/* 레이어 컨트롤 */}
<div
className="absolute top-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
style={{ padding: '6px 10px' }}
>
<div className="absolute top-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px' }}>
<div className="text-[9px] font-semibold text-fg mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1">
<label className="flex items-center gap-1.5 cursor-pointer">
@ -441,17 +430,12 @@ export function WeatherView() {
</div>
{/* 범례 */}
<div
className="absolute bottom-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
style={{ padding: '6px 10px', maxWidth: 180 }}
>
<div className="absolute bottom-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px', maxWidth: 180 }}>
<div className="text-[9px] font-semibold text-fg mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
{/* 바람 */}
<div>
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
(m/s)
</div>
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}> (m/s)</div>
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
@ -463,20 +447,12 @@ export function WeatherView() {
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
</div>
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
<span>3</span>
<span>5</span>
<span>7</span>
<span>10</span>
<span>13</span>
<span>16</span>
<span>20+</span>
<span>3</span><span>5</span><span>7</span><span>10</span><span>13</span><span>16</span><span>20+</span>
</div>
</div>
{/* 해류 */}
<div className="pt-1 border-t border-stroke">
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
(m/s)
</div>
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}> (m/s)</div>
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
@ -484,17 +460,12 @@ export function WeatherView() {
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
</div>
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
<span>0.2</span>
<span>0.4</span>
<span>0.6</span>
<span>0.6+</span>
<span>0.2</span><span>0.4</span><span>0.6</span><span>0.6+</span>
</div>
</div>
{/* 파고 */}
<div className="pt-1 border-t border-stroke">
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
(m)
</div>
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}> (m)</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-fg-disabled">&lt;1.5 </span>
@ -505,10 +476,7 @@ export function WeatherView() {
</div>
</div>
</div>
<div
className="mt-1 pt-1 border-t border-stroke text-fg-disabled font-korean"
style={{ fontSize: 7 }}
>
<div className="mt-1 pt-1 border-t border-stroke text-fg-disabled font-korean" style={{ fontSize: 7 }}>
💡
</div>
</div>
@ -518,5 +486,5 @@ export function WeatherView() {
{/* Right Panel */}
<WeatherRightPanel weatherData={weatherData} />
</div>
);
)
}