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