Merge branch 'develop' into feature/design-system-font
This commit is contained in:
커밋
76ab75f561
@ -5,29 +5,7 @@
|
|||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm run *)",
|
"Bash(*)"
|
||||||
"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 *)"
|
|
||||||
],
|
],
|
||||||
"deny": [
|
"deny": [
|
||||||
"Bash(git push --force*)",
|
"Bash(git push --force*)",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-03-30",
|
"applied_date": "2026-03-31",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
@ -3,6 +3,35 @@ import { Router } from 'express';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const VWORLD_API_KEY = process.env.VWORLD_API_KEY || '';
|
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 우회)
|
// GET /api/tiles/vworld/:z/:y/:x — VWorld WMTS 위성타일 프록시 (CORS 우회)
|
||||||
// VWorld는 브라우저 직접 요청에 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`;
|
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)' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!upstream.ok) {
|
// ─── S-57 전자해도 (ENC) ───
|
||||||
res.status(upstream.status).end();
|
// tiles.gcnautical.com CORS 제한 우회를 위한 프록시 엔드포인트 그룹
|
||||||
|
|
||||||
|
// GET /api/tiles/enc/style — 공식 style.json 프록시
|
||||||
|
router.get('/enc/style', async (_req, res) => {
|
||||||
|
await proxyUpstream(`${ENC_UPSTREAM}/style/nautical`, res, 'application/json');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
const fallbackCt = file.endsWith('.png') ? 'image/png' : 'application/json';
|
||||||
|
await proxyUpstream(`${ENC_UPSTREAM}/sprite/${file}`, res, fallbackCt);
|
||||||
|
});
|
||||||
|
|
||||||
const contentType = upstream.headers.get('content-type') || 'image/jpeg';
|
// GET /api/tiles/enc/font/:fontstack/:range — glyphs(PBF) 프록시
|
||||||
const cacheControl = upstream.headers.get('cache-control') || 'public, max-age=86400';
|
router.get('/enc/font/:fontstack/:range', async (req, res) => {
|
||||||
|
const { fontstack, range } = req.params;
|
||||||
res.setHeader('Content-Type', contentType);
|
if (!/^[\w\s%-]+$/.test(fontstack) || !/^\d+-\d+$/.test(range)) {
|
||||||
res.setHeader('Cache-Control', cacheControl);
|
res.status(400).json({ error: '잘못된 폰트 요청' });
|
||||||
|
return;
|
||||||
const buffer = await upstream.arrayBuffer();
|
|
||||||
res.end(Buffer.from(buffer));
|
|
||||||
} catch {
|
|
||||||
res.status(502).json({ error: 'VWorld 타일 서버 연결 실패' });
|
|
||||||
}
|
}
|
||||||
|
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;
|
export default router;
|
||||||
|
|||||||
@ -11,13 +11,25 @@
|
|||||||
- 구조 탭: RescueView
|
- 구조 탭: RescueView
|
||||||
- 하드코딩 색상(#hex, rgba) → CSS 변수 전환, 그라데이션 → 단색, fontFamily/fontSize → Tailwind 토큰
|
- 하드코딩 색상(#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]
|
## [2026-03-31]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
- 지도: S-57 전자해도(ENC) 오버레이 레이어 추가
|
||||||
|
- 지도: 전체 탭 지도 배경 토글 통합 (S-57/3D/밝은테마/기본지도)
|
||||||
|
- 공통: useBaseMapStyle 훅 및 mapStyles 공유 모듈 추가
|
||||||
- 다크/라이트 테마 전환 기능 (TopBar 퀵메뉴에서 토글)
|
- 다크/라이트 테마 전환 기능 (TopBar 퀵메뉴에서 토글)
|
||||||
- themeStore (Zustand) 테마 상태 관리 + localStorage 영속화
|
- themeStore (Zustand) 테마 상태 관리 + localStorage 영속화
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
|
- 지도: 초기 접속 시 기본지도(CartoDB Dark Matter) 표시로 변경 (S-57 기본 off)
|
||||||
- 디자인 시스템 토큰 시맨틱 네이밍 전환 (하드코딩 색상 → CSS 변수)
|
- 디자인 시스템 토큰 시맨틱 네이밍 전환 (하드코딩 색상 → CSS 변수)
|
||||||
- PretendardGOV 폰트 적용
|
- PretendardGOV 폰트 적용
|
||||||
- 라이트 테마 CSS 변수 오버라이드 및 컴포넌트별 스타일 대응
|
- 라이트 테마 CSS 변수 오버라이드 및 컴포넌트별 스타일 대응
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
274
frontend/src/common/components/map/S57EncOverlay.tsx
Normal file
274
frontend/src/common/components/map/S57EncOverlay.tsx
Normal file
@ -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;
|
||||||
|
}
|
||||||
248
frontend/src/common/components/map/mapStyles.ts
Normal file
248
frontend/src/common/components/map/mapStyles.ts
Normal file
@ -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:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'carto-dark-layer',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'carto-dark',
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 22,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// MarineTraffic 스타일 — 깔끔한 연한 회색 육지 + 흰색 바다 + 한글 라벨
|
||||||
|
export const LIGHT_STYLE: StyleSpecification = {
|
||||||
|
version: 8,
|
||||||
|
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
||||||
|
sources: {
|
||||||
|
'ofm-chart': {
|
||||||
|
type: 'vector',
|
||||||
|
url: 'https://tiles.openfreemap.org/planet',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
// ── 배경 = 육지 (연한 회색) ──
|
||||||
|
{
|
||||||
|
id: 'land-bg',
|
||||||
|
type: 'background',
|
||||||
|
paint: { 'background-color': '#e8e8e8' },
|
||||||
|
},
|
||||||
|
// ── 바다/호수/강 = water 레이어 (파란색) ──
|
||||||
|
{
|
||||||
|
id: 'water',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'ofm-chart',
|
||||||
|
'source-layer': 'water',
|
||||||
|
paint: { 'fill-color': '#a8cce0' },
|
||||||
|
},
|
||||||
|
// ── 주요 도로 (zoom 9+) ──
|
||||||
|
{
|
||||||
|
id: 'roads-major',
|
||||||
|
type: 'line',
|
||||||
|
source: 'ofm-chart',
|
||||||
|
'source-layer': 'transportation',
|
||||||
|
minzoom: 9,
|
||||||
|
filter: ['in', 'class', 'motorway', 'trunk', 'primary'],
|
||||||
|
paint: {
|
||||||
|
'line-color': '#c0c0c0',
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.4, 14, 1.5],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ── 보조 도로 (zoom 12+) ──
|
||||||
|
{
|
||||||
|
id: 'roads-secondary',
|
||||||
|
type: 'line',
|
||||||
|
source: 'ofm-chart',
|
||||||
|
'source-layer': 'transportation',
|
||||||
|
minzoom: 12,
|
||||||
|
filter: ['in', 'class', 'secondary', 'tertiary'],
|
||||||
|
paint: {
|
||||||
|
'line-color': '#cccccc',
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 0.3, 14, 1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ── 건물 (zoom 14+) ──
|
||||||
|
{
|
||||||
|
id: 'buildings',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'ofm-chart',
|
||||||
|
'source-layer': 'building',
|
||||||
|
minzoom: 14,
|
||||||
|
paint: { 'fill-color': '#cbcbcb', 'fill-opacity': 0.5 },
|
||||||
|
},
|
||||||
|
// ── 국경선 ──
|
||||||
|
{
|
||||||
|
id: 'boundaries-country',
|
||||||
|
type: 'line',
|
||||||
|
source: 'ofm-chart',
|
||||||
|
'source-layer': 'boundary',
|
||||||
|
filter: ['==', 'admin_level', 2],
|
||||||
|
paint: { 'line-color': '#999999', 'line-width': 1, 'line-dasharray': [4, 2] },
|
||||||
|
},
|
||||||
|
// ── 시도 경계 (zoom 5+) ──
|
||||||
|
{
|
||||||
|
id: 'boundaries-province',
|
||||||
|
type: 'line',
|
||||||
|
source: 'ofm-chart',
|
||||||
|
'source-layer': 'boundary',
|
||||||
|
minzoom: 5,
|
||||||
|
filter: ['==', 'admin_level', 4],
|
||||||
|
paint: { 'line-color': '#bbbbbb', 'line-width': 0.5, 'line-dasharray': [3, 2] },
|
||||||
|
},
|
||||||
|
// ── 국가/시도 라벨 (한글) ──
|
||||||
|
{
|
||||||
|
id: 'place-labels-major',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'ofm-chart',
|
||||||
|
'source-layer': 'place',
|
||||||
|
minzoom: 3,
|
||||||
|
filter: ['in', 'class', 'country', 'state'],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']],
|
||||||
|
'text-font': ['Open Sans Bold'],
|
||||||
|
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 11, 8, 16],
|
||||||
|
'text-max-width': 8,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#555555',
|
||||||
|
'text-halo-color': '#ffffff',
|
||||||
|
'text-halo-width': 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'place-labels-city',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'ofm-chart',
|
||||||
|
'source-layer': 'place',
|
||||||
|
minzoom: 5,
|
||||||
|
filter: ['in', 'class', 'city', 'town'],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']],
|
||||||
|
'text-font': ['Open Sans Regular'],
|
||||||
|
'text-size': ['interpolate', ['linear'], ['zoom'], 5, 10, 12, 14],
|
||||||
|
'text-max-width': 7,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#666666',
|
||||||
|
'text-halo-color': '#ffffff',
|
||||||
|
'text-halo-width': 1.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ── 해양 지명 (water_name) ──
|
||||||
|
{
|
||||||
|
id: 'water-labels',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'ofm-chart',
|
||||||
|
'source-layer': 'water_name',
|
||||||
|
layout: {
|
||||||
|
'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']],
|
||||||
|
'text-font': ['Open Sans Italic'],
|
||||||
|
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 10, 8, 14],
|
||||||
|
'text-max-width': 10,
|
||||||
|
'text-letter-spacing': 0.15,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#8899aa',
|
||||||
|
'text-halo-color': 'rgba(168,204,224,0.7)',
|
||||||
|
'text-halo-width': 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ── 마을/소지명 (zoom 10+) ──
|
||||||
|
{
|
||||||
|
id: 'place-labels-village',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'ofm-chart',
|
||||||
|
'source-layer': 'place',
|
||||||
|
minzoom: 10,
|
||||||
|
filter: ['in', 'class', 'village', 'suburb', 'hamlet'],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']],
|
||||||
|
'text-font': ['Open Sans Regular'],
|
||||||
|
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 9, 14, 12],
|
||||||
|
'text-max-width': 6,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#777777',
|
||||||
|
'text-halo-color': '#ffffff',
|
||||||
|
'text-halo-width': 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3D 위성 스타일 (VWorld 위성 배경 + OpenFreeMap 건물 extrusion)
|
||||||
|
// VWorld WMTS: {z}/{y}/{x} (row/col 순서)
|
||||||
|
// OpenFreeMap: CORS 허용, 전 세계 OSM 벡터타일 (building: render_height 포함)
|
||||||
|
export const SATELLITE_3D_STYLE: StyleSpecification = {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
'vworld-satellite': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: ['/api/tiles/vworld/{z}/{y}/{x}.jpeg'],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: '© <a href="https://www.vworld.kr">국토지리정보원 VWorld</a>',
|
||||||
|
},
|
||||||
|
ofm: {
|
||||||
|
type: 'vector',
|
||||||
|
url: 'https://tiles.openfreemap.org/planet',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'satellite-base',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'vworld-satellite',
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 22,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'roads-3d',
|
||||||
|
type: 'line',
|
||||||
|
source: 'ofm',
|
||||||
|
'source-layer': 'transportation',
|
||||||
|
filter: ['in', ['get', 'class'], ['literal', ['motorway', 'trunk', 'primary', 'secondary']]],
|
||||||
|
paint: {
|
||||||
|
'line-color': 'rgba(255,255,200,0.3)',
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 16, 3],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3d-buildings',
|
||||||
|
type: 'fill-extrusion',
|
||||||
|
source: 'ofm',
|
||||||
|
'source-layer': 'building',
|
||||||
|
minzoom: 13,
|
||||||
|
filter: ['!=', ['get', 'hide_3d'], true],
|
||||||
|
paint: {
|
||||||
|
'fill-extrusion-color': '#c8b99a',
|
||||||
|
'fill-extrusion-height': ['max', ['coalesce', ['get', 'render_height'], 0], 3],
|
||||||
|
'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0],
|
||||||
|
'fill-extrusion-opacity': 0.85,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// S-57 전자해도 전용 빈 스타일 (ENC 레이어가 자체적으로 육지/해안/수심 모두 포함)
|
||||||
|
export const ENC_EMPTY_STYLE: StyleSpecification = {
|
||||||
|
version: 8,
|
||||||
|
sources: {},
|
||||||
|
layers: [],
|
||||||
|
};
|
||||||
17
frontend/src/common/hooks/useBaseMapStyle.ts
Normal file
17
frontend/src/common/hooks/useBaseMapStyle.ts
Normal file
@ -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 { create } from 'zustand'
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api'
|
||||||
import { haversineDistance, polygonAreaKm2 } from '../utils/geo';
|
import { haversineDistance, polygonAreaKm2 } from '../utils/geo'
|
||||||
|
|
||||||
export interface MapTypeItem {
|
export interface MapTypeItem {
|
||||||
mapKey: string;
|
mapKey: string;
|
||||||
@ -50,33 +50,34 @@ const DEFAULT_MAP_TYPES: MapTypeItem[] = [
|
|||||||
{ mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' },
|
{ mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' },
|
||||||
{ mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' },
|
{ mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' },
|
||||||
{ mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' },
|
{ mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' },
|
||||||
];
|
]
|
||||||
|
|
||||||
let measureIdCounter = 0;
|
let measureIdCounter = 0;
|
||||||
|
|
||||||
export const useMapStore = create<MapState>((set, get) => ({
|
export const useMapStore = create<MapState>((set, get) => ({
|
||||||
mapToggles: { s57: true, s101: false, threeD: false, satellite: false },
|
mapToggles: { s57: false, s101: false, threeD: false, satellite: false },
|
||||||
mapTypes: DEFAULT_MAP_TYPES,
|
mapTypes: DEFAULT_MAP_TYPES,
|
||||||
toggleMap: (key) =>
|
toggleMap: (key) =>
|
||||||
set((s) => ({
|
set((s) => {
|
||||||
mapToggles: { ...s.mapToggles, [key]: !s.mapToggles[key] },
|
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 () => {
|
loadMapTypes: async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get<MapTypeItem[]>('/map-base/active');
|
const res = await api.get<MapTypeItem[]>('/map-base/active')
|
||||||
const types = res.data;
|
const types = res.data
|
||||||
const current = get().mapToggles;
|
const current = get().mapToggles
|
||||||
const newToggles: Partial<MapToggles> = {};
|
const newToggles: Partial<MapToggles> = {}
|
||||||
for (const t of types) {
|
for (const t of types) {
|
||||||
if (t.mapKey in current) {
|
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 기본값 유지
|
// 모든 토글 기본 off (기본지도 표시)
|
||||||
if (newToggles['s57'] === undefined && types.find((t) => t.mapKey === 's57')) {
|
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } })
|
||||||
newToggles['s57'] = true;
|
|
||||||
}
|
|
||||||
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } });
|
|
||||||
} catch {
|
} catch {
|
||||||
// API 실패 시 fallback 유지
|
// API 실패 시 fallback 유지
|
||||||
}
|
}
|
||||||
@ -87,7 +88,8 @@ export const useMapStore = create<MapState>((set, get) => ({
|
|||||||
measureInProgress: [],
|
measureInProgress: [],
|
||||||
measurements: [],
|
measurements: [],
|
||||||
|
|
||||||
setMeasureMode: (mode) => set({ measureMode: mode, measureInProgress: [] }),
|
setMeasureMode: (mode) =>
|
||||||
|
set({ measureMode: mode, measureInProgress: [] }),
|
||||||
|
|
||||||
addMeasurePoint: (pt) => {
|
addMeasurePoint: (pt) => {
|
||||||
const { measureMode, measureInProgress } = get();
|
const { measureMode, measureInProgress } = get();
|
||||||
@ -97,10 +99,7 @@ export const useMapStore = create<MapState>((set, get) => ({
|
|||||||
const dist = haversineDistance(next[0], next[1]);
|
const dist = haversineDistance(next[0], next[1]);
|
||||||
const id = `measure-${++measureIdCounter}`;
|
const id = `measure-${++measureIdCounter}`;
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
measurements: [
|
measurements: [...s.measurements, { id, mode: 'distance', points: [next[0], next[1]], value: dist }],
|
||||||
...s.measurements,
|
|
||||||
{ id, mode: 'distance', points: [next[0], next[1]], value: dist },
|
|
||||||
],
|
|
||||||
measureInProgress: [],
|
measureInProgress: [],
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
@ -117,10 +116,7 @@ export const useMapStore = create<MapState>((set, get) => ({
|
|||||||
const area = polygonAreaKm2(measureInProgress);
|
const area = polygonAreaKm2(measureInProgress);
|
||||||
const id = `measure-${++measureIdCounter}`;
|
const id = `measure-${++measureIdCounter}`;
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
measurements: [
|
measurements: [...s.measurements, { id, mode: 'area', points: [...measureInProgress], value: area }],
|
||||||
...s.measurements,
|
|
||||||
{ id, mode: 'area', points: [...measureInProgress], value: area },
|
|
||||||
],
|
|
||||||
measureInProgress: [],
|
measureInProgress: [],
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@ -128,5 +124,6 @@ export const useMapStore = create<MapState>((set, get) => ({
|
|||||||
removeMeasurement: (id) =>
|
removeMeasurement: (id) =>
|
||||||
set((s) => ({ measurements: s.measurements.filter((m) => m.id !== 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 { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
import { GeoJsonLayer } from '@deck.gl/layers';
|
import { GeoJsonLayer } from '@deck.gl/layers';
|
||||||
import type { Layer } from '@deck.gl/core';
|
import type { Layer } from '@deck.gl/core';
|
||||||
import type { StyleSpecification } from 'maplibre-gl';
|
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||||
// CartoDB Dark Matter 스타일
|
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||||||
const MAP_STYLE: StyleSpecification = {
|
import { useMapStore } from '@common/store/mapStore';
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
'carto-dark': {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: [
|
|
||||||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
|
||||||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
|
||||||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
|
||||||
],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution:
|
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: 'carto-dark-layer',
|
|
||||||
type: 'raster',
|
|
||||||
source: 'carto-dark',
|
|
||||||
minzoom: 0,
|
|
||||||
maxzoom: 22,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAP_CENTER: [number, number] = [127.5, 36.0];
|
const MAP_CENTER: [number, number] = [127.5, 36.0];
|
||||||
const MAP_ZOOM = 5.5;
|
const MAP_ZOOM = 5.5;
|
||||||
@ -82,6 +57,8 @@ const ZONE_INFO: Record<ZoneKey, { label: string; rows: { key: string; value: st
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DispersingZonePanel = () => {
|
const DispersingZonePanel = () => {
|
||||||
|
const currentMapStyle = useBaseMapStyle();
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||||
const [showConsider, setShowConsider] = useState(true);
|
const [showConsider, setShowConsider] = useState(true);
|
||||||
const [showRestrict, setShowRestrict] = useState(true);
|
const [showRestrict, setShowRestrict] = useState(true);
|
||||||
const [expandedZone, setExpandedZone] = useState<ZoneKey | null>(null);
|
const [expandedZone, setExpandedZone] = useState<ZoneKey | null>(null);
|
||||||
@ -209,8 +186,9 @@ const DispersingZonePanel = () => {
|
|||||||
zoom: MAP_ZOOM,
|
zoom: MAP_ZOOM,
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
mapStyle={MAP_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
<DeckGLOverlay layers={layers} />
|
<DeckGLOverlay layers={layers} />
|
||||||
</Map>
|
</Map>
|
||||||
|
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,224 +1,139 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Map, Marker, Popup } from '@vis.gl/react-maplibre';
|
import { Map, Marker, Popup } from '@vis.gl/react-maplibre'
|
||||||
import type { StyleSpecification } from 'maplibre-gl';
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi'
|
||||||
import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi';
|
import type { DroneStreamItem } from '../services/aerialApi'
|
||||||
import type { DroneStreamItem } from '../services/aerialApi';
|
import { CCTVPlayer } from './CCTVPlayer'
|
||||||
import { CCTVPlayer } from './CCTVPlayer';
|
import type { CCTVPlayerHandle } from './CCTVPlayer'
|
||||||
import type { CCTVPlayerHandle } from './CCTVPlayer';
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
|
||||||
|
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
|
||||||
|
import { useMapStore } from '@common/store/mapStore'
|
||||||
|
|
||||||
/** 함정 위치 + 드론 비행 위치 */
|
/** 함정 위치 + 드론 비행 위치 */
|
||||||
const DRONE_POSITIONS: Record<
|
const DRONE_POSITIONS: Record<string, { ship: { lat: number; lon: number }; drone: { lat: number; lon: number } }> = {
|
||||||
string,
|
'busan-1501': { ship: { lat: 35.0796, lon: 129.0756 }, drone: { lat: 35.1100, lon: 129.1100 } },
|
||||||
{ ship: { lat: number; lon: number }; drone: { lat: number; lon: number } }
|
'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 } },
|
||||||
'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 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RealtimeDrone() {
|
export function RealtimeDrone() {
|
||||||
const [streams, setStreams] = useState<DroneStreamItem[]>([]);
|
const [streams, setStreams] = useState<DroneStreamItem[]>([])
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true)
|
||||||
const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null);
|
const [selectedStream, setSelectedStream] = useState<DroneStreamItem | null>(null)
|
||||||
const [gridMode, setGridMode] = useState(1);
|
const [gridMode, setGridMode] = useState(1)
|
||||||
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([]);
|
const [activeCells, setActiveCells] = useState<DroneStreamItem[]>([])
|
||||||
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(null);
|
const [mapPopup, setMapPopup] = useState<DroneStreamItem | null>(null)
|
||||||
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]);
|
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
|
||||||
|
const currentMapStyle = useBaseMapStyle()
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||||
|
|
||||||
const showMap = activeCells.length === 0;
|
const showMap = activeCells.length === 0
|
||||||
|
|
||||||
const loadStreams = useCallback(async () => {
|
const loadStreams = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const items = await fetchDroneStreams();
|
const items = await fetchDroneStreams()
|
||||||
setStreams(items);
|
setStreams(items)
|
||||||
// Update selected stream and active cells with latest status
|
// Update selected stream and active cells with latest status
|
||||||
setSelectedStream((prev) => (prev ? (items.find((s) => s.id === prev.id) ?? prev) : prev));
|
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));
|
setActiveCells(prev => prev.map(cell => items.find(s => s.id === cell.id) ?? cell))
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: show configured streams as idle
|
// Fallback: show configured streams as idle
|
||||||
setStreams([
|
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: 'busan-1501',
|
{ 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 },
|
||||||
name: '1501함 드론',
|
{ 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 },
|
||||||
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStreams();
|
loadStreams()
|
||||||
}, [loadStreams]);
|
}, [loadStreams])
|
||||||
|
|
||||||
// Poll status every 3 seconds when any stream is starting
|
// Poll status every 3 seconds when any stream is starting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasStarting = streams.some((s) => s.status === 'starting');
|
const hasStarting = streams.some(s => s.status === 'starting')
|
||||||
if (!hasStarting) return;
|
if (!hasStarting) return
|
||||||
const timer = setInterval(loadStreams, 3000);
|
const timer = setInterval(loadStreams, 3000)
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer)
|
||||||
}, [streams, loadStreams]);
|
}, [streams, loadStreams])
|
||||||
|
|
||||||
const handleStartStream = async (id: string) => {
|
const handleStartStream = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await startDroneStreamApi(id);
|
await startDroneStreamApi(id)
|
||||||
// Immediately update to 'starting' state
|
// Immediately update to 'starting' state
|
||||||
setStreams((prev) =>
|
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'starting' as const, error: null } : s))
|
||||||
prev.map((s) => (s.id === id ? { ...s, status: 'starting' as const, error: null } : s)),
|
|
||||||
);
|
|
||||||
// Poll for status update
|
// Poll for status update
|
||||||
setTimeout(loadStreams, 2000);
|
setTimeout(loadStreams, 2000)
|
||||||
} catch {
|
} catch {
|
||||||
setStreams((prev) =>
|
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s))
|
||||||
prev.map((s) =>
|
}
|
||||||
s.id === id ? { ...s, status: 'error' as const, error: '스트림 시작 요청 실패' } : s,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleStopStream = async (id: string) => {
|
const handleStopStream = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await stopDroneStreamApi(id);
|
await stopDroneStreamApi(id)
|
||||||
setStreams((prev) =>
|
setStreams(prev => prev.map(s => s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s))
|
||||||
prev.map((s) =>
|
setActiveCells(prev => prev.filter(c => c.id !== id))
|
||||||
s.id === id ? { ...s, status: 'idle' as const, hlsUrl: null, error: null } : s,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setActiveCells((prev) => prev.filter((c) => c.id !== id));
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSelectStream = (stream: DroneStreamItem) => {
|
const handleSelectStream = (stream: DroneStreamItem) => {
|
||||||
setSelectedStream(stream);
|
setSelectedStream(stream)
|
||||||
if (stream.status === 'streaming' && stream.hlsUrl) {
|
if (stream.status === 'streaming' && stream.hlsUrl) {
|
||||||
if (gridMode === 1) {
|
if (gridMode === 1) {
|
||||||
setActiveCells([stream]);
|
setActiveCells([stream])
|
||||||
} else {
|
} else {
|
||||||
setActiveCells((prev) => {
|
setActiveCells(prev => {
|
||||||
if (prev.length < gridMode && !prev.find((c) => c.id === stream.id))
|
if (prev.length < gridMode && !prev.find(c => c.id === stream.id)) return [...prev, stream]
|
||||||
return [...prev, stream];
|
return prev
|
||||||
return prev;
|
})
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const statusInfo = (status: string) => {
|
const statusInfo = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'streaming':
|
case 'streaming': return { label: '송출중', color: 'var(--color-success)', bg: 'rgba(34,197,94,.12)' }
|
||||||
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 'starting':
|
case 'error': return { label: '오류', color: 'var(--color-danger)', bg: 'rgba(239,68,68,.12)' }
|
||||||
return { label: '연결중', color: 'var(--color-accent)', bg: 'rgba(6,182,212,.12)' };
|
default: return { label: '대기', color: 'var(--fg-disabled)', bg: 'rgba(255,255,255,.06)' }
|
||||||
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 gridCols = gridMode === 1 ? 1 : 2
|
||||||
const totalCells = gridMode;
|
const totalCells = gridMode
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||||||
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="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="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="flex items-center justify-between mb-2">
|
||||||
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
|
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
|
||||||
<span
|
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: streams.some(s => s.status === 'streaming') ? 'var(--color-success)' : 'var(--fg-disabled)' }} />
|
||||||
className="w-[7px] h-[7px] rounded-full inline-block"
|
|
||||||
style={{
|
|
||||||
background: streams.some((s) => s.status === 'streaming')
|
|
||||||
? 'var(--color-success)'
|
|
||||||
: 'var(--fg-disabled)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
실시간 드론 영상
|
실시간 드론 영상
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={loadStreams}
|
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"
|
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>
|
||||||
새로고침
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-[9px] text-fg-disabled font-korean">
|
|
||||||
ViewLink RTSP 스트림 · 내부망 전용
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-[9px] text-fg-disabled font-korean">ViewLink RTSP 스트림 · 내부망 전용</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 드론 스트림 카드 */}
|
{/* 드론 스트림 카드 */}
|
||||||
<div
|
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||||
className="flex-1 overflow-y-auto"
|
|
||||||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="px-3.5 py-4 text-[11px] text-fg-disabled font-korean">
|
<div className="px-3.5 py-4 text-[11px] text-fg-disabled font-korean">불러오는 중...</div>
|
||||||
불러오는 중...
|
) : streams.map(stream => {
|
||||||
</div>
|
const si = statusInfo(stream.status)
|
||||||
) : (
|
const isSelected = selectedStream?.id === stream.id
|
||||||
streams.map((stream) => {
|
|
||||||
const si = statusInfo(stream.status);
|
|
||||||
const isSelected = selectedStream?.id === stream.id;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={stream.id}
|
key={stream.id}
|
||||||
@ -233,30 +148,19 @@ export function RealtimeDrone() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-sm">🚁</div>
|
<div className="text-sm">🚁</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-semibold text-fg font-korean">
|
<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>
|
||||||
{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 className="text-[9px] text-fg-disabled font-mono">{stream.ip}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="text-[8px] font-bold px-1.5 py-0.5 rounded-full"
|
className="text-[8px] font-bold px-1.5 py-0.5 rounded-full"
|
||||||
style={{ background: si.bg, color: si.color }}
|
style={{ background: si.bg, color: si.color }}
|
||||||
>
|
>{si.label}</span>
|
||||||
{si.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<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">
|
<span className="text-[8px] text-fg-disabled font-korean px-1.5 py-0.5 rounded bg-bg-card">{stream.region}</span>
|
||||||
{stream.region}
|
<span className="text-[8px] text-fg-disabled font-mono px-1.5 py-0.5 rounded bg-bg-card">RTSP :554</span>
|
||||||
</span>
|
|
||||||
<span className="text-[8px] text-fg-disabled font-mono px-1.5 py-0.5 rounded bg-bg-card">
|
|
||||||
RTSP :554
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stream.error && (
|
{stream.error && (
|
||||||
@ -269,46 +173,28 @@ export function RealtimeDrone() {
|
|||||||
<div className="mt-2 flex gap-1.5">
|
<div className="mt-2 flex gap-1.5">
|
||||||
{stream.status === 'idle' || stream.status === 'error' ? (
|
{stream.status === 'idle' || stream.status === 'error' ? (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); handleStartStream(stream.id) }}
|
||||||
e.stopPropagation();
|
|
||||||
handleStartStream(stream.id);
|
|
||||||
}}
|
|
||||||
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
|
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
|
||||||
style={{
|
style={{ background: 'rgba(34,197,94,.1)', borderColor: 'rgba(34,197,94,.3)', color: 'var(--color-success)' }}
|
||||||
background: 'rgba(34,197,94,.1)',
|
>▶ 스트림 시작</button>
|
||||||
borderColor: 'rgba(34,197,94,.3)',
|
|
||||||
color: 'var(--color-success)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
▶ 스트림 시작
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); handleStopStream(stream.id) }}
|
||||||
e.stopPropagation();
|
|
||||||
handleStopStream(stream.id);
|
|
||||||
}}
|
|
||||||
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
|
className="flex-1 px-2 py-1 text-[9px] font-bold font-korean rounded border cursor-pointer transition-colors"
|
||||||
style={{
|
style={{ background: 'rgba(239,68,68,.1)', borderColor: 'rgba(239,68,68,.3)', color: 'var(--color-danger)' }}
|
||||||
background: 'rgba(239,68,68,.1)',
|
>■ 스트림 중지</button>
|
||||||
borderColor: 'rgba(239,68,68,.3)',
|
|
||||||
color: 'var(--color-danger)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
■ 스트림 중지
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
})
|
})}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하단 안내 */}
|
{/* 하단 안내 */}
|
||||||
<div className="px-3 py-2 border-t border-stroke bg-bg-elevated shrink-0">
|
<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">
|
<div className="text-[8px] text-fg-disabled font-korean leading-relaxed">
|
||||||
RTSP 스트림은 해양경찰 내부망에서만 접속 가능합니다. ViewLink 프로그램과 연동됩니다.
|
RTSP 스트림은 해양경찰 내부망에서만 접속 가능합니다.
|
||||||
|
ViewLink 프로그램과 연동됩니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -322,35 +208,13 @@ export function RealtimeDrone() {
|
|||||||
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
|
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
|
||||||
</div>
|
</div>
|
||||||
{selectedStream?.status === 'streaming' && (
|
{selectedStream?.status === 'streaming' && (
|
||||||
<div
|
<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)' }}>
|
||||||
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0"
|
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-success)' }} />LIVE
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedStream?.status === 'starting' && (
|
{selectedStream?.status === 'starting' && (
|
||||||
<div
|
<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)' }}>
|
||||||
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0"
|
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-accent)' }} />연결중
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -360,31 +224,23 @@ export function RealtimeDrone() {
|
|||||||
{[
|
{[
|
||||||
{ mode: 1, icon: '▣', label: '1화면' },
|
{ mode: 1, icon: '▣', label: '1화면' },
|
||||||
{ mode: 4, icon: '⊞', label: '4분할' },
|
{ mode: 4, icon: '⊞', label: '4분할' },
|
||||||
].map((g) => (
|
].map(g => (
|
||||||
<button
|
<button
|
||||||
key={g.mode}
|
key={g.mode}
|
||||||
onClick={() => {
|
onClick={() => { setGridMode(g.mode); setActiveCells(prev => prev.slice(0, g.mode)) }}
|
||||||
setGridMode(g.mode);
|
|
||||||
setActiveCells((prev) => prev.slice(0, g.mode));
|
|
||||||
}}
|
|
||||||
title={g.label}
|
title={g.label}
|
||||||
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
|
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
|
||||||
style={
|
style={gridMode === g.mode
|
||||||
gridMode === g.mode
|
|
||||||
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
|
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
|
||||||
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
|
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
|
||||||
}
|
}
|
||||||
>
|
>{g.icon}</button>
|
||||||
{g.icon}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -393,145 +249,53 @@ export function RealtimeDrone() {
|
|||||||
<div className="flex-1 overflow-hidden relative">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
<Map
|
<Map
|
||||||
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
|
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
|
||||||
mapStyle={DRONE_MAP_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
{streams.map((stream) => {
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
const pos = DRONE_POSITIONS[stream.id];
|
{streams.map(stream => {
|
||||||
if (!pos) return null;
|
const pos = DRONE_POSITIONS[stream.id]
|
||||||
const statusColor =
|
if (!pos) return null
|
||||||
stream.status === 'streaming'
|
const statusColor = stream.status === 'streaming' ? '#22c55e' : stream.status === 'starting' ? '#06b6d4' : stream.status === 'error' ? '#ef4444' : '#94a3b8'
|
||||||
? '#22c55e'
|
|
||||||
: stream.status === 'starting'
|
|
||||||
? '#06b6d4'
|
|
||||||
: stream.status === 'error'
|
|
||||||
? '#ef4444'
|
|
||||||
: '#94a3b8';
|
|
||||||
return (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
key={stream.id}
|
key={stream.id}
|
||||||
longitude={(pos.ship.lon + pos.drone.lon) / 2}
|
longitude={(pos.ship.lon + pos.drone.lon) / 2}
|
||||||
latitude={(pos.ship.lat + pos.drone.lat) / 2}
|
latitude={(pos.ship.lat + pos.drone.lat) / 2}
|
||||||
anchor="center"
|
anchor="center"
|
||||||
onClick={(e) => {
|
onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(stream) }}
|
||||||
e.originalEvent.stopPropagation();
|
|
||||||
setMapPopup(stream);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="cursor-pointer group" title={stream.shipName}>
|
<div className="cursor-pointer group" title={stream.shipName}>
|
||||||
<svg
|
<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' }}>
|
||||||
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
|
<line x1="28" y1="52" x2="88" y2="30" stroke={statusColor} strokeWidth="1.2" strokeDasharray="4 3" opacity="0.5" />
|
||||||
x1="28"
|
|
||||||
y1="52"
|
|
||||||
x2="88"
|
|
||||||
y2="30"
|
|
||||||
stroke={statusColor}
|
|
||||||
strokeWidth="1.2"
|
|
||||||
strokeDasharray="4 3"
|
|
||||||
opacity="0.5"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── 함정: MarineTraffic 스타일 삼각형 (선수 방향 위) ── */}
|
{/* ── 함정: MarineTraffic 스타일 삼각형 (선수 방향 위) ── */}
|
||||||
<polygon points="28,38 18,58 38,58" fill={statusColor} opacity="0.85" />
|
<polygon points="28,38 18,58 38,58" fill={statusColor} opacity="0.85" />
|
||||||
<polygon
|
<polygon points="28,38 18,58 38,58" fill="none" stroke="#fff" strokeWidth="0.8" opacity="0.5" />
|
||||||
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)" />
|
<rect x="3" y="61" width="50" height="13" rx="3" fill="rgba(0,0,0,.75)" />
|
||||||
<text
|
<text x="28" y="70.5" textAnchor="middle" fill="#fff" fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.shipName.replace(/서 /, ' ')}</text>
|
||||||
x="28"
|
|
||||||
y="70.5"
|
|
||||||
textAnchor="middle"
|
|
||||||
fill="#fff"
|
|
||||||
fontSize="7"
|
|
||||||
fontFamily="sans-serif"
|
|
||||||
fontWeight="bold"
|
|
||||||
>
|
|
||||||
{stream.shipName.replace(/서 /, ' ')}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* ── 드론: 쿼드콥터 아이콘 ── */}
|
{/* ── 드론: 쿼드콥터 아이콘 ── */}
|
||||||
{/* 외곽 원 */}
|
{/* 외곽 원 */}
|
||||||
<circle
|
<circle cx="88" cy="30" r="18" fill="rgba(10,14,24,.7)" stroke={statusColor} strokeWidth="1.5" />
|
||||||
cx="88"
|
|
||||||
cy="30"
|
|
||||||
r="18"
|
|
||||||
fill="rgba(10,14,24,.7)"
|
|
||||||
stroke={statusColor}
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
{/* X자 팔 */}
|
{/* X자 팔 */}
|
||||||
<line
|
<line x1="76" y1="18" x2="100" y2="42" stroke={statusColor} strokeWidth="1.2" opacity="0.5" />
|
||||||
x1="76"
|
<line x1="100" y1="18" x2="76" y2="42" stroke={statusColor} strokeWidth="1.2" opacity="0.5" />
|
||||||
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개 (회전 애니메이션) */}
|
{/* 프로펠러 4개 (회전 애니메이션) */}
|
||||||
<ellipse cx="76" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
<ellipse cx="76" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
||||||
<animateTransform
|
<animateTransform attributeName="transform" type="rotate" from="0 76 18" to="360 76 18" dur="1.5s" repeatCount="indefinite" />
|
||||||
attributeName="transform"
|
|
||||||
type="rotate"
|
|
||||||
from="0 76 18"
|
|
||||||
to="360 76 18"
|
|
||||||
dur="1.5s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</ellipse>
|
</ellipse>
|
||||||
<ellipse cx="100" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
<ellipse cx="100" cy="18" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
||||||
<animateTransform
|
<animateTransform attributeName="transform" type="rotate" from="0 100 18" to="-360 100 18" dur="1.5s" repeatCount="indefinite" />
|
||||||
attributeName="transform"
|
|
||||||
type="rotate"
|
|
||||||
from="0 100 18"
|
|
||||||
to="-360 100 18"
|
|
||||||
dur="1.5s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</ellipse>
|
</ellipse>
|
||||||
<ellipse cx="76" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
<ellipse cx="76" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
||||||
<animateTransform
|
<animateTransform attributeName="transform" type="rotate" from="0 76 42" to="-360 76 42" dur="1.5s" repeatCount="indefinite" />
|
||||||
attributeName="transform"
|
|
||||||
type="rotate"
|
|
||||||
from="0 76 42"
|
|
||||||
to="-360 76 42"
|
|
||||||
dur="1.5s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</ellipse>
|
</ellipse>
|
||||||
<ellipse cx="100" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
<ellipse cx="100" cy="42" rx="5" ry="2.5" fill={statusColor} opacity="0.35">
|
||||||
<animateTransform
|
<animateTransform attributeName="transform" type="rotate" from="0 100 42" to="360 100 42" dur="1.5s" repeatCount="indefinite" />
|
||||||
attributeName="transform"
|
|
||||||
type="rotate"
|
|
||||||
from="0 100 42"
|
|
||||||
to="360 100 42"
|
|
||||||
dur="1.5s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</ellipse>
|
</ellipse>
|
||||||
{/* 본체 */}
|
{/* 본체 */}
|
||||||
<circle cx="88" cy="30" r="6" fill={statusColor} opacity="0.8" />
|
<circle cx="88" cy="30" r="6" fill={statusColor} opacity="0.8" />
|
||||||
@ -541,31 +305,16 @@ export function RealtimeDrone() {
|
|||||||
{/* 송출중 REC LED */}
|
{/* 송출중 REC LED */}
|
||||||
{stream.status === 'streaming' && (
|
{stream.status === 'streaming' && (
|
||||||
<circle cx="100" cy="16" r="3" fill="#ef4444">
|
<circle cx="100" cy="16" r="3" fill="#ef4444">
|
||||||
<animate
|
<animate attributeName="opacity" values="1;0.2;1" dur="1s" repeatCount="indefinite" />
|
||||||
attributeName="opacity"
|
|
||||||
values="1;0.2;1"
|
|
||||||
dur="1s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</circle>
|
</circle>
|
||||||
)}
|
)}
|
||||||
{/* 드론 모델명 */}
|
{/* 드론 모델명 */}
|
||||||
<rect x="65" y="51" width="46" height="12" rx="3" fill="rgba(0,0,0,.75)" />
|
<rect x="65" y="51" width="46" height="12" rx="3" fill="rgba(0,0,0,.75)" />
|
||||||
<text
|
<text x="88" y="60" textAnchor="middle" fill={statusColor} fontSize="7" fontFamily="sans-serif" fontWeight="bold">{stream.droneModel.split(' ').slice(-1)[0]}</text>
|
||||||
x="88"
|
|
||||||
y="60"
|
|
||||||
textAnchor="middle"
|
|
||||||
fill={statusColor}
|
|
||||||
fontSize="7"
|
|
||||||
fontFamily="sans-serif"
|
|
||||||
fontWeight="bold"
|
|
||||||
>
|
|
||||||
{stream.droneModel.split(' ').slice(-1)[0]}
|
|
||||||
</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
{/* 드론 클릭 팝업 */}
|
{/* 드론 클릭 팝업 */}
|
||||||
{mapPopup && DRONE_POSITIONS[mapPopup.id] && (
|
{mapPopup && DRONE_POSITIONS[mapPopup.id] && (
|
||||||
@ -578,102 +327,56 @@ export function RealtimeDrone() {
|
|||||||
offset={36}
|
offset={36}
|
||||||
className="cctv-dark-popup"
|
className="cctv-dark-popup"
|
||||||
>
|
>
|
||||||
<div
|
<div className="p-2.5" style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}>
|
||||||
className="p-2.5"
|
|
||||||
style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<span className="text-sm">🚁</span>
|
<span className="text-sm">🚁</span>
|
||||||
<div className="text-[11px] font-bold text-fg">{mapPopup.shipName}</div>
|
<div className="text-[11px] font-bold text-fg">{mapPopup.shipName}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-fg-disabled mb-0.5">{mapPopup.droneModel}</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">
|
<div className="text-[8px] text-fg-disabled font-mono mb-2">{mapPopup.ip} · {mapPopup.region}</div>
|
||||||
{mapPopup.ip} · {mapPopup.region}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<span
|
<span className="text-[8px] font-bold px-1.5 py-px rounded-full"
|
||||||
className="text-[8px] font-bold px-1.5 py-px rounded-full"
|
style={{ background: statusInfo(mapPopup.status).bg, color: statusInfo(mapPopup.status).color }}
|
||||||
style={{
|
>● {statusInfo(mapPopup.status).label}</span>
|
||||||
background: statusInfo(mapPopup.status).bg,
|
|
||||||
color: statusInfo(mapPopup.status).color,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
● {statusInfo(mapPopup.status).label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{mapPopup.status === 'idle' || mapPopup.status === 'error' ? (
|
{mapPopup.status === 'idle' || mapPopup.status === 'error' ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => { handleStartStream(mapPopup.id); handleSelectStream(mapPopup); setMapPopup(null) }}
|
||||||
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"
|
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
|
||||||
style={{
|
style={{ background: 'rgba(34,197,94,.15)', borderColor: 'rgba(34,197,94,.3)', color: '#4ade80' }}
|
||||||
background: 'rgba(34,197,94,.15)',
|
>▶ 스트림 시작</button>
|
||||||
borderColor: 'rgba(34,197,94,.3)',
|
|
||||||
color: '#4ade80',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
▶ 스트림 시작
|
|
||||||
</button>
|
|
||||||
) : mapPopup.status === 'streaming' ? (
|
) : mapPopup.status === 'streaming' ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => { handleSelectStream(mapPopup); setMapPopup(null) }}
|
||||||
handleSelectStream(mapPopup);
|
|
||||||
setMapPopup(null);
|
|
||||||
}}
|
|
||||||
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
|
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors"
|
||||||
style={{
|
style={{ background: 'rgba(6,182,212,.15)', borderColor: 'rgba(6,182,212,.3)', color: '#67e8f9' }}
|
||||||
background: 'rgba(6,182,212,.15)',
|
>▶ 영상 보기</button>
|
||||||
borderColor: 'rgba(6,182,212,.3)',
|
|
||||||
color: '#67e8f9',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
▶ 영상 보기
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[9px] text-color-accent font-korean text-center animate-pulse">
|
<div className="text-[9px] text-color-accent font-korean text-center animate-pulse">연결 중...</div>
|
||||||
연결 중...
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
)}
|
)}
|
||||||
</Map>
|
</Map>
|
||||||
{/* 지도 위 안내 배지 */}
|
{/* 지도 위 안내 배지 */}
|
||||||
<div
|
<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"
|
||||||
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)' }}>
|
||||||
style={{
|
|
||||||
background: 'rgba(0,0,0,.7)',
|
|
||||||
color: 'rgba(255,255,255,.7)',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🚁 드론 위치를 클릭하여 스트림을 시작하세요 ({streams.length}대)
|
🚁 드론 위치를 클릭하여 스트림을 시작하세요 ({streams.length}대)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
|
||||||
className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
|
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
|
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
|
||||||
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
|
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
{Array.from({ length: totalCells }).map((_, i) => {
|
{Array.from({ length: totalCells }).map((_, i) => {
|
||||||
const stream = activeCells[i];
|
const stream = activeCells[i]
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-bg-base" style={{ border: '1px solid var(--stroke-light)' }}>
|
||||||
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 ? (
|
{stream && stream.status === 'streaming' && stream.hlsUrl ? (
|
||||||
<CCTVPlayer
|
<CCTVPlayer
|
||||||
ref={(el) => {
|
ref={el => { playerRefs.current[i] = el }}
|
||||||
playerRefs.current[i] = el;
|
|
||||||
}}
|
|
||||||
cameraNm={stream.shipName}
|
cameraNm={stream.shipName}
|
||||||
streamUrl={stream.hlsUrl}
|
streamUrl={stream.hlsUrl}
|
||||||
sttsCd="LIVE"
|
sttsCd="LIVE"
|
||||||
@ -684,55 +387,36 @@ export function RealtimeDrone() {
|
|||||||
) : stream && stream.status === 'starting' ? (
|
) : stream && stream.status === 'starting' ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-2">
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
<div className="text-lg opacity-40 animate-pulse">🚁</div>
|
<div className="text-lg opacity-40 animate-pulse">🚁</div>
|
||||||
<div className="text-[10px] text-color-accent font-korean animate-pulse">
|
<div className="text-[10px] text-color-accent font-korean animate-pulse">RTSP 스트림 연결 중...</div>
|
||||||
RTSP 스트림 연결 중...
|
|
||||||
</div>
|
|
||||||
<div className="text-[8px] text-fg-disabled font-mono">{stream.ip}:554</div>
|
<div className="text-[8px] text-fg-disabled font-mono">{stream.ip}:554</div>
|
||||||
</div>
|
</div>
|
||||||
) : stream && stream.status === 'error' ? (
|
) : stream && stream.status === 'error' ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-2">
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
<div className="text-lg opacity-30">⚠️</div>
|
<div className="text-lg opacity-30">⚠️</div>
|
||||||
<div className="text-[10px] text-color-danger font-korean">연결 실패</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">
|
<div className="text-[8px] text-fg-disabled font-korean max-w-[200px] text-center">{stream.error}</div>
|
||||||
{stream.error}
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStartStream(stream.id)}
|
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"
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[10px] text-fg-disabled font-korean opacity-40">
|
<div className="text-[10px] text-fg-disabled font-korean opacity-40">
|
||||||
{streams.length > 0
|
{streams.length > 0 ? '스트림을 시작하고 선택하세요' : '드론 스트림을 선택하세요'}
|
||||||
? '스트림을 시작하고 선택하세요'
|
|
||||||
: '드론 스트림을 선택하세요'}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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="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">
|
<div className="text-[10px] text-fg-disabled font-korean">선택: <b className="text-fg">{selectedStream?.shipName ?? '–'}</b></div>
|
||||||
선택: <b className="text-fg">{selectedStream?.shipName ?? '–'}</b>
|
<div className="text-[10px] text-fg-disabled font-korean">IP: <span className="text-color-accent font-mono text-[9px]">{selectedStream?.ip ?? '–'}</span></div>
|
||||||
</div>
|
<div className="text-[10px] text-fg-disabled font-korean">지역: <span className="text-fg-sub">{selectedStream?.region ?? '–'}</span></div>
|
||||||
<div className="text-[10px] text-fg-disabled font-korean">
|
<div className="ml-auto text-[9px] text-fg-disabled font-korean">RTSP → HLS · ViewLink 연동</div>
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -743,10 +427,7 @@ export function RealtimeDrone() {
|
|||||||
📋 스트림 정보
|
📋 스트림 정보
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="flex-1 overflow-y-auto px-3 py-2.5" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
|
||||||
className="flex-1 overflow-y-auto px-3 py-2.5"
|
|
||||||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
|
|
||||||
>
|
|
||||||
{selectedStream ? (
|
{selectedStream ? (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{[
|
{[
|
||||||
@ -759,10 +440,7 @@ export function RealtimeDrone() {
|
|||||||
['프로토콜', 'RTSP → HLS'],
|
['프로토콜', 'RTSP → HLS'],
|
||||||
['상태', statusInfo(selectedStream.status).label],
|
['상태', statusInfo(selectedStream.status).label],
|
||||||
].map(([k, v], i) => (
|
].map(([k, v], i) => (
|
||||||
<div
|
<div key={i} className="flex justify-between px-2 py-1 bg-bg-base rounded text-[9px]">
|
||||||
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="text-fg-disabled font-korean">{k}</span>
|
||||||
<span className="font-mono text-fg">{v}</span>
|
<span className="font-mono text-fg">{v}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -770,9 +448,7 @@ export function RealtimeDrone() {
|
|||||||
{selectedStream.hlsUrl && (
|
{selectedStream.hlsUrl && (
|
||||||
<div className="px-2 py-1 bg-bg-base rounded text-[8px]">
|
<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="text-fg-disabled font-korean mb-0.5">HLS URL</div>
|
||||||
<div className="font-mono text-color-accent break-all">
|
<div className="font-mono text-color-accent break-all">{selectedStream.hlsUrl}</div>
|
||||||
{selectedStream.hlsUrl}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -784,23 +460,13 @@ export function RealtimeDrone() {
|
|||||||
<div className="mt-3 pt-2.5 border-t border-stroke">
|
<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="text-[10px] font-bold text-fg-sub font-korean mb-2">🔗 연동 시스템</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div
|
<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)' }}>
|
||||||
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] text-fg-sub font-korean">ViewLink 3.5</span>
|
||||||
<span className="text-[9px] font-bold" style={{ color: 'var(--color-accent)' }}>
|
<span className="text-[9px] font-bold" style={{ color: 'var(--color-accent)' }}>● RTSP</span>
|
||||||
● RTSP
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<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)' }}>
|
||||||
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] text-fg-sub font-korean">FFmpeg 변환</span>
|
||||||
<span className="text-[9px] font-bold" style={{ color: 'var(--blue, #3b82f6)' }}>
|
<span className="text-[9px] font-bold" style={{ color: 'var(--blue, #3b82f6)' }}>RTSP→HLS</span>
|
||||||
RTSP→HLS
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -811,21 +477,9 @@ export function RealtimeDrone() {
|
|||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
{[
|
{[
|
||||||
{ label: '전체', value: streams.length, color: 'text-fg' },
|
{ label: '전체', value: streams.length, color: 'text-fg' },
|
||||||
{
|
{ label: '송출중', value: streams.filter(s => s.status === 'streaming').length, color: 'text-color-success' },
|
||||||
label: '송출중',
|
{ label: '연결중', value: streams.filter(s => s.status === 'starting').length, color: 'text-color-accent' },
|
||||||
value: streams.filter((s) => s.status === 'streaming').length,
|
{ label: '오류', value: streams.filter(s => s.status === 'error').length, color: 'text-color-danger' },
|
||||||
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) => (
|
].map((item, i) => (
|
||||||
<div key={i} className="px-2 py-1.5 bg-bg-base rounded text-center">
|
<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>
|
<div className="text-[8px] text-fg-disabled font-korean">{item.label}</div>
|
||||||
@ -837,5 +491,5 @@ export function RealtimeDrone() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,61 +1,45 @@
|
|||||||
import { useMemo, useCallback, useEffect, useRef } from 'react';
|
import { useMemo, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre';
|
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import { ScatterplotLayer } from '@deck.gl/layers';
|
import { ScatterplotLayer } from '@deck.gl/layers'
|
||||||
import type { StyleSpecification } from 'maplibre-gl';
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
|
||||||
import type { AssetOrgCompat } from '../services/assetsApi';
|
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
|
||||||
import { typeColor } from './assetTypes';
|
import { useMapStore } from '@common/store/mapStore'
|
||||||
import { hexToRgba } from '@common/components/map/mapUtils';
|
import type { AssetOrgCompat } from '../services/assetsApi'
|
||||||
|
import { typeColor } from './assetTypes'
|
||||||
const BASE_STYLE: StyleSpecification = {
|
import { hexToRgba } from '@common/components/map/mapUtils'
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
'carto-dark': {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: [
|
|
||||||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
||||||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
||||||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
||||||
],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution:
|
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── DeckGLOverlay ──────────────────────────────────────
|
// ── DeckGLOverlay ──────────────────────────────────────
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
||||||
overlay.setProps({ layers });
|
overlay.setProps({ layers })
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FlyTo Controller ────────────────────────────────────
|
// ── FlyTo Controller ────────────────────────────────────
|
||||||
function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) {
|
function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) {
|
||||||
const { current: map } = useMap();
|
const { current: map } = useMap()
|
||||||
const prevIdRef = useRef<number | undefined>(undefined);
|
const prevIdRef = useRef<number | undefined>(undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return
|
||||||
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedOrg.id) {
|
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;
|
prevIdRef.current = selectedOrg.id
|
||||||
}, [map, selectedOrg]);
|
}, [map, selectedOrg])
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssetMapProps {
|
interface AssetMapProps {
|
||||||
organizations: AssetOrgCompat[];
|
organizations: AssetOrgCompat[]
|
||||||
selectedOrg: AssetOrgCompat;
|
selectedOrg: AssetOrgCompat
|
||||||
onSelectOrg: (o: AssetOrgCompat) => void;
|
onSelectOrg: (o: AssetOrgCompat) => void
|
||||||
regionFilter: string;
|
regionFilter: string
|
||||||
onRegionFilterChange: (v: string) => void;
|
onRegionFilterChange: (v: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssetMap({
|
function AssetMap({
|
||||||
@ -65,12 +49,15 @@ function AssetMap({
|
|||||||
regionFilter,
|
regionFilter,
|
||||||
onRegionFilterChange,
|
onRegionFilterChange,
|
||||||
}: AssetMapProps) {
|
}: AssetMapProps) {
|
||||||
|
const currentMapStyle = useBaseMapStyle()
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(org: AssetOrgCompat) => {
|
(org: AssetOrgCompat) => {
|
||||||
onSelectOrg(org);
|
onSelectOrg(org)
|
||||||
},
|
},
|
||||||
[onSelectOrg],
|
[onSelectOrg],
|
||||||
);
|
)
|
||||||
|
|
||||||
const markerLayer = useMemo(() => {
|
const markerLayer = useMemo(() => {
|
||||||
return new ScatterplotLayer({
|
return new ScatterplotLayer({
|
||||||
@ -78,19 +65,19 @@ function AssetMap({
|
|||||||
data: orgs,
|
data: orgs,
|
||||||
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
|
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
|
||||||
getRadius: (d: AssetOrgCompat) => {
|
getRadius: (d: AssetOrgCompat) => {
|
||||||
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7;
|
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7
|
||||||
const isSelected = selectedOrg.id === d.id;
|
const isSelected = selectedOrg.id === d.id
|
||||||
return isSelected ? baseRadius + 4 : baseRadius;
|
return isSelected ? baseRadius + 4 : baseRadius
|
||||||
},
|
},
|
||||||
getFillColor: (d: AssetOrgCompat) => {
|
getFillColor: (d: AssetOrgCompat) => {
|
||||||
const tc = typeColor(d.type);
|
const tc = typeColor(d.type)
|
||||||
const isSelected = selectedOrg.id === d.id;
|
const isSelected = selectedOrg.id === d.id
|
||||||
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178);
|
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178)
|
||||||
},
|
},
|
||||||
getLineColor: (d: AssetOrgCompat) => {
|
getLineColor: (d: AssetOrgCompat) => {
|
||||||
const tc = typeColor(d.type);
|
const tc = typeColor(d.type)
|
||||||
const isSelected = selectedOrg.id === d.id;
|
const isSelected = selectedOrg.id === d.id
|
||||||
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200);
|
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200)
|
||||||
},
|
},
|
||||||
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
|
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
|
||||||
stroked: true,
|
stroked: true,
|
||||||
@ -99,7 +86,7 @@ function AssetMap({
|
|||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: { object?: AssetOrgCompat }) => {
|
onClick: (info: { object?: AssetOrgCompat }) => {
|
||||||
if (info.object) handleClick(info.object);
|
if (info.object) handleClick(info.object)
|
||||||
},
|
},
|
||||||
updateTriggers: {
|
updateTriggers: {
|
||||||
getRadius: [selectedOrg.id],
|
getRadius: [selectedOrg.id],
|
||||||
@ -107,17 +94,18 @@ function AssetMap({
|
|||||||
getLineColor: [selectedOrg.id],
|
getLineColor: [selectedOrg.id],
|
||||||
getLineWidth: [selectedOrg.id],
|
getLineWidth: [selectedOrg.id],
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}, [orgs, selectedOrg, handleClick]);
|
}, [orgs, selectedOrg, handleClick])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative">
|
<div className="w-full h-full relative">
|
||||||
<Map
|
<Map
|
||||||
initialViewState={{ longitude: 127.8, latitude: 35.9, zoom: 7 }}
|
initialViewState={{ longitude: 127.8, latitude: 35.9, zoom: 7 }}
|
||||||
mapStyle={BASE_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
<DeckGLOverlay layers={[markerLayer]} />
|
<DeckGLOverlay layers={[markerLayer]} />
|
||||||
<FlyToController selectedOrg={selectedOrg} />
|
<FlyToController selectedOrg={selectedOrg} />
|
||||||
</Map>
|
</Map>
|
||||||
@ -131,7 +119,7 @@ function AssetMap({
|
|||||||
{ value: '중부', label: '중부청' },
|
{ value: '중부', label: '중부청' },
|
||||||
{ value: '동해', label: '동해청' },
|
{ value: '동해', label: '동해청' },
|
||||||
{ value: '제주', label: '제주청' },
|
{ value: '제주', label: '제주청' },
|
||||||
].map((r) => (
|
].map(r => (
|
||||||
<button
|
<button
|
||||||
key={r.value}
|
key={r.value}
|
||||||
onClick={() => onRegionFilterChange(r.value)}
|
onClick={() => onRegionFilterChange(r.value)}
|
||||||
@ -163,16 +151,13 @@ function AssetMap({
|
|||||||
{ color: '#6b7280', label: '기타' },
|
{ color: '#6b7280', label: '기타' },
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div key={i} className="flex items-center gap-1.5 mb-0.5 last:mb-0">
|
<div key={i} className="flex items-center gap-1.5 mb-0.5 last:mb-0">
|
||||||
<span
|
<span className="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0" style={{ background: item.color }} />
|
||||||
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>
|
<span className="text-[10px] text-fg-sub font-korean">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
<span
|
<span
|
||||||
className="text-[11px] font-bold font-korean px-2 py-0.5 rounded"
|
className="text-[11px] font-bold font-korean px-2 py-0.5 rounded"
|
||||||
style={{
|
style={{ background: 'rgba(6,182,212,0.12)', color: '#06b6d4', border: '1px solid rgba(6,182,212,0.25)' }}
|
||||||
background: 'rgba(6,182,212,0.12)',
|
|
||||||
color: '#06b6d4',
|
|
||||||
border: '1px solid rgba(6,182,212,0.25)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 지도 + 캡처 오버레이 */}
|
{/* 지도 + 캡처 오버레이 */}
|
||||||
<div
|
<div className="relative rounded-lg border border-stroke overflow-hidden" style={{ aspectRatio: '16/9' }}>
|
||||||
className="relative rounded-lg border border-stroke overflow-hidden"
|
|
||||||
style={{ height: '300px' }}
|
|
||||||
>
|
|
||||||
<MapView
|
<MapView
|
||||||
center={mapData.center}
|
center={mapData.center}
|
||||||
zoom={mapData.zoom}
|
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="absolute top-2 right-2 z-10" style={{ width: '180px' }}>
|
||||||
<div
|
<div
|
||||||
className="rounded-lg overflow-hidden"
|
className="rounded-lg overflow-hidden"
|
||||||
style={{
|
style={{ border: '1px solid rgba(6,182,212,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)' }}
|
||||||
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" />
|
<img src={captured} alt={`${label} 캡처`} className="w-full block" />
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between px-2 py-1"
|
className="flex items-center justify-between px-2 py-1"
|
||||||
style={{
|
style={{ background: 'rgba(15,23,42,0.85)', borderTop: '1px solid rgba(6,182,212,0.3)' }}
|
||||||
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' }}>
|
<span className="text-[9px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
|
||||||
📷 캡처 완료
|
📷 캡처 완료
|
||||||
|
|||||||
@ -1,88 +1,66 @@
|
|||||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre';
|
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers';
|
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'
|
||||||
import type { StyleSpecification } from 'maplibre-gl';
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
|
||||||
import type { ScatSegment } from './scatTypes';
|
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
|
||||||
import type { ApiZoneItem } from '../services/scatApi';
|
import { useMapStore } from '@common/store/mapStore'
|
||||||
import { esiColor } from './scatConstants';
|
import type { ScatSegment } from './scatTypes'
|
||||||
import { hexToRgba } from '@common/components/map/mapUtils';
|
import type { ApiZoneItem } from '../services/scatApi'
|
||||||
|
import { esiColor } from './scatConstants'
|
||||||
const BASE_STYLE: StyleSpecification = {
|
import { hexToRgba } from '@common/components/map/mapUtils'
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
'carto-dark': {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: [
|
|
||||||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
||||||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
||||||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
||||||
],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution:
|
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ScatMapProps {
|
interface ScatMapProps {
|
||||||
segments: ScatSegment[];
|
segments: ScatSegment[]
|
||||||
zones: ApiZoneItem[];
|
zones: ApiZoneItem[]
|
||||||
selectedSeg: ScatSegment;
|
selectedSeg: ScatSegment
|
||||||
jurisdictionFilter: string;
|
jurisdictionFilter: string
|
||||||
onSelectSeg: (s: ScatSegment) => void;
|
onSelectSeg: (s: ScatSegment) => void
|
||||||
onOpenPopup: (idx: number) => void;
|
onOpenPopup: (idx: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DeckGLOverlay ──────────────────────────────────────
|
// ── DeckGLOverlay ──────────────────────────────────────
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
||||||
overlay.setProps({ layers });
|
overlay.setProps({ layers })
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
|
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
|
||||||
function FlyToController({
|
function FlyToController({ selectedSeg, zones }: { selectedSeg: ScatSegment; zones: ApiZoneItem[] }) {
|
||||||
selectedSeg,
|
const { current: map } = useMap()
|
||||||
zones,
|
const prevIdRef = useRef<number | undefined>(undefined)
|
||||||
}: {
|
const prevZonesLenRef = useRef<number>(0)
|
||||||
selectedSeg: ScatSegment;
|
|
||||||
zones: ApiZoneItem[];
|
|
||||||
}) {
|
|
||||||
const { current: map } = useMap();
|
|
||||||
const prevIdRef = useRef<number | undefined>(undefined);
|
|
||||||
const prevZonesLenRef = useRef<number>(0);
|
|
||||||
|
|
||||||
// 선택 구간 변경 시
|
// 선택 구간 변경 시
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return
|
||||||
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) {
|
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;
|
prevIdRef.current = selectedSeg.id
|
||||||
}, [map, selectedSeg]);
|
}, [map, selectedSeg])
|
||||||
|
|
||||||
// 관할해경(zones) 변경 시 지도 중심 이동
|
// 관할해경(zones) 변경 시 지도 중심 이동
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || zones.length === 0) return;
|
if (!map || zones.length === 0) return
|
||||||
if (prevZonesLenRef.current === zones.length) return;
|
if (prevZonesLenRef.current === zones.length) return
|
||||||
prevZonesLenRef.current = zones.length;
|
prevZonesLenRef.current = zones.length
|
||||||
const validZones = zones.filter((z) => z.latCenter && z.lngCenter);
|
const validZones = zones.filter(z => z.latCenter && z.lngCenter)
|
||||||
if (validZones.length === 0) return;
|
if (validZones.length === 0) return
|
||||||
const avgLat = validZones.reduce((a, z) => a + z.latCenter, 0) / validZones.length;
|
const avgLat = validZones.reduce((a, z) => a + z.latCenter, 0) / validZones.length
|
||||||
const avgLng = validZones.reduce((a, z) => a + z.lngCenter, 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.flyTo({ center: [avgLng, avgLat], zoom: 9, duration: 800 })
|
||||||
}, [map, zones]);
|
}, [map, zones])
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 줌 기반 스케일 계산 ─────────────────────────────────
|
// ── 줌 기반 스케일 계산 ─────────────────────────────────
|
||||||
function getZoomScale(zoom: number) {
|
function getZoomScale(zoom: number) {
|
||||||
const zScale = Math.max(0, zoom - 9) / 5;
|
const zScale = Math.max(0, zoom - 9) / 5
|
||||||
return {
|
return {
|
||||||
polyWidth: 1 + zScale * 4,
|
polyWidth: 1 + zScale * 4,
|
||||||
selPolyWidth: 2 + zScale * 5,
|
selPolyWidth: 2 + zScale * 5,
|
||||||
@ -90,7 +68,7 @@ function getZoomScale(zoom: number) {
|
|||||||
halfLenScale: 0.15 + zScale * 0.85,
|
halfLenScale: 0.15 + zScale * 0.85,
|
||||||
markerRadius: Math.round(6 + zScale * 16),
|
markerRadius: Math.round(6 + zScale * 16),
|
||||||
showStatusMarker: zoom >= 11,
|
showStatusMarker: zoom >= 11,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
|
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
|
||||||
@ -100,50 +78,46 @@ function buildSegCoords(
|
|||||||
halfLenScale: number,
|
halfLenScale: number,
|
||||||
segments: ScatSegment[],
|
segments: ScatSegment[],
|
||||||
): [number, number][] {
|
): [number, number][] {
|
||||||
const idx = segments.indexOf(seg);
|
const idx = segments.indexOf(seg)
|
||||||
const prev = idx > 0 ? segments[idx - 1] : seg;
|
const prev = idx > 0 ? segments[idx - 1] : seg
|
||||||
const next = idx < segments.length - 1 ? segments[idx + 1] : seg;
|
const next = idx < segments.length - 1 ? segments[idx + 1] : seg
|
||||||
const dlat = next.lat - prev.lat;
|
const dlat = next.lat - prev.lat
|
||||||
const dlng = next.lng - prev.lng;
|
const dlng = next.lng - prev.lng
|
||||||
const dist = Math.sqrt(dlat * dlat + dlng * dlng);
|
const dist = Math.sqrt(dlat * dlat + dlng * dlng)
|
||||||
const nDlat = dist > 0 ? dlat / dist : 0;
|
const nDlat = dist > 0 ? dlat / dist : 0
|
||||||
const nDlng = dist > 0 ? dlng / dist : 1;
|
const nDlng = dist > 0 ? dlng / dist : 1
|
||||||
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale;
|
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale
|
||||||
return [
|
return [
|
||||||
[seg.lng - nDlng * halfLen, seg.lat - nDlat * halfLen],
|
[seg.lng - nDlng * halfLen, seg.lat - nDlat * halfLen],
|
||||||
[seg.lng, seg.lat],
|
[seg.lng, seg.lat],
|
||||||
[seg.lng + nDlng * halfLen, seg.lat + nDlat * halfLen],
|
[seg.lng + nDlng * halfLen, seg.lat + nDlat * halfLen],
|
||||||
];
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 툴팁 상태 ───────────────────────────────────────────
|
// ── 툴팁 상태 ───────────────────────────────────────────
|
||||||
interface TooltipState {
|
interface TooltipState {
|
||||||
x: number;
|
x: number
|
||||||
y: number;
|
y: number
|
||||||
seg: ScatSegment;
|
seg: ScatSegment
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ScatMap ─────────────────────────────────────────────
|
// ── ScatMap ─────────────────────────────────────────────
|
||||||
function ScatMap({
|
function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg, onOpenPopup }: ScatMapProps) {
|
||||||
segments,
|
const currentMapStyle = useBaseMapStyle()
|
||||||
zones,
|
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||||
selectedSeg,
|
|
||||||
jurisdictionFilter,
|
const [zoom, setZoom] = useState(10)
|
||||||
onSelectSeg,
|
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
||||||
onOpenPopup,
|
|
||||||
}: ScatMapProps) {
|
|
||||||
const [zoom, setZoom] = useState(10);
|
|
||||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(seg: ScatSegment) => {
|
(seg: ScatSegment) => {
|
||||||
onSelectSeg(seg);
|
onSelectSeg(seg)
|
||||||
onOpenPopup(seg.id);
|
onOpenPopup(seg.id)
|
||||||
},
|
},
|
||||||
[onSelectSeg, onOpenPopup],
|
[onSelectSeg, onOpenPopup],
|
||||||
);
|
)
|
||||||
|
|
||||||
const zs = useMemo(() => getZoomScale(zoom), [zoom]);
|
const zs = useMemo(() => getZoomScale(zoom), [zoom])
|
||||||
|
|
||||||
// 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정
|
// 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정
|
||||||
// const coastlineLayer = useMemo(
|
// const coastlineLayer = useMemo(
|
||||||
@ -179,7 +153,7 @@ function ScatMap({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
|
[selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
|
||||||
);
|
)
|
||||||
|
|
||||||
// ESI 색상 세그먼트 폴리라인
|
// ESI 색상 세그먼트 폴리라인
|
||||||
const segPathLayer = useMemo(
|
const segPathLayer = useMemo(
|
||||||
@ -189,9 +163,9 @@ function ScatMap({
|
|||||||
data: segments,
|
data: segments,
|
||||||
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
|
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
|
||||||
getColor: (d: ScatSegment) => {
|
getColor: (d: ScatSegment) => {
|
||||||
const isSelected = selectedSeg.id === d.id;
|
const isSelected = selectedSeg.id === d.id
|
||||||
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum);
|
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum)
|
||||||
return hexToRgba(hexCol, isSelected ? 242 : 178);
|
return hexToRgba(hexCol, isSelected ? 242 : 178)
|
||||||
},
|
},
|
||||||
getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth),
|
getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth),
|
||||||
capRounded: true,
|
capRounded: true,
|
||||||
@ -200,13 +174,13 @@ function ScatMap({
|
|||||||
pickable: true,
|
pickable: true,
|
||||||
onHover: (info: { object?: ScatSegment; x: number; y: number }) => {
|
onHover: (info: { object?: ScatSegment; x: number; y: number }) => {
|
||||||
if (info.object) {
|
if (info.object) {
|
||||||
setTooltip({ x: info.x, y: info.y, seg: info.object });
|
setTooltip({ x: info.x, y: info.y, seg: info.object })
|
||||||
} else {
|
} else {
|
||||||
setTooltip(null);
|
setTooltip(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClick: (info: { object?: ScatSegment }) => {
|
onClick: (info: { object?: ScatSegment }) => {
|
||||||
if (info.object) handleClick(info.object);
|
if (info.object) handleClick(info.object)
|
||||||
},
|
},
|
||||||
updateTriggers: {
|
updateTriggers: {
|
||||||
getColor: [selectedSeg.id],
|
getColor: [selectedSeg.id],
|
||||||
@ -215,25 +189,25 @@ function ScatMap({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[segments, selectedSeg, zs, handleClick],
|
[segments, selectedSeg, zs, handleClick],
|
||||||
);
|
)
|
||||||
|
|
||||||
// 조사 상태 마커 (줌 >= 11 시 표시)
|
// 조사 상태 마커 (줌 >= 11 시 표시)
|
||||||
const markerLayer = useMemo(() => {
|
const markerLayer = useMemo(() => {
|
||||||
if (!zs.showStatusMarker) return null;
|
if (!zs.showStatusMarker) return null
|
||||||
return new ScatterplotLayer({
|
return new ScatterplotLayer({
|
||||||
id: 'scat-status-markers',
|
id: 'scat-status-markers',
|
||||||
data: segments,
|
data: segments,
|
||||||
getPosition: (d: ScatSegment) => [d.lng, d.lat],
|
getPosition: (d: ScatSegment) => [d.lng, d.lat],
|
||||||
getRadius: zs.markerRadius,
|
getRadius: zs.markerRadius,
|
||||||
getFillColor: (d: ScatSegment) => {
|
getFillColor: (d: ScatSegment) => {
|
||||||
if (d.status === '완료') return [34, 197, 94, 51];
|
if (d.status === '완료') return [34, 197, 94, 51]
|
||||||
if (d.status === '진행중') return [234, 179, 8, 51];
|
if (d.status === '진행중') return [234, 179, 8, 51]
|
||||||
return [100, 116, 139, 51];
|
return [100, 116, 139, 51]
|
||||||
},
|
},
|
||||||
getLineColor: (d: ScatSegment) => {
|
getLineColor: (d: ScatSegment) => {
|
||||||
if (d.status === '완료') return [34, 197, 94, 200];
|
if (d.status === '완료') return [34, 197, 94, 200]
|
||||||
if (d.status === '진행중') return [234, 179, 8, 200];
|
if (d.status === '진행중') return [234, 179, 8, 200]
|
||||||
return [100, 116, 139, 200];
|
return [100, 116, 139, 200]
|
||||||
},
|
},
|
||||||
getLineWidth: 1,
|
getLineWidth: 1,
|
||||||
stroked: true,
|
stroked: true,
|
||||||
@ -242,32 +216,32 @@ function ScatMap({
|
|||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: { object?: ScatSegment }) => {
|
onClick: (info: { object?: ScatSegment }) => {
|
||||||
if (info.object) handleClick(info.object);
|
if (info.object) handleClick(info.object)
|
||||||
},
|
},
|
||||||
updateTriggers: {
|
updateTriggers: {
|
||||||
getRadius: [zs.markerRadius],
|
getRadius: [zs.markerRadius],
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick]);
|
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick])
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const deckLayers: any[] = useMemo(() => {
|
const deckLayers: any[] = useMemo(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const layers: any[] = [glowLayer, segPathLayer];
|
const layers: any[] = [glowLayer, segPathLayer]
|
||||||
if (markerLayer) layers.push(markerLayer);
|
if (markerLayer) layers.push(markerLayer)
|
||||||
return layers;
|
return layers
|
||||||
}, [glowLayer, segPathLayer, markerLayer]);
|
}, [glowLayer, segPathLayer, markerLayer])
|
||||||
|
|
||||||
const doneCount = segments.filter((s) => s.status === '완료').length;
|
const doneCount = segments.filter(s => s.status === '완료').length
|
||||||
const progCount = 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 totalLen = segments.reduce((a, s) => a + s.lengthM, 0)
|
||||||
const doneLen = segments.filter((s) => s.status === '완료').reduce((a, s) => a + s.lengthM, 0);
|
const doneLen = segments.filter(s => s.status === '완료').reduce((a, s) => a + s.lengthM, 0)
|
||||||
const highSens = segments
|
const highSens = segments
|
||||||
.filter((s) => s.sensitivity === '최상' || s.sensitivity === '상')
|
.filter(s => s.sensitivity === '최상' || s.sensitivity === '상')
|
||||||
.reduce((a, s) => a + s.lengthM, 0);
|
.reduce((a, s) => a + s.lengthM, 0)
|
||||||
const donePct = Math.round((doneCount / segments.length) * 100);
|
const donePct = Math.round((doneCount / segments.length) * 100)
|
||||||
const progPct = Math.round((progCount / segments.length) * 100);
|
const progPct = Math.round((progCount / segments.length) * 100)
|
||||||
const notPct = 100 - donePct - progPct;
|
const notPct = 100 - donePct - progPct
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
@ -280,11 +254,12 @@ function ScatMap({
|
|||||||
}
|
}
|
||||||
return { longitude: 126.55, latitude: 33.38, zoom: 10 };
|
return { longitude: 126.55, latitude: 33.38, zoom: 10 };
|
||||||
})()}
|
})()}
|
||||||
mapStyle={BASE_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
onZoom={(e) => setZoom(e.viewState.zoom)}
|
onZoom={e => setZoom(e.viewState.zoom)}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
<FlyToController selectedSeg={selectedSeg} zones={zones} />
|
<FlyToController selectedSeg={selectedSeg} zones={zones} />
|
||||||
</Map>
|
</Map>
|
||||||
@ -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">
|
<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 */}
|
{/* 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="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 className="text-[10px] font-bold uppercase tracking-wider text-fg-disabled mb-2.5">ESI 민감도 분류 범례</div>
|
||||||
ESI 민감도 분류 범례
|
|
||||||
</div>
|
|
||||||
{[
|
{[
|
||||||
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
|
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
|
||||||
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
|
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
|
||||||
@ -349,10 +322,7 @@ function ScatMap({
|
|||||||
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
|
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 py-1 text-[11px]">
|
<div key={i} className="flex items-center gap-2 py-1 text-[11px]">
|
||||||
<span
|
<span className="w-3.5 h-1.5 rounded-sm flex-shrink-0" style={{ background: item.color }} />
|
||||||
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="text-fg-sub font-korean">{item.label}</span>
|
||||||
<span className="ml-auto font-mono text-[10px] text-fg">{item.esi}</span>
|
<span className="ml-auto font-mono text-[10px] text-fg">{item.esi}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -361,22 +331,11 @@ function ScatMap({
|
|||||||
|
|
||||||
{/* Progress */}
|
{/* 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="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 className="text-[10px] font-bold uppercase tracking-wider text-fg-disabled mb-2.5">조사 진행률</div>
|
||||||
조사 진행률
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
|
<div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
|
||||||
<div
|
<div className="h-full transition-all duration-500" style={{ width: `${donePct}%`, background: 'var(--color-success)' }} />
|
||||||
className="h-full transition-all duration-500"
|
<div className="h-full transition-all duration-500" style={{ width: `${progPct}%`, background: 'var(--color-warning)' }} />
|
||||||
style={{ width: `${donePct}%`, background: 'var(--color-success)' }}
|
<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: `${progPct}%`, background: 'var(--color-warning)' }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="h-full transition-all duration-500"
|
|
||||||
style={{ width: `${notPct}%`, background: 'var(--stroke-default)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-1">
|
<div className="flex justify-between mt-1">
|
||||||
<span className="text-[9px] font-mono text-color-success">완료 {donePct}%</span>
|
<span className="text-[9px] font-mono text-color-success">완료 {donePct}%</span>
|
||||||
@ -388,21 +347,11 @@ function ScatMap({
|
|||||||
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
|
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
|
||||||
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--color-success)'],
|
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--color-success)'],
|
||||||
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--color-danger)'],
|
['고민감 구간', `${(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) => (
|
].map(([label, val, color], i) => (
|
||||||
<div
|
<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]">
|
||||||
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="text-fg-sub font-korean">{label}</span>
|
||||||
<span
|
<span className="font-mono font-medium text-[11px]" style={{ color: color || undefined }}>
|
||||||
className="font-mono font-medium text-[11px]"
|
|
||||||
style={{ color: color || undefined }}
|
|
||||||
>
|
|
||||||
{val}
|
{val}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -424,7 +373,7 @@ function ScatMap({
|
|||||||
</span>
|
</span>
|
||||||
</div> */}
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ScatMap;
|
export default ScatMap
|
||||||
|
|||||||
@ -1,52 +1,55 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
import { Map, Marker, useControl } from '@vis.gl/react-maplibre';
|
import { Map, Marker, useControl } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import type { Layer } from '@deck.gl/core';
|
import type { Layer } from '@deck.gl/core'
|
||||||
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl';
|
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { WeatherRightPanel } from './WeatherRightPanel';
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
|
||||||
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay';
|
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 { OceanForecastOverlay } from './OceanForecastOverlay'
|
||||||
// import { useOceanCurrentLayers } from './OceanCurrentLayer'
|
// import { useOceanCurrentLayers } from './OceanCurrentLayer'
|
||||||
import { useWaterTemperatureLayers } from './WaterTemperatureLayer';
|
import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
|
||||||
import { WindParticleLayer } from './WindParticleLayer';
|
import { WindParticleLayer } from './WindParticleLayer'
|
||||||
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer';
|
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
|
||||||
import { useWeatherData } from '../hooks/useWeatherData';
|
import { useWeatherData } from '../hooks/useWeatherData'
|
||||||
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||||
import { WeatherMapControls } from './WeatherMapControls';
|
import { WeatherMapControls } from './WeatherMapControls'
|
||||||
import { degreesToCardinal } from '../services/weatherUtils';
|
import { degreesToCardinal } from '../services/weatherUtils'
|
||||||
|
|
||||||
type TimeOffset = '0' | '3' | '6' | '9';
|
type TimeOffset = '0' | '3' | '6' | '9'
|
||||||
|
|
||||||
interface WeatherStation {
|
interface WeatherStation {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
location: { lat: number; lon: number };
|
location: { lat: number; lon: number }
|
||||||
wind: {
|
wind: {
|
||||||
speed: number;
|
speed: number
|
||||||
direction: number;
|
direction: number
|
||||||
speed_1k: number;
|
speed_1k: number
|
||||||
speed_3k: number;
|
speed_3k: number
|
||||||
};
|
}
|
||||||
wave: {
|
wave: {
|
||||||
height: number;
|
height: number
|
||||||
period: number;
|
period: number
|
||||||
};
|
}
|
||||||
temperature: {
|
temperature: {
|
||||||
current: number;
|
current: number
|
||||||
feelsLike: number;
|
feelsLike: number
|
||||||
};
|
}
|
||||||
pressure: number;
|
pressure: number
|
||||||
visibility: number;
|
visibility: number
|
||||||
salinity?: number;
|
salinity?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeatherForecast {
|
interface WeatherForecast {
|
||||||
time: string;
|
time: string
|
||||||
hour: string;
|
hour: string
|
||||||
icon: string;
|
icon: string
|
||||||
temperature: number;
|
temperature: number
|
||||||
windSpeed: number;
|
windSpeed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base weather station locations
|
// Base weather station locations
|
||||||
@ -61,77 +64,50 @@ const BASE_STATIONS = [
|
|||||||
{ id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } },
|
{ id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } },
|
||||||
{ id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } },
|
{ id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } },
|
||||||
{ id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } },
|
{ id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } },
|
||||||
];
|
]
|
||||||
|
|
||||||
// Generate forecast data based on time offset
|
// Generate forecast data based on time offset
|
||||||
const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => {
|
const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => {
|
||||||
const baseHour = parseInt(timeOffset);
|
const baseHour = parseInt(timeOffset)
|
||||||
const forecasts: WeatherForecast[] = [];
|
const forecasts: WeatherForecast[] = []
|
||||||
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️'];
|
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️']
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
const hour = baseHour + i * 3;
|
const hour = baseHour + i * 3
|
||||||
forecasts.push({
|
forecasts.push({
|
||||||
time: `+${hour}시`,
|
time: `+${hour}시`,
|
||||||
hour: `${hour}시`,
|
hour: `${hour}시`,
|
||||||
icon: icons[i % icons.length],
|
icon: icons[i % icons.length],
|
||||||
temperature: Math.floor(Math.random() * 5) + 5,
|
temperature: Math.floor(Math.random() * 5) + 5,
|
||||||
windSpeed: Math.floor(Math.random() * 5) + 6,
|
windSpeed: Math.floor(Math.random() * 5) + 6,
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
return forecasts
|
||||||
}
|
}
|
||||||
return forecasts;
|
|
||||||
};
|
|
||||||
|
|
||||||
// CartoDB Dark Matter 스타일 (기존 WeatherView와 동일)
|
|
||||||
const WEATHER_MAP_STYLE: StyleSpecification = {
|
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
'carto-dark': {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: [
|
|
||||||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
|
||||||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
|
||||||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
|
||||||
],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution:
|
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: 'carto-dark-layer',
|
|
||||||
type: 'raster',
|
|
||||||
source: 'carto-dark',
|
|
||||||
minzoom: 0,
|
|
||||||
maxzoom: 22,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 한국 해역 중심 좌표 (한반도 중앙)
|
// 한국 해역 중심 좌표 (한반도 중앙)
|
||||||
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat]
|
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5] // [lng, lat]
|
||||||
const WEATHER_MAP_ZOOM = 7;
|
const WEATHER_MAP_ZOOM = 7
|
||||||
|
|
||||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
|
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
||||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
||||||
overlay.setProps({ layers });
|
overlay.setProps({ layers })
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역)
|
* WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역)
|
||||||
*/
|
*/
|
||||||
interface WeatherMapInnerProps {
|
interface WeatherMapInnerProps {
|
||||||
weatherStations: WeatherStation[];
|
weatherStations: WeatherStation[]
|
||||||
enabledLayers: Set<string>;
|
enabledLayers: Set<string>
|
||||||
selectedStationId: string | null;
|
selectedStationId: string | null
|
||||||
onStationClick: (station: WeatherStation) => void;
|
onStationClick: (station: WeatherStation) => void
|
||||||
mapCenter: [number, number];
|
mapCenter: [number, number]
|
||||||
mapZoom: number;
|
mapZoom: number
|
||||||
clickedLocation: { lat: number; lon: number } | null;
|
clickedLocation: { lat: number; lon: number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function WeatherMapInner({
|
function WeatherMapInner({
|
||||||
@ -148,8 +124,8 @@ function WeatherMapInner({
|
|||||||
weatherStations,
|
weatherStations,
|
||||||
enabledLayers,
|
enabledLayers,
|
||||||
selectedStationId,
|
selectedStationId,
|
||||||
onStationClick,
|
onStationClick
|
||||||
);
|
)
|
||||||
// const oceanCurrentLayers = useOceanCurrentLayers({
|
// const oceanCurrentLayers = useOceanCurrentLayers({
|
||||||
// visible: enabledLayers.has('oceanCurrent'),
|
// visible: enabledLayers.has('oceanCurrent'),
|
||||||
// opacity: 0.7,
|
// opacity: 0.7,
|
||||||
@ -157,12 +133,12 @@ function WeatherMapInner({
|
|||||||
const waterTempLayers = useWaterTemperatureLayers({
|
const waterTempLayers = useWaterTemperatureLayers({
|
||||||
visible: enabledLayers.has('waterTemperature'),
|
visible: enabledLayers.has('waterTemperature'),
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
});
|
})
|
||||||
|
|
||||||
const deckLayers = useMemo(
|
const deckLayers = useMemo(
|
||||||
() => [...waterTempLayers, ...weatherDeckLayers],
|
() => [...waterTempLayers, ...weatherDeckLayers],
|
||||||
[waterTempLayers, weatherDeckLayers],
|
[waterTempLayers, weatherDeckLayers]
|
||||||
);
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -177,7 +153,9 @@ function WeatherMapInner({
|
|||||||
/> */}
|
/> */}
|
||||||
|
|
||||||
{/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */}
|
{/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */}
|
||||||
<OceanCurrentParticleLayer visible={enabledLayers.has('oceanCurrentParticle')} />
|
<OceanCurrentParticleLayer
|
||||||
|
visible={enabledLayers.has('oceanCurrentParticle')}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
|
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
|
||||||
<WeatherMapOverlay
|
<WeatherMapOverlay
|
||||||
@ -188,11 +166,18 @@ function WeatherMapInner({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 바람 파티클 애니메이션 (Canvas 직접 조작) */}
|
{/* 바람 파티클 애니메이션 (Canvas 직접 조작) */}
|
||||||
<WindParticleLayer visible={enabledLayers.has('windParticle')} stations={weatherStations} />
|
<WindParticleLayer
|
||||||
|
visible={enabledLayers.has('windParticle')}
|
||||||
|
stations={weatherStations}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 클릭 위치 마커 */}
|
{/* 클릭 위치 마커 */}
|
||||||
{clickedLocation && (
|
{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="flex flex-col items-center pointer-events-none">
|
||||||
{/* 펄스 링 */}
|
{/* 펄스 링 */}
|
||||||
<div className="relative flex items-center justify-center">
|
<div className="relative flex items-center justify-center">
|
||||||
@ -212,11 +197,14 @@ function WeatherMapInner({
|
|||||||
{/* 줌 컨트롤 */}
|
{/* 줌 컨트롤 */}
|
||||||
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
|
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeatherView() {
|
export function WeatherView() {
|
||||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS);
|
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
||||||
|
const currentMapStyle = useBaseMapStyle()
|
||||||
|
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||||
|
|
||||||
|
|
||||||
// const {
|
// const {
|
||||||
// selectedForecast,
|
// selectedForecast,
|
||||||
@ -226,55 +214,58 @@ export function WeatherView() {
|
|||||||
// selectForecast,
|
// selectForecast,
|
||||||
// } = useOceanForecast('KOREA')
|
// } = useOceanForecast('KOREA')
|
||||||
|
|
||||||
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0');
|
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0')
|
||||||
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null);
|
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null)
|
||||||
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
|
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
|
||||||
null,
|
null
|
||||||
);
|
)
|
||||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']));
|
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
|
||||||
// const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
// const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
||||||
|
|
||||||
// 첫 관측소 자동 선택 (파생 값)
|
// 첫 관측소 자동 선택 (파생 값)
|
||||||
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null;
|
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
|
||||||
|
|
||||||
const handleStationClick = useCallback((station: WeatherStation) => {
|
const handleStationClick = useCallback(
|
||||||
setSelectedStation(station);
|
(station: WeatherStation) => {
|
||||||
setSelectedLocation(null);
|
setSelectedStation(station)
|
||||||
}, []);
|
setSelectedLocation(null)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const handleMapClick = useCallback(
|
const handleMapClick = useCallback(
|
||||||
(e: MapLayerMouseEvent) => {
|
(e: MapLayerMouseEvent) => {
|
||||||
const { lat, lng } = e.lngLat;
|
const { lat, lng } = e.lngLat
|
||||||
if (weatherStations.length === 0) return;
|
if (weatherStations.length === 0) return
|
||||||
|
|
||||||
// 가장 가까운 관측소 선택
|
// 가장 가까운 관측소 선택
|
||||||
const nearestStation = weatherStations.reduce((nearest, station) => {
|
const nearestStation = weatherStations.reduce((nearest, station) => {
|
||||||
const distance = Math.sqrt(
|
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(
|
const nearestDistance = Math.sqrt(
|
||||||
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2),
|
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2)
|
||||||
);
|
)
|
||||||
return distance < nearestDistance ? station : nearest;
|
return distance < nearestDistance ? station : nearest
|
||||||
}, weatherStations[0]);
|
}, weatherStations[0])
|
||||||
|
|
||||||
setSelectedStation(nearestStation);
|
setSelectedStation(nearestStation)
|
||||||
setSelectedLocation({ lat, lon: lng });
|
setSelectedLocation({ lat, lon: lng })
|
||||||
},
|
},
|
||||||
[weatherStations],
|
[weatherStations]
|
||||||
);
|
)
|
||||||
|
|
||||||
const toggleLayer = useCallback((layer: string) => {
|
const toggleLayer = useCallback((layer: string) => {
|
||||||
setEnabledLayers((prev) => {
|
setEnabledLayers((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev)
|
||||||
if (next.has(layer)) {
|
if (next.has(layer)) {
|
||||||
next.delete(layer);
|
next.delete(layer)
|
||||||
} else {
|
} else {
|
||||||
next.add(layer);
|
next.add(layer)
|
||||||
}
|
}
|
||||||
return next;
|
return next
|
||||||
});
|
})
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const weatherData = selectedStation
|
const weatherData = selectedStation
|
||||||
? {
|
? {
|
||||||
@ -310,7 +301,7 @@ export function WeatherView() {
|
|||||||
alert: '풍랑주의보 예상 08:00~',
|
alert: '풍랑주의보 예상 08:00~',
|
||||||
forecast: generateForecast(timeOffset),
|
forecast: generateForecast(timeOffset),
|
||||||
}
|
}
|
||||||
: null;
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
@ -360,11 +351,12 @@ export function WeatherView() {
|
|||||||
latitude: WEATHER_MAP_CENTER[1],
|
latitude: WEATHER_MAP_CENTER[1],
|
||||||
zoom: WEATHER_MAP_ZOOM,
|
zoom: WEATHER_MAP_ZOOM,
|
||||||
}}
|
}}
|
||||||
mapStyle={WEATHER_MAP_STYLE}
|
mapStyle={currentMapStyle}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
onClick={handleMapClick}
|
onClick={handleMapClick}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||||
<WeatherMapInner
|
<WeatherMapInner
|
||||||
weatherStations={weatherStations}
|
weatherStations={weatherStations}
|
||||||
enabledLayers={enabledLayers}
|
enabledLayers={enabledLayers}
|
||||||
@ -377,10 +369,7 @@ export function WeatherView() {
|
|||||||
</Map>
|
</Map>
|
||||||
|
|
||||||
{/* 레이어 컨트롤 */}
|
{/* 레이어 컨트롤 */}
|
||||||
<div
|
<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' }}>
|
||||||
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="text-[9px] font-semibold text-fg mb-1.5 font-korean">기상 레이어</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
@ -441,17 +430,12 @@ export function WeatherView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 범례 */}
|
{/* 범례 */}
|
||||||
<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 }}>
|
||||||
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="text-[9px] font-semibold text-fg mb-1.5 font-korean">기상 범례</div>
|
||||||
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
|
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
|
||||||
{/* 바람 */}
|
{/* 바람 */}
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>바람 (m/s)</div>
|
||||||
바람 (m/s)
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
<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: '#6271b7' }} />
|
||||||
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
|
<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 className="flex-1 h-full" style={{ background: '#b41e46' }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
|
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
|
||||||
<span>3</span>
|
<span>3</span><span>5</span><span>7</span><span>10</span><span>13</span><span>16</span><span>20+</span>
|
||||||
<span>5</span>
|
|
||||||
<span>7</span>
|
|
||||||
<span>10</span>
|
|
||||||
<span>13</span>
|
|
||||||
<span>16</span>
|
|
||||||
<span>20+</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 해류 */}
|
{/* 해류 */}
|
||||||
<div className="pt-1 border-t border-stroke">
|
<div className="pt-1 border-t border-stroke">
|
||||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>해류 (m/s)</div>
|
||||||
해류 (m/s)
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
<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(59, 130, 246)' }} />
|
||||||
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
|
<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 className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
|
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
|
||||||
<span>0.2</span>
|
<span>0.2</span><span>0.4</span><span>0.6</span><span>0.6+</span>
|
||||||
<span>0.4</span>
|
|
||||||
<span>0.6</span>
|
|
||||||
<span>0.6+</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 파고 */}
|
{/* 파고 */}
|
||||||
<div className="pt-1 border-t border-stroke">
|
<div className="pt-1 border-t border-stroke">
|
||||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>파고 (m)</div>
|
||||||
파고 (m)
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||||
<span className="text-fg-disabled"><1.5 낮음</span>
|
<span className="text-fg-disabled"><1.5 낮음</span>
|
||||||
@ -505,10 +476,7 @@ export function WeatherView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="mt-1 pt-1 border-t border-stroke text-fg-disabled font-korean" style={{ fontSize: 7 }}>
|
||||||
className="mt-1 pt-1 border-t border-stroke text-fg-disabled font-korean"
|
|
||||||
style={{ fontSize: 7 }}
|
|
||||||
>
|
|
||||||
💡 지도 클릭 → 기상 예보 확인
|
💡 지도 클릭 → 기상 예보 확인
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -518,5 +486,5 @@ export function WeatherView() {
|
|||||||
{/* Right Panel */}
|
{/* Right Panel */}
|
||||||
<WeatherRightPanel weatherData={weatherData} />
|
<WeatherRightPanel weatherData={weatherData} />
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user