Compare commits

..

33 커밋

작성자 SHA1 메시지 날짜
7cdc8bef2f Merge pull request 'fix(map): Globe 렌더링 안정화 및 툴팁 유지 개선' (#29) from feature/globe-rendering-optimization into develop
Reviewed-on: #29
2026-02-17 16:49:48 +09:00
59a5e6beac fix(map): 패널 선택 fly-to 즉시 반응 개선
- shipData를 ref로 관리하여 AIS poll마다 effect 재실행 방지
- isStyleLoaded 가드 제거 → try/catch로 즉시 실행
- duration 700→400ms로 단축
- selectedMmsi만 의존성으로 → 선택 시 1회만 fly-to 실행

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:47:38 +09:00
f9da13b694 fix(map): 패널 선택 시 fly-to 복원, 지도 클릭은 제외
- mapInitiatedSelectRef 도입: 지도 클릭 선택과 패널 선택을 구분
- 좌측 패널(선박 목록, 알람 목록) 선택 시 해당 위치로 fly-to
- 지도에서 직접 클릭/우클릭 선택 시에는 fly-to 비활성화
- onMapSelectMmsi 래퍼로 지도 내 선택 경로 통합 (Globe+Mercator)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:44:55 +09:00
7bca216c53 fix(map): Globe 렌더링 안정화 및 툴팁 유지 개선
- isStyleLoaded() 가드를 try/catch 패턴으로 교체 (AIS poll setData 중 렌더링 차단 방지)
- Globe 툴팁 buildTooltipRef 패턴 도입 (AIS poll 주기 변경 시 사라짐 방지)
- Globe 우클릭 컨텍스트 메뉴 isStyleLoaded 가드 제거
- 항적 가상 선박을 IconLayer에서 ScatterplotLayer(원형)로 변경
- useNativeMapLayers isStyleLoaded 가드 제거 (항적 레이어 셋업 스킵 방지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:38:51 +09:00
ccaf20804d Merge pull request 'feat(map): 오버레이 가시성 개선 + 경고 선박 강조' (#27) from feature/overlay-alarm-visibility into develop
Reviewed-on: #27
2026-02-17 11:01:56 +09:00
168bea0621 feat(map): 오버레이 가시성 개선 + 경고 선박 강조
Part A — 오버레이 라인 가시성:
- Globe/Mercator 쌍끌이·FC·선단 라인 굵기 ~2x 증가
- Globe 범위 원·선단 원 대시 패턴 추가
- Globe 오버레이 호버 시 브리딩(breathing) 맥동 rAF

Part B — 경고 선박 강조:
- DashboardPage → Map3D alarmMmsiMap 전달
- Globe: 경고 맥동 레이어(ships-globe-alarm-pulse) + 배지(ships-globe-alarm-badge)
- Mercator: Deck.gl ScatterplotLayer 맥동 + TextLayer 배지
- 경고 종류별 배지(이/수/환/휴/A), z-index 상향, 호버 스케일 확대
- 경고 필터 OFF 시 맥동/배지 자동 비표시

Part C — Mercator 선명 라벨:
- MapLibre 라벨 → Deck.gl TextLayer 교체 (z-order 안정)

Part D — 프로젝션 전환 안정화:
- Globe→Mercator 전환 시 Globe custom layer 맵에서 분리
- alarm rAF에 projectionBusyRef 가드 추가
- overlay.setProps() stale WebGL 자원 참조 방지

Part E — 김개발(DEV) 모드 더미 데이터:
- mockOverlayData.ts: 서해 12척 가상 선박 (5종 경고 시나리오)
- 김개발 로그인 시 자동 주입, 일반 계정 미노출

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:52:51 +09:00
383b41f49a fix(sidebar): 검색 복원, 경고 필터, 내부 스크롤 수정 (#25) 2026-02-17 08:12:14 +09:00
9048dfdfc6 Merge pull request 'feat: 모바일 반응형 UI (drawer, 아코디언, 범례)' (#22) from feature/mobile-responsive-ui into develop
Reviewed-on: #22
2026-02-17 07:14:45 +09:00
d9b8e9ca44 fix(topbar): 통계 토글 탭을 하단 우측으로 이동
- 통계 펼치기 버튼을 topbar 내부에서 하단 우측 탭으로 이동
- 통계 바를 absolute→flow 레이아웃으로 변경 (지도 줌 겹침 방지)
- 모바일 grid-rows를 auto로 변경하여 통계 바 높이 수용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 07:11:42 +09:00
541135977c refactor(topbar): POLL 제거 + 모바일 통계 바
- POLL/DATA/API/READY 상태 표시 제거
- 데스크톱: 통계 칩 항상 인라인 표시
- 모바일: 통계 펼치기 버튼 + 하단 확장 바
- 시스템명/시계/테마/로그아웃 항상 표시
- pollingStatus/lastFetchMinutes props 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 07:06:05 +09:00
61fc3bbce4 feat: 모바일 반응형 UI (drawer, 아코디언, 범례)
- Topbar: 햄버거 메뉴 버튼, 반응형 stats 숨김
- Sidebar: 모바일 drawer (fixed + translate-x), backdrop
- Sidebar: Section 아코디언으로 전환 (details/summary)
- Legend: 접기/펼치기 토글 추가
- panels.css: .sb/.sb-t 클래스 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:54:27 +09:00
255c082436 Merge pull request 'feat: Tailwind CSS v4 + @wing/ui 디자인 시스템' (#21) from feature/tailwind-design-system into develop
Reviewed-on: #21
2026-02-17 06:46:49 +09:00
40229a75c0 fix: 폰트 크기 복원 + 라이트 모드 대비 + CSS 변수 전환
- @theme에서 --text-xs/sm 오버라이드 제거 (Tailwind 기본값 복원)
- 라이트 모드: bg #e2e8f0, border #94a3b8로 영역 대비 강화
- 새 토큰: --wing-glass-dense, --wing-overlay, --wing-card-alpha, --wing-subtle
- 8개 CSS 파일의 하드코딩된 rgba 값을 CSS 변수로 전환
- MapLibre 컨트롤 아이콘 라이트 모드 대응

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:41:27 +09:00
a132c7eaf8 feat: useTheme 훅 + 테마 토글 버튼 구현
- useTheme: localStorage 기반 다크/라이트 테마 전환
- data-theme 속성으로 CSS 변수 자동 전환
- Topbar에 Light/Dark 토글 버튼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:22:49 +09:00
2adcbc9a93 refactor: toggles CSS를 Tailwind + @wing/ui로 전환
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:18:57 +09:00
e9a41c6663 refactor: base/layout/topbar CSS를 Tailwind로 전환
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:15:32 +09:00
6167a0ebd8 feat(ui): @wing/ui 기본 컴포넌트 8개 구현
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:12:02 +09:00
a4859f54bc chore: Tailwind CSS v4 + @wing/ui 패키지 인프라 구축
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:10:04 +09:00
c8c1b556d6 Merge pull request 'refactor: 데드코드 정리 + 대형 파일 분리 + FSD 위반 해소' (#19) from feature/refactor-cleanup into develop
Reviewed-on: #19
2026-02-17 06:05:41 +09:00
ec9d894ac8 refactor: FSD 위반 해소 — 공유 상수/함수를 shared/로 이동
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:04:40 +09:00
3fa0b67e97 refactor(styles): CSS 1,814줄 → 15개 컴포넌트 파일 분리
styles.css 모놀리스를 @import 기반 모듈 구조로 분리:
- base.css: CSS 변수, 리셋, 폰트
- layout.css: 그리드 레이아웃, 반응형
- components/: topbar, panels, toggles, speed, vessel-list,
  ais-list, alarms, relations, map-panels, map-settings,
  auth, weather, weather-overlay

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:01:04 +09:00
ec03a88fbd refactor(dashboard): 사이드바 + 상태 훅 추출 분리
DashboardPage.tsx (808줄) → 3파일 분리:
- useDashboardState.ts (147줄): UI 상태 관리 훅
- DashboardSidebar.tsx (430줄): 좌측 사이드바 컴포넌트
- DashboardPage.tsx (295줄): 레이아웃 + 지도 영역

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:55:58 +09:00
4d67b26ffa refactor(map3d): useGlobeOverlays 600줄 → 서브훅 2+1개 분리
- useGlobePairOverlay: pair lines + pair range + paint
- useGlobeFcFleetOverlay: fc lines + fleet circles + paint
- useGlobeOverlays: 오케스트레이터 (기존 호출부 호환)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:44:19 +09:00
b1551f800b refactor(map3d): useDeckLayers 레이어 생성 팩토리 추출
- buildMercatorDeckLayers: Mercator 모드 Deck.gl 레이어 팩토리
- buildGlobeDeckLayers: Globe 모드 Deck.gl 레이어 팩토리
- useDeckLayers: 오케스트레이션 + 툴팁/클릭 + setProps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:41:29 +09:00
e2dc927ad2 refactor(map3d): useGlobeShips 977줄 → 서브훅 3+1개 분리
- useGlobeShipLabels: Mercator 선명 라벨
- useGlobeShipLayers: Globe 선박 아이콘 레이어 + GeoJSON
- useGlobeShipHover: Globe 호버 오버레이 + 클릭 선택
- useGlobeShips: 오케스트레이터 (기존 호출부 호환)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:35:03 +09:00
c2ca830ef0 chore: 미사용 데드코드 삭제
- entities/vessel/lib/filter.ts (미사용 필터 유틸)
- entities/vessel/model/mockFleet.ts (미사용 mock 데이터)
- shared/lib/color/hexToRgb.ts (MapSettingsPanel 로컬 중복)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:20:47 +09:00
4d1a8a0d1e Merge pull request 'feat(weather,track): 기상 오버레이 + vessel-track 병합' (#17) from feature/weather-overlay into develop
Reviewed-on: #17
2026-02-16 22:47:26 +09:00
86a0b2276f fix(web): vessel-track 안정화 반영
- Mercator/Globe track-replay 레이어 충돌 및 setProps 레이스 해결
- track DTO 좌표/시간 정규화 + stale query 응답 무시
- 조회 직후 표시 안정화 및 기본 100x 자동재생 적용
- Global Track Replay 패널 초기 위치 조정 + 헤더 드래그 지원
- liveRenderer batch rendering + trackReplay store 기반 구조 반영

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:43:08 +09:00
c09211d5ed chore: develop 병합으로 충돌 해결
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:21:15 +09:00
6acf2045b2 chore: vessel-track 브랜치 병합 (squash)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:12:48 +09:00
cb6493b8a1 feat(weather): 기상 타일 오버레이 + 수역별 날씨 패널
MapTiler Weather SDK 6종 기상 타일 오버레이:
- 바람/기온/강수/기압/레이더/구름 라디오 토글
- 3시간 단위 step 스냅 타임라인 + 드래그 실시간 seek
- 색상 범례, 배속 제어, 투명도 조절
- ServiceWorker 타일 캐시 (cache-first, 최대 2000장)
- SDK 시간 단위(epoch 초) 정합성 보장

Open-Meteo 수역별 기상 패널:
- 4개 수역 centroid 기반 해양/기상 데이터 5분 폴링
- 파고/풍속/수온/너울 카드 UI + 경고 하이라이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:45:07 +09:00
c05ec159ce Merge pull request 'feat(map): 항적조회 기능 구현' (#14) from feature/vessel-track into develop
Reviewed-on: #14
2026-02-16 18:33:18 +09:00
c23264c3ba Merge pull request 'feature/dual-rendering' (#12) from feature/dual-rendering into develop 2026-02-16 16:26:10 +09:00
97개의 변경된 파일10838개의 추가작업 그리고 4294개의 파일을 삭제

파일 보기

@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
<title>WING 조업감시 데모</title>
</head>
<body>

파일 보기

@ -12,17 +12,23 @@
"dependencies": {
"@deck.gl/aggregation-layers": "^9.2.7",
"@deck.gl/core": "^9.2.7",
"@deck.gl/extensions": "^9.2.7",
"@deck.gl/geo-layers": "^9.2.7",
"@deck.gl/layers": "^9.2.7",
"@deck.gl/mapbox": "^9.2.7",
"@maptiler/weather": "^3.1.1",
"@react-oauth/google": "^0.13.4",
"@stomp/stompjs": "^7.2.1",
"@wing/ui": "*",
"maplibre-gl": "^5.18.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router": "^7.13.0"
"react-router": "^7.13.0",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@ -31,6 +37,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"

파일 보기

@ -0,0 +1,62 @@
/**
* Weather Tile Cache ServiceWorker
*
* MapTiler Weather SDK 타일(Image 요소로 로드) Cache API로 캐싱.
* 같은 tileset_id + z/x/y 좌표 동일 URL cache-first 전략.
*
* 캐시 최대 2000, 초과 가장 오래된 항목부터 제거.
*/
const CACHE_NAME = 'weather-tiles-v1';
const MAX_ENTRIES = 2000;
/** api.maptiler.com/tiles/ 패턴만 캐싱 */
const TILE_RE = /api\.maptiler\.com\/tiles\//;
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((k) => k.startsWith('weather-tiles-') && k !== CACHE_NAME)
.map((k) => caches.delete(k)),
),
),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const url = event.request.url;
if (!TILE_RE.test(url)) return; // 타일 외 요청은 패스스루
event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => {
// 1. 캐시 히트 → 즉시 반환
const cached = await cache.match(event.request);
if (cached) return cached;
// 2. 네트워크 fetch
const response = await fetch(event.request);
if (response.ok) {
// 비동기 캐시 저장 (응답 지연 없음)
cache.put(event.request, response.clone()).then(() => trimCache(cache));
}
return response;
}),
);
});
/** 캐시 항목이 MAX_ENTRIES 초과 시 오래된 것부터 삭제 */
async function trimCache(cache) {
const keys = await cache.keys();
if (keys.length <= MAX_ENTRIES) return;
const excess = keys.length - MAX_ENTRIES;
for (let i = 0; i < excess; i++) {
await cache.delete(keys[i]);
}
}

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

파일 보기

@ -0,0 +1,19 @@
/* Google Fonts: loaded via index.html <link> */
/* CSS Reset: handled by Tailwind preflight */
/* CSS Variables: defined in @wing/ui/theme/tokens.css */
html,
body {
height: 100%;
}
body {
font-family: "Noto Sans KR", sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
#root {
height: 100%;
}

파일 보기

@ -0,0 +1,159 @@
/* AIS target list */
.ais-q {
flex: 1;
font-size: 10px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--wing-card-alpha);
color: var(--text);
outline: none;
}
.ais-q::placeholder {
color: var(--wing-muted);
}
.ais-mode {
display: flex;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.ais-mode-btn {
font-size: 9px;
padding: 0 8px;
height: 28px;
border: none;
background: var(--card);
color: var(--muted);
cursor: pointer;
}
.ais-mode-btn.on {
background: rgba(59, 130, 246, 0.18);
color: var(--text);
}
.ais-list {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.ais-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 6px;
border-radius: 6px;
cursor: pointer;
user-select: none;
transition: background 0.12s, border-color 0.12s;
border: 1px solid transparent;
}
.ais-row:hover {
background: var(--wing-card-alpha);
border-color: var(--wing-border);
}
.ais-row.sel {
background: rgba(59, 130, 246, 0.14);
border-color: rgba(59, 130, 246, 0.55);
}
.ais-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.ais-nm {
flex: 1;
min-width: 0;
}
.ais-nm1 {
font-size: 10px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ais-nm2 {
font-size: 9px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ais-right {
text-align: right;
flex-shrink: 0;
}
.ais-badges {
display: flex;
gap: 4px;
justify-content: flex-end;
margin-bottom: 2px;
flex-wrap: wrap;
}
.ais-badge {
font-size: 8px;
padding: 1px 5px;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.08);
font-weight: 800;
letter-spacing: 0.2px;
color: #fff;
background: rgba(100, 116, 139, 0.22);
}
.ais-badge.pn {
color: var(--muted);
background: var(--wing-card-alpha);
border-color: var(--wing-border);
font-weight: 700;
}
.ais-badge.PT {
background: rgba(30, 64, 175, 0.28);
border-color: rgba(30, 64, 175, 0.7);
}
.ais-badge.PT-S {
background: rgba(234, 88, 12, 0.22);
border-color: rgba(234, 88, 12, 0.7);
}
.ais-badge.GN {
background: rgba(16, 185, 129, 0.22);
border-color: rgba(16, 185, 129, 0.7);
}
.ais-badge.OT {
background: rgba(139, 92, 246, 0.22);
border-color: rgba(139, 92, 246, 0.7);
}
.ais-badge.PS {
background: rgba(239, 68, 68, 0.22);
border-color: rgba(239, 68, 68, 0.7);
}
.ais-badge.FC {
background: rgba(245, 158, 11, 0.22);
border-color: rgba(245, 158, 11, 0.7);
}
.ais-sp {
font-size: 10px;
font-weight: 800;
}
.ais-ts {
font-size: 9px;
color: var(--wing-muted);
}

파일 보기

@ -0,0 +1,95 @@
/* Alarm */
.ai {
display: flex;
gap: 6px;
padding: 4px 6px;
border-radius: 3px;
margin-bottom: 2px;
font-size: 9px;
border-left: 3px solid;
}
.ai.cr {
border-color: var(--crit);
background: rgba(239, 68, 68, 0.07);
}
.ai.hi {
border-color: var(--high);
background: rgba(245, 158, 11, 0.05);
}
.ai .at {
color: var(--muted);
font-size: 8px;
white-space: nowrap;
}
/* Alarm filter (dropdown) */
.alarm-filter {
position: relative;
}
.alarm-filter__summary {
list-style: none;
cursor: pointer;
padding: 2px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--wing-card-alpha);
color: var(--text);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.4px;
user-select: none;
white-space: nowrap;
}
.alarm-filter__summary::-webkit-details-marker {
display: none;
}
.alarm-filter__menu {
position: absolute;
right: 0;
top: 22px;
z-index: 2000;
min-width: 170px;
padding: 6px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--wing-glass-dense);
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55);
}
.alarm-filter__row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 6px;
cursor: pointer;
font-size: 10px;
color: var(--text);
user-select: none;
}
.alarm-filter__row:hover {
background: rgba(59, 130, 246, 0.08);
}
.alarm-filter__row input {
cursor: pointer;
}
.alarm-filter__cnt {
margin-left: auto;
font-size: 9px;
color: var(--muted);
}
.alarm-filter__sep {
height: 1px;
background: var(--wing-border);
margin: 4px 0;
}

파일 보기

@ -0,0 +1,126 @@
/* ── Auth pages ──────────────────────────────────────────────────── */
.auth-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--wing-bg) 0%, var(--wing-surface) 50%, var(--wing-bg) 100%);
}
.auth-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 40px 36px;
width: 360px;
text-align: center;
}
.auth-logo {
font-size: 36px;
font-weight: 900;
color: var(--accent);
letter-spacing: 4px;
margin-bottom: 4px;
}
.auth-title {
font-size: 16px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.auth-subtitle {
font-size: 12px;
color: var(--muted);
margin-bottom: 24px;
}
.auth-error {
font-size: 11px;
color: var(--crit);
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 16px;
}
.auth-google-btn {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.auth-dev-btn {
width: 100%;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card);
color: var(--muted);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
margin-bottom: 12px;
}
.auth-dev-btn:hover {
color: var(--text);
border-color: var(--accent);
}
.auth-footer {
font-size: 10px;
color: var(--muted);
margin-top: 16px;
}
.auth-status-icon {
font-size: 48px;
margin-bottom: 12px;
}
.auth-message {
font-size: 13px;
color: var(--muted);
line-height: 1.6;
margin-bottom: 16px;
}
.auth-message b {
color: var(--text);
}
.auth-link-btn {
background: none;
border: none;
color: var(--accent);
font-size: 12px;
cursor: pointer;
text-decoration: underline;
padding: 4px 8px;
}
.auth-link-btn:hover {
color: var(--text);
}
.auth-loading {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
}
.auth-loading__spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(148, 163, 184, 0.28);
border-top-color: var(--accent);
border-radius: 50%;
animation: map-loader-spin 0.7s linear infinite;
}

파일 보기

@ -0,0 +1,217 @@
/* Map panels */
.map-legend {
position: absolute;
/* Keep attribution visible (bottom-right) for licensing/compliance. */
bottom: 44px;
right: 12px;
z-index: 800;
background: var(--wing-glass);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
font-size: 9px;
min-width: 180px;
}
.map-legend .lt {
font-size: 8px;
font-weight: 700;
color: var(--muted);
margin-bottom: 4px;
letter-spacing: 1px;
}
.map-legend .li {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 2px;
}
.map-legend .ls {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.map-info {
position: absolute;
top: 12px;
right: 12px;
z-index: 800;
background: var(--wing-glass-dense);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
width: 270px;
}
.map-info .ir {
display: flex;
justify-content: space-between;
font-size: 10px;
padding: 2px 0;
border-bottom: 1px solid var(--wing-subtle);
}
.map-info .il {
color: var(--muted);
}
.map-info .iv {
font-weight: 600;
}
.map-loader-overlay {
position: absolute;
inset: 0;
z-index: 950;
display: flex;
align-items: center;
justify-content: center;
background: var(--wing-overlay);
pointer-events: auto;
}
.map-loader-overlay__panel {
width: min(72vw, 320px);
background: var(--wing-glass-dense);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 16px;
display: grid;
gap: 10px;
justify-items: center;
}
.map-loader-overlay__spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(148, 163, 184, 0.28);
border-top-color: var(--accent);
border-radius: 50%;
animation: map-loader-spin 0.7s linear infinite;
}
.map-loader-overlay__text {
font-size: 12px;
color: var(--text);
letter-spacing: 0.2px;
}
.map-loader-overlay__bar {
width: 100%;
height: 6px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.2);
overflow: hidden;
position: relative;
}
.map-loader-overlay__fill {
width: 28%;
height: 100%;
border-radius: inherit;
background: var(--accent);
animation: map-loader-fill 1.2s ease-in-out infinite;
}
.maplibre-tooltip-popup .maplibregl-popup-content {
color: #f8fafc !important;
background: rgba(2, 6, 23, 0.98) !important;
border: 1px solid rgba(148, 163, 184, 0.4) !important;
box-shadow: 0 8px 26px rgba(2, 6, 23, 0.55) !important;
border-radius: 8px !important;
font-size: 11px !important;
line-height: 1.35 !important;
padding: 7px 9px !important;
color: #f8fafc !important;
min-width: 180px;
}
.maplibre-tooltip-popup .maplibregl-popup-tip {
border-top-color: rgba(2, 6, 23, 0.97) !important;
}
.maplibre-tooltip-popup__content {
color: #f8fafc;
font-family: Pretendard, Inter, ui-sans-serif, -apple-system, Segoe UI, sans-serif;
font-size: 11px;
line-height: 1.35;
}
.maplibre-tooltip-popup__content div,
.maplibre-tooltip-popup__content span,
.maplibre-tooltip-popup__content p {
color: inherit;
}
.maplibre-tooltip-popup__content div {
word-break: break-word;
}
.maplibre-tooltip-popup .maplibregl-popup-content div,
.maplibre-tooltip-popup .maplibregl-popup-content span,
.maplibre-tooltip-popup .maplibregl-popup-content p {
color: inherit !important;
}
.maplibre-tooltip-popup .maplibregl-popup-close-button {
color: #94a3b8 !important;
}
@keyframes map-loader-spin {
to {
transform: rotate(360deg);
}
}
@keyframes map-loader-fill {
0% {
transform: translateX(-40%);
}
50% {
transform: translateX(220%);
}
100% {
transform: translateX(-40%);
}
}
.maplibregl-ctrl-group {
border: 1px solid var(--border) !important;
background: var(--wing-glass) !important;
backdrop-filter: blur(8px);
}
.maplibregl-ctrl-group button {
background: transparent !important;
}
.maplibregl-ctrl-group button + button {
border-top: 1px solid var(--border) !important;
}
:root .maplibregl-ctrl-group button span,
[data-theme='dark'] .maplibregl-ctrl-group button span {
filter: invert(1);
opacity: 0.9;
}
[data-theme='light'] .maplibregl-ctrl-group button span {
filter: none;
opacity: 0.7;
}
.maplibregl-ctrl-attrib {
font-size: 10px !important;
background: var(--wing-glass) !important;
color: var(--text) !important;
border: 1px solid var(--border) !important;
border-radius: 8px;
}

파일 보기

@ -0,0 +1,175 @@
/* ── Map Settings Panel ──────────────────────────────────────────── */
.map-settings-gear {
position: absolute;
top: 100px;
left: 10px;
z-index: 850;
width: 29px;
height: 29px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--wing-glass);
backdrop-filter: blur(8px);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: color 0.15s, border-color 0.15s;
user-select: none;
padding: 0;
}
.map-settings-gear:hover {
color: var(--text);
border-color: var(--accent);
}
.map-settings-gear.open {
color: var(--accent);
border-color: var(--accent);
}
.map-settings-panel {
position: absolute;
top: 10px;
left: 48px;
z-index: 850;
background: var(--wing-glass-dense);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
width: 240px;
max-height: calc(100vh - 80px);
overflow-y: auto;
}
.map-settings-panel .ms-title {
font-size: 11px;
font-weight: 700;
color: var(--text);
letter-spacing: 1px;
margin-bottom: 10px;
}
.map-settings-panel .ms-section {
margin-bottom: 10px;
}
.map-settings-panel .ms-label {
font-size: 10px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.map-settings-panel .ms-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.map-settings-panel .ms-color-input {
width: 24px;
height: 24px;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0;
cursor: pointer;
background: transparent;
flex-shrink: 0;
}
.map-settings-panel .ms-color-input::-webkit-color-swatch-wrapper {
padding: 1px;
}
.map-settings-panel .ms-color-input::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.map-settings-panel .ms-hex {
font-size: 9px;
color: #94a3b8;
font-family: monospace;
}
.map-settings-panel .ms-depth-label {
font-size: 10px;
color: var(--text);
min-width: 48px;
text-align: right;
}
.map-settings-panel select {
font-size: 10px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
cursor: pointer;
outline: none;
width: 100%;
}
.map-settings-panel select:focus {
border-color: var(--accent);
}
.map-settings-panel .ms-reset {
width: 100%;
font-size: 9px;
padding: 5px 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--card);
color: var(--muted);
cursor: pointer;
transition: all 0.15s;
margin-top: 4px;
}
.map-settings-panel .ms-reset:hover {
color: var(--text);
border-color: var(--accent);
}
/* ── Depth Legend ──────────────────────────────────────────────────── */
.depth-legend {
position: absolute;
bottom: 44px;
left: 10px;
z-index: 800;
background: var(--wing-glass);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
display: flex;
gap: 6px;
align-items: stretch;
}
.depth-legend__bar {
width: 14px;
border-radius: 3px;
min-height: 120px;
}
.depth-legend__ticks {
display: flex;
flex-direction: column;
justify-content: space-between;
font-size: 8px;
color: var(--muted);
font-family: monospace;
padding: 1px 0;
}

파일 보기

@ -0,0 +1,56 @@
.relation-sort {
display: flex;
align-items: center;
gap: 6px;
font-size: 8px;
color: var(--muted);
white-space: nowrap;
}
.relation-sort__option {
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
user-select: none;
}
.close-btn {
position: absolute;
top: 6px;
right: 8px;
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 13px;
}
.month-row {
display: flex;
gap: 1px;
}
.month-cell {
flex: 1;
height: 12px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 6px;
font-weight: 600;
}
::-webkit-scrollbar {
width: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}

파일 보기

@ -0,0 +1,95 @@
/* Relation panel */
.rel-panel {
background: var(--card);
border-radius: 6px;
padding: 8px;
margin-top: 4px;
}
.rel-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.rel-badge {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
font-weight: 700;
}
.rel-line {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
padding: 2px 0;
}
.rel-line .dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.rel-link {
width: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 10px;
}
.rel-dist {
font-size: 8px;
padding: 1px 4px;
border-radius: 2px;
font-weight: 600;
}
/* Fleet network */
.fleet-card {
background: var(--wing-card-alpha);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
margin-bottom: 4px;
}
.fleet-card.hl,
.fleet-card:hover {
border-color: rgba(245, 158, 11, 0.75);
background: rgba(251, 191, 36, 0.09);
box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.25) inset;
}
.fleet-owner {
font-size: 10px;
font-weight: 700;
color: var(--accent);
margin-bottom: 4px;
}
.fleet-owner.hl {
color: rgba(245, 158, 11, 1);
}
.fleet-vessel {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
padding: 1px 0;
}
.fleet-vessel.hl {
color: rgba(245, 158, 11, 1);
}
.fleet-dot.hl {
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.45);
}

파일 보기

@ -0,0 +1,33 @@
/* Speed bar */
.sbar {
position: relative;
height: 24px;
background: var(--bg);
border-radius: 5px;
overflow: hidden;
margin: 4px 0;
}
.sseg {
position: absolute;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
font-size: 7px;
color: #fff;
font-weight: 600;
}
.sseg.p {
height: 24px;
top: 0;
border: 1.5px solid rgba(255, 255, 255, 0.25);
box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
}
.sseg:not(.p) {
height: 16px;
top: 4px;
opacity: 0.6;
}

파일 보기

@ -0,0 +1,61 @@
/* Vessel list */
.vlist {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.vi {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 3px;
cursor: pointer;
font-size: 10px;
transition: background 0.1s;
user-select: none;
}
.vi:hover {
background: var(--card);
}
.vi.sel {
background: rgba(14, 234, 255, 0.16);
border-color: rgba(14, 234, 255, 0.55);
}
.vi.hl {
background: rgba(245, 158, 11, 0.16);
border: 1px solid rgba(245, 158, 11, 0.4);
}
.vi.sel.hl {
background: linear-gradient(90deg, rgba(14, 234, 255, 0.16), rgba(245, 158, 11, 0.16));
border-color: rgba(14, 234, 255, 0.7);
}
.vi .dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.vi .nm {
flex: 1;
font-weight: 500;
}
.vi .sp {
font-weight: 700;
font-size: 9px;
}
.vi .st {
font-size: 7px;
padding: 1px 3px;
border-radius: 2px;
font-weight: 600;
}

파일 보기

@ -0,0 +1,356 @@
/* ── Weather Overlay Panel (MapTiler) ────────────────────────────── */
.wo-gear {
position: absolute;
top: 180px;
left: 10px;
z-index: 850;
width: 29px;
height: 29px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--wing-glass);
backdrop-filter: blur(8px);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: color 0.15s, border-color 0.15s;
user-select: none;
padding: 0;
}
.wo-gear:hover {
color: var(--text);
border-color: var(--accent);
}
.wo-gear.open {
color: var(--accent);
border-color: var(--accent);
}
.wo-gear.active {
border-color: #22c55e;
}
.wo-gear.active.open {
border-color: var(--accent);
}
.wo-gear-badge {
position: absolute;
top: -4px;
right: -4px;
background: #22c55e;
color: #fff;
font-size: 8px;
font-weight: 700;
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.wo-stack {
position: absolute;
top: 170px;
left: 48px;
z-index: 850;
display: flex;
flex-direction: column;
gap: 6px;
width: 280px;
pointer-events: none;
}
.wo-stack > * {
pointer-events: auto;
}
.wo-panel {
background: var(--wing-glass-dense);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
width: 100%;
max-height: calc(100vh - 240px);
overflow-y: auto;
}
.wo-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.wo-title {
font-size: 11px;
font-weight: 700;
color: var(--text);
letter-spacing: 1px;
}
.wo-loading {
font-size: 9px;
color: var(--accent);
animation: wo-pulse 1.2s ease-in-out infinite;
}
@keyframes wo-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.wo-layers {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
margin-bottom: 10px;
}
.wo-layer-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 4px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--wing-subtle);
color: var(--muted);
cursor: pointer;
transition: all 0.15s;
font-size: 10px;
}
.wo-layer-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
border-color: rgba(59, 130, 246, 0.4);
}
.wo-layer-btn.on {
background: rgba(59, 130, 246, 0.15);
color: var(--text);
border-color: var(--accent);
}
.wo-layer-icon {
font-size: 16px;
line-height: 1;
}
.wo-layer-name {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.3px;
}
.wo-section {
margin-bottom: 8px;
}
.wo-label {
font-size: 9px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
.wo-val {
font-weight: 400;
color: var(--muted);
font-size: 9px;
}
.wo-offset {
color: #4fc3f7;
font-weight: 600;
}
.wo-slider {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--border);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.wo-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
border: 2px solid var(--panel);
cursor: pointer;
}
.wo-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
border: 2px solid var(--panel);
cursor: pointer;
}
.wo-slider:disabled {
opacity: 0.4;
cursor: default;
}
.wo-timeline {
padding-top: 8px;
border-top: 1px solid var(--border);
}
.wo-step-slider-wrap {
position: relative;
padding-bottom: 10px;
}
.wo-time-slider {
margin-bottom: 2px;
}
.wo-step-ticks {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 8px;
pointer-events: none;
}
.wo-step-tick {
position: absolute;
bottom: 0;
width: 1px;
height: 4px;
background: var(--muted);
opacity: 0.4;
transform: translateX(-0.5px);
}
.wo-step-tick.day {
height: 8px;
opacity: 0.8;
background: var(--accent);
}
.wo-time-range {
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--muted);
margin-bottom: 6px;
}
.wo-playback {
display: flex;
align-items: center;
gap: 6px;
}
.wo-play-btn {
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
transition: all 0.15s;
padding: 0;
flex-shrink: 0;
}
.wo-play-btn:hover {
border-color: var(--accent);
background: rgba(59, 130, 246, 0.12);
}
.wo-play-btn:disabled {
opacity: 0.4;
cursor: default;
}
.wo-speed-btns {
display: flex;
gap: 2px;
}
.wo-speed-btn {
font-size: 8px;
padding: 3px 6px;
border-radius: 3px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
cursor: pointer;
transition: all 0.15s;
}
.wo-speed-btn:hover {
color: var(--text);
border-color: rgba(59, 130, 246, 0.4);
}
.wo-speed-btn.on {
background: rgba(59, 130, 246, 0.15);
color: var(--text);
border-color: var(--accent);
}
.wo-hint {
font-size: 8px;
color: var(--muted);
text-align: right;
margin-top: 4px;
opacity: 0.6;
}
/* ── Weather Legend ── */
.wo-legend {
background: var(--wing-glass);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px 4px;
width: 100%;
}
.wo-legend-header {
font-size: 9px;
color: var(--muted);
margin-bottom: 4px;
text-align: center;
}
.wo-legend-bar {
height: 10px;
border-radius: 3px;
width: 100%;
}
.wo-legend-ticks {
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--muted);
margin-top: 2px;
}

파일 보기

@ -0,0 +1,185 @@
/* ── Weather Panel (Open-Meteo) ───────────────────────────────────── */
.weather-gear {
position: absolute;
top: 140px;
left: 10px;
z-index: 850;
width: 29px;
height: 29px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--wing-glass);
backdrop-filter: blur(8px);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: color 0.15s, border-color 0.15s;
user-select: none;
padding: 0;
}
.weather-gear:hover {
color: var(--text);
border-color: var(--accent);
}
.weather-gear.open {
color: var(--accent);
border-color: var(--accent);
}
.weather-panel {
position: absolute;
top: 130px;
left: 48px;
z-index: 850;
background: var(--wing-glass-dense);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
width: 260px;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.wp-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.wp-title {
font-size: 11px;
font-weight: 700;
color: var(--text);
letter-spacing: 1px;
}
.wp-loading {
font-size: 9px;
color: var(--accent);
}
.wp-error {
font-size: 9px;
color: #f87171;
margin-bottom: 6px;
padding: 4px 6px;
background: rgba(248, 113, 113, 0.1);
border-radius: 4px;
}
.wp-empty {
font-size: 10px;
color: var(--muted);
text-align: center;
padding: 12px 0;
}
.wz-card {
border-left: 3px solid var(--border);
padding: 6px 8px;
margin-bottom: 6px;
border-radius: 0 4px 4px 0;
background: var(--wing-subtle);
transition: background 0.15s;
}
.wz-card:last-of-type {
margin-bottom: 0;
}
.wz-card.wz-warn {
background: rgba(248, 113, 113, 0.08);
}
.wz-name {
font-size: 10px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
letter-spacing: 0.3px;
}
.wz-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 2px;
}
.wz-item {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
color: var(--muted);
white-space: nowrap;
}
.wz-icon {
font-size: 10px;
color: var(--accent);
}
.wz-label {
font-size: 9px;
color: var(--muted);
}
.wz-value {
font-size: 10px;
font-weight: 600;
color: var(--text);
}
.wz-weather {
font-weight: 500;
color: var(--muted);
}
.wp-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid var(--border);
}
.wp-time {
font-size: 9px;
color: var(--muted);
}
.wp-refresh {
font-size: 14px;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 3px;
width: 22px;
height: 22px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s;
padding: 0;
}
.wp-refresh:hover {
color: var(--text);
border-color: var(--accent);
}
.wp-refresh:disabled {
opacity: 0.5;
cursor: default;
}

파일 보기

@ -1,34 +0,0 @@
import type { Vessel, VesselTypeCode } from "../model/types";
export function isTrawl(code: VesselTypeCode) {
return code === "PT" || code === "PT-S";
}
export function filterVesselsForMap(vessels: readonly Vessel[], selectedType: VesselTypeCode | null) {
if (!selectedType) return vessels;
return vessels.filter((v) => {
if (v.code === selectedType) return true;
// PT and PT-S should be shown together
if (selectedType === "PT" && v.code === "PT-S") return true;
if (selectedType === "PT-S" && v.code === "PT") return true;
// FC interacts with trawl; show trawl when FC selected and FC when trawl selected
if (selectedType === "FC" && (v.code === "PT" || v.code === "PT-S")) return true;
if ((selectedType === "PT" || selectedType === "PT-S") && v.code === "FC") return true;
return false;
});
}
export function filterVesselsForList(vessels: readonly Vessel[], selectedType: VesselTypeCode | null) {
if (!selectedType) return vessels;
return vessels.filter((v) => {
if (v.code === selectedType) return true;
if (selectedType === "PT" && v.code === "PT-S") return true;
if (selectedType === "PT-S" && v.code === "PT") return true;
return false;
});
}

파일 보기

@ -1,278 +0,0 @@
import type { ZoneId } from "../../zone/model/meta";
import { haversineNm } from "../../../shared/lib/geo/haversineNm";
import { VESSEL_TYPES } from "./meta";
import type { FleetOwner, FleetState, TrawlPair, Vessel, VesselTypeCode } from "./types";
const SURNAMES = ["张", "王", "李", "刘", "陈", "杨", "黄", "赵", "周", "吴", "徐", "孙", "马", "朱", "胡", "郭", "林", "何", "高", "罗"];
const REGIONS = ["荣成", "石岛", "烟台", "威海", "日照", "青岛", "连云港", "舟山", "象山", "大连"];
const ZONE_BOUNDS: Record<ZoneId, { lat: [number, number]; lon: [number, number] }> = {
"1": { lon: [128.85, 131.70], lat: [36.16, 38.25] },
"2": { lon: [126.00, 128.90], lat: [32.18, 34.35] },
"3": { lon: [124.12, 126.06], lat: [33.13, 35.00] },
"4": { lon: [124.33, 125.85], lat: [35.00, 37.00] },
};
function rnd(a: number, b: number) {
return a + Math.random() * (b - a);
}
function pick<T>(arr: readonly T[]) {
return arr[Math.floor(Math.random() * arr.length)];
}
function randomPointInZone(zone: ZoneId) {
const b = ZONE_BOUNDS[zone];
// Small margin to avoid sitting exactly on the edge.
const lat = rnd(b.lat[0] + 0.05, b.lat[1] - 0.05);
const lon = rnd(b.lon[0] + 0.05, b.lon[1] - 0.05);
return { lat, lon };
}
function makePermit(id: number, suffix: "A" | "B") {
return `C21-${10000 + id}${suffix}`;
}
export function createMockFleetState(): FleetState {
const vessels: Vessel[] = [];
const owners: FleetOwner[] = [];
const ptPairs: TrawlPair[] = [];
let vid = 1;
// PT pairs: PT count == PT-S count, treat as "pair count".
for (let i = 0; i < VESSEL_TYPES.PT.count; i++) {
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}${pick(["渔业", "海产", "水产", "船务"])}${pick(["有限公司", "合作社", ""])}`;
const region = pick(REGIONS);
const zone = pick<ZoneId>(["2", "3"]);
const { lat, lon } = randomPointInZone(zone);
const isFishing = Math.random() < 0.55;
const sp = isFishing ? rnd(2.5, 4.5) : rnd(6, 11);
const crs = rnd(0, 360);
const pairDist = isFishing ? rnd(0.2, 1.2) : rnd(0, 0.3); // NM (rough)
const pairAngle = rnd(0, 360);
const lat2 = lat + (pairDist / 60) * Math.cos((pairAngle * Math.PI) / 180);
const lon2 = lon + ((pairDist / 60) * Math.sin((pairAngle * Math.PI) / 180)) / Math.cos((lat * Math.PI) / 180);
const permitBase = vid;
const ptId = vid++;
const ptsId = vid++;
const pt: Vessel = {
id: ptId,
permit: makePermit(permitBase, "A"),
code: "PT",
color: VESSEL_TYPES.PT.color,
lat,
lon,
speed: Number(sp.toFixed(1)),
course: Number(crs.toFixed(0)),
state: isFishing ? "조업중" : sp < 1 ? "정박" : "항해중",
zone,
isFishing,
owner,
region,
pairId: null,
pairDistNm: Number(pairDist.toFixed(2)),
nearVesselIds: [],
};
const pts: Vessel = {
id: ptsId,
permit: makePermit(permitBase, "B"),
code: "PT-S",
color: VESSEL_TYPES["PT-S"].color,
lat: Number(lat2.toFixed(4)),
lon: Number(lon2.toFixed(4)),
speed: Number((sp + rnd(-0.3, 0.3)).toFixed(1)),
course: Number((crs + rnd(-10, 10)).toFixed(0)),
state: isFishing ? "조업중" : sp < 1 ? "정박" : "항해중",
zone,
isFishing,
owner,
region,
pairId: null,
pairDistNm: pt.pairDistNm,
nearVesselIds: [],
};
pt.pairId = pts.id;
pts.pairId = pt.id;
vessels.push(pt, pts);
ptPairs.push({ mainId: pt.id, subId: pts.id, owner, region });
owners.push({ name: owner, region, vessels: [pt.id, pts.id], type: "trawl" });
}
// GN vessels
for (let i = 0; i < VESSEL_TYPES.GN.count; i++) {
const attachToOwner = Math.random() < 0.3 ? owners[Math.floor(Math.random() * owners.length)] : null;
const owner = attachToOwner ? attachToOwner.name : `${pick(SURNAMES)}${pick(SURNAMES)}${pick(["渔业", "水产"])}有限公司`;
const region = attachToOwner ? attachToOwner.region : pick(REGIONS);
const zone = pick<ZoneId>(["2", "3", "4"]);
const { lat, lon } = randomPointInZone(zone);
const isFishing = Math.random() < 0.5;
const sp = isFishing ? rnd(0.5, 2) : rnd(5, 10);
const id = vid++;
const v: Vessel = {
id,
permit: makePermit(id, "A"),
code: "GN",
color: VESSEL_TYPES.GN.color,
lat,
lon,
speed: Number(sp.toFixed(1)),
course: Number(rnd(0, 360).toFixed(0)),
state: isFishing ? pick(["표류", "투망", "양망"]) : sp < 1 ? "정박" : "항해중",
zone,
isFishing,
owner,
region,
pairId: null,
pairDistNm: null,
nearVesselIds: [],
};
vessels.push(v);
if (attachToOwner) attachToOwner.vessels.push(v.id);
else owners.push({ name: owner, region, vessels: [v.id], type: "gn" });
}
// OT
for (let i = 0; i < VESSEL_TYPES.OT.count; i++) {
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}远洋渔业`;
const region = pick(REGIONS);
const zone = pick<ZoneId>(["2", "3"]);
const { lat, lon } = randomPointInZone(zone);
const isFishing = Math.random() < 0.5;
const sp = isFishing ? rnd(2.5, 5) : rnd(5, 10);
const id = vid++;
const v: Vessel = {
id,
permit: makePermit(id, "A"),
code: "OT",
color: VESSEL_TYPES.OT.color,
lat,
lon,
speed: Number(sp.toFixed(1)),
course: Number(rnd(0, 360).toFixed(0)),
state: isFishing ? "조업중" : "항해중",
zone,
isFishing,
owner,
region,
pairId: null,
pairDistNm: null,
nearVesselIds: [],
};
vessels.push(v);
owners.push({ name: owner, region, vessels: [v.id], type: "ot" });
}
// PS
for (let i = 0; i < VESSEL_TYPES.PS.count; i++) {
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}水产`;
const region = pick(REGIONS);
const zone = pick<ZoneId>(["1", "2", "3", "4"]);
const { lat, lon } = randomPointInZone(zone);
const isFishing = Math.random() < 0.5;
const sp = isFishing ? rnd(0.3, 1.5) : rnd(5, 9);
const id = vid++;
const v: Vessel = {
id,
permit: makePermit(id, "A"),
code: "PS",
color: VESSEL_TYPES.PS.color,
lat,
lon,
speed: Number(sp.toFixed(1)),
course: Number(rnd(0, 360).toFixed(0)),
state: isFishing ? pick(["위망", "채낚기"]) : "항해중",
zone,
isFishing,
owner,
region,
pairId: null,
pairDistNm: null,
nearVesselIds: [],
};
vessels.push(v);
owners.push({ name: owner, region, vessels: [v.id], type: "ps" });
}
// FC — assigned to trawl owners (positioned near PT)
const trawlOwners = owners.filter((o) => o.type === "trawl");
for (let i = 0; i < VESSEL_TYPES.FC.count; i++) {
const oi = i < trawlOwners.length ? trawlOwners[i] : pick(trawlOwners);
const refId = oi.vessels.find((id) => vessels[id - 1]?.code === "PT") ?? oi.vessels[0];
const ref = vessels[refId - 1];
const zone = pick<ZoneId>(["2", "3"]);
const lat = ref.lat + rnd(-0.2, 0.2);
const lon = ref.lon + rnd(-0.2, 0.2);
const isNear = Math.random() < 0.4;
const sp = isNear ? rnd(0.5, 1.5) : rnd(5, 9);
const nearVesselIds = isNear ? oi.vessels.filter((id) => vessels[id - 1]?.code !== "FC").slice(0, 2) : [];
const v: Vessel = {
id: vid,
permit: makePermit(vid, "A"),
code: "FC",
color: VESSEL_TYPES.FC.color,
lat,
lon,
speed: Number(sp.toFixed(1)),
course: Number(rnd(0, 360).toFixed(0)),
state: isNear ? "환적" : "항해중",
zone,
isFishing: isNear, // kept from prototype: treat "환적" as fishing-like activity
owner: oi.name,
region: oi.region,
pairId: null,
pairDistNm: null,
nearVesselIds,
};
vid += 1;
vessels.push(v);
oi.vessels.push(v.id);
}
// Ensure initial pair distances are consistent with actual coordinates.
for (const p of ptPairs) {
const a = vessels[p.mainId - 1];
const b = vessels[p.subId - 1];
const d = haversineNm(a.lat, a.lon, b.lat, b.lon);
a.pairDistNm = d;
b.pairDistNm = d;
}
return { vessels, owners, ptPairs };
}
export function tickMockFleetState(state: FleetState) {
for (const v of state.vessels) {
v.lat += (0.5 - Math.random()) * 0.003;
v.lon += (0.5 - Math.random()) * 0.003;
v.speed = Math.max(0, Number((v.speed + (0.5 - Math.random()) * 0.4).toFixed(1)));
v.course = Number(((v.course + (0.5 - Math.random()) * 6 + 360) % 360).toFixed(0));
}
for (const p of state.ptPairs) {
const a = state.vessels[p.mainId - 1];
const b = state.vessels[p.subId - 1];
const d = haversineNm(a.lat, a.lon, b.lat, b.lon);
a.pairDistNm = d;
b.pairDistNm = d;
}
}
export function isVesselCode(code: string): code is VesselTypeCode {
return code === "PT" || code === "PT-S" || code === "GN" || code === "OT" || code === "PS" || code === "FC";
}

파일 보기

@ -0,0 +1,107 @@
import type { WeatherQueryPoint, WeatherPoint, WeatherSnapshot } from '../model/types';
const MARINE_BASE = 'https://marine-api.open-meteo.com/v1/marine';
const WEATHER_BASE = 'https://api.open-meteo.com/v1/forecast';
const MARINE_PARAMS = 'current=wave_height,wave_direction,wave_period,swell_wave_height,swell_wave_direction,sea_surface_temperature';
const WEATHER_PARAMS = 'current=wind_speed_10m,wind_direction_10m,wind_gusts_10m,temperature_2m,weather_code';
const TIMEOUT_MS = 10_000;
/* Open-Meteo 다중 좌표 응답 타입 */
interface MarineCurrentItem {
wave_height?: number;
wave_direction?: number;
wave_period?: number;
swell_wave_height?: number;
swell_wave_direction?: number;
sea_surface_temperature?: number;
}
interface WeatherCurrentItem {
wind_speed_10m?: number;
wind_direction_10m?: number;
wind_gusts_10m?: number;
temperature_2m?: number;
weather_code?: number;
}
/* 단일 좌표 응답 */
interface SingleMarineResponse {
current?: MarineCurrentItem;
}
/* 다중 좌표 응답 — 배열 */
type MarineResponse = SingleMarineResponse | SingleMarineResponse[];
interface SingleWeatherResponse {
current?: WeatherCurrentItem;
}
type WeatherResponse = SingleWeatherResponse | SingleWeatherResponse[];
function buildMultiCoordParams(points: WeatherQueryPoint[]): string {
const lats = points.map((p) => p.lat.toFixed(2)).join(',');
const lons = points.map((p) => p.lon.toFixed(2)).join(',');
return `latitude=${lats}&longitude=${lons}`;
}
function n(v: number | undefined): number | null {
return v != null && Number.isFinite(v) ? v : null;
}
/**
* Open-Meteo Marine + Weather API를 .
* centroid .
*/
export async function fetchWeatherForPoints(
points: WeatherQueryPoint[],
): Promise<WeatherSnapshot> {
if (points.length === 0) {
return { points: [], fetchedAt: Date.now() };
}
const coords = buildMultiCoordParams(points);
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
try {
const [marineRaw, weatherRaw] = await Promise.all([
fetch(`${MARINE_BASE}?${coords}&${MARINE_PARAMS}`, { signal: ac.signal })
.then((r) => r.json() as Promise<MarineResponse>),
fetch(`${WEATHER_BASE}?${coords}&${WEATHER_PARAMS}`, { signal: ac.signal })
.then((r) => r.json() as Promise<WeatherResponse>),
]);
// 단일 좌표면 배열이 아닌 단일 객체가 반환됨 → 통일
const marines: SingleMarineResponse[] = Array.isArray(marineRaw) ? marineRaw : [marineRaw];
const weathers: SingleWeatherResponse[] = Array.isArray(weatherRaw) ? weatherRaw : [weatherRaw];
const result: WeatherPoint[] = points.map((pt, i) => {
const mc = marines[i]?.current;
const wc = weathers[i]?.current;
return {
label: pt.label,
color: pt.color,
lat: pt.lat,
lon: pt.lon,
zoneId: pt.zoneId,
waveHeight: n(mc?.wave_height),
waveDirection: n(mc?.wave_direction),
wavePeriod: n(mc?.wave_period),
swellHeight: n(mc?.swell_wave_height),
swellDirection: n(mc?.swell_wave_direction),
seaSurfaceTemp: n(mc?.sea_surface_temperature),
windSpeed: n(wc?.wind_speed_10m),
windDirection: n(wc?.wind_direction_10m),
windGusts: n(wc?.wind_gusts_10m),
temperature: n(wc?.temperature_2m),
weatherCode: n(wc?.weather_code),
};
});
return { points: result, fetchedAt: Date.now() };
} finally {
clearTimeout(timer);
}
}

파일 보기

@ -0,0 +1,78 @@
/** 기상 데이터 유틸리티 */
const DIRECTION_LABELS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] as const;
const DIRECTION_ARROWS = ['↓', '↙', '←', '↖', '↑', '↗', '→', '↘'] as const;
/**
* 8 .
* "바람이 불어오는 방향" 0° = (N).
*/
export function getWindDirectionLabel(deg: number | null): string {
if (deg == null) return '-';
const idx = Math.round(((deg % 360) + 360) % 360 / 45) % 8;
return DIRECTION_LABELS[idx];
}
/**
* .
* ( ).
*/
export function getWindArrow(deg: number | null): string {
if (deg == null) return '';
const idx = Math.round(((deg % 360) + 360) % 360 / 45) % 8;
return DIRECTION_ARROWS[idx];
}
export type WaveSeverity = 'calm' | 'moderate' | 'rough' | 'severe';
/** 파고 등급 분류 */
export function getWaveSeverity(m: number | null): WaveSeverity {
if (m == null || m < 0.5) return 'calm';
if (m < 1.5) return 'moderate';
if (m < 2.5) return 'rough';
return 'severe';
}
/**
* WMO Weather Interpretation Code .
* https://open-meteo.com/en/docs 참조.
*/
export function getWeatherLabel(code: number | null): string {
if (code == null) return '-';
if (code === 0) return '맑음';
if (code <= 3) return ['약간 흐림', '흐림', '매우 흐림'][code - 1];
if (code <= 49) return '안개';
if (code <= 59) return '이슬비';
if (code <= 69) return '비';
if (code <= 79) return '눈';
if (code <= 84) return '소나기';
if (code <= 94) return '뇌우';
if (code <= 99) return '뇌우(우박)';
return '-';
}
/**
* MultiPolygon centroid .
* GeoJSON MultiPolygon: number[][][][]
*/
export function computeMultiPolygonCentroid(
coordinates: number[][][][],
): [number, number] {
let sumLon = 0;
let sumLat = 0;
let count = 0;
for (const polygon of coordinates) {
// 외곽 링만 사용 (polygon[0])
const ring = polygon[0];
if (!ring) continue;
for (const coord of ring) {
sumLon += coord[0];
sumLat += coord[1];
count++;
}
}
if (count === 0) return [0, 0];
return [sumLon / count, sumLat / count];
}

파일 보기

@ -0,0 +1,40 @@
import type { ZoneId } from '../../zone/model/meta';
/** 기상 조회 대상 지점 (입력용) */
export interface WeatherQueryPoint {
label: string;
color: string;
lat: number;
lon: number;
zoneId?: ZoneId;
}
/** 단일 지점의 기상 데이터 */
export interface WeatherPoint {
label: string;
color: string;
lat: number;
lon: number;
zoneId?: ZoneId;
/* Marine */
waveHeight: number | null;
waveDirection: number | null;
wavePeriod: number | null;
swellHeight: number | null;
swellDirection: number | null;
seaSurfaceTemp: number | null;
/* Weather */
windSpeed: number | null;
windDirection: number | null;
windGusts: number | null;
temperature: number | null;
weatherCode: number | null;
}
/** 기상 스냅샷 (전체 지점 묶음) */
export interface WeatherSnapshot {
points: WeatherPoint[];
fetchedAt: number;
}

파일 보기

@ -0,0 +1,216 @@
/**
* / (DEV) .
*
* 12 :
* - (pair link, ~1 NM)
* - (pair_separation alarm, ~8 NM)
* - (fleet circle, 5 )
* - (transshipment alarm, FC PS < 0.5 NM)
* - AIS (ais_stale alarm, 2 )
* - (zone_violation alarm, PT가 zone 4 )
* - closed_season은 () (2 )
*
* :
* DashboardPage에서 isDevMode일 targetsInScope + legacyHits에 .
* computePairLinks / computeFcLinks / computeFleetCircles /
* computeLegacyAlarms / .
*/
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
// ── 타임스탬프 ──────────────────────────────────────────────
const FRESH_TS = new Date().toISOString();
const STALE_TS = new Date(Date.now() - 120 * 60_000).toISOString(); // 2시간 전 → ais_stale
// ── 팩토리 ──────────────────────────────────────────────────
function makeAis(o: Partial<AisTarget> & Pick<AisTarget, 'mmsi' | 'lat' | 'lon'>): AisTarget {
return {
imo: 0,
name: '',
callsign: '',
vesselType: '',
heading: o.cog ?? 0,
sog: 0,
cog: 0,
rot: 0,
length: 30,
width: 8,
draught: 4,
destination: '',
eta: '',
status: 'underway',
messageTimestamp: FRESH_TS,
receivedDate: FRESH_TS,
source: 'mock',
classType: 'A',
...o,
};
}
function makeLegacy(
o: Partial<LegacyVesselInfo> & Pick<LegacyVesselInfo, 'permitNo' | 'shipCode' | 'mmsiList'>,
): LegacyVesselInfo {
return {
shipNameRoman: '',
shipNameCn: null,
ton: 100,
callSign: '',
workSeaArea: '서해',
workTerm1: '2025-01-01',
workTerm2: '2025-12-31',
quota: '',
ownerCn: null,
ownerRoman: null,
pairPermitNo: null,
pairShipNameCn: null,
checklistSheet: null,
sources: { permittedList: true, checklist: false, fleet906: false },
...o,
};
}
// ── 선박 정의 (12척) ────────────────────────────────────────
/*
* Group 1 ( ~1 NM, )
* 위치: 서해남부(zone 3) 125.3°E 34.0°N
*/
const PT_01_AIS = makeAis({ mmsi: 990001, lat: 34.00, lon: 125.30, sog: 3.3, cog: 45, name: 'MOCK VESSEL 1' });
const PT_02_AIS = makeAis({ mmsi: 990002, lat: 34.01, lon: 125.32, sog: 3.3, cog: 45, name: 'MOCK VESSEL 2' });
const PT_01_LEG = makeLegacy({
permitNo: 'MOCK-P001', shipCode: 'PT', mmsiList: [990001],
shipNameRoman: 'MOCK VESSEL 1', shipNameCn: '模拟渔船一号',
pairPermitNo: 'MOCK-P002', pairShipNameCn: '模拟渔船二号',
ownerRoman: 'MOCK Owner A', ownerCn: '模拟A渔业',
});
const PT_02_LEG = makeLegacy({
permitNo: 'MOCK-P002', shipCode: 'PT-S', mmsiList: [990002],
shipNameRoman: 'MOCK VESSEL 2', shipNameCn: '模拟渔船二号',
pairPermitNo: 'MOCK-P001', pairShipNameCn: '模拟渔船一号',
ownerRoman: 'MOCK Owner A', ownerCn: '模拟A渔业',
});
/*
* Group 2 ( ~8 NM pair_separation alarm)
* 위치: 서해남부(zone 3) 125.0°E 34.5°N
*/
const PT_03_AIS = makeAis({ mmsi: 990003, lat: 34.50, lon: 125.00, sog: 3.5, cog: 90, name: 'MOCK VESSEL 3' });
const PT_04_AIS = makeAis({ mmsi: 990004, lat: 34.60, lon: 125.12, sog: 3.5, cog: 90, name: 'MOCK VESSEL 4' });
const PT_03_LEG = makeLegacy({
permitNo: 'MOCK-P003', shipCode: 'PT', mmsiList: [990003],
shipNameRoman: 'MOCK VESSEL 3', shipNameCn: '模拟渔船三号',
pairPermitNo: 'MOCK-P004', pairShipNameCn: '模拟渔船四号',
ownerRoman: 'MOCK Owner B', ownerCn: '模拟B渔业',
});
const PT_04_LEG = makeLegacy({
permitNo: 'MOCK-P004', shipCode: 'PT-S', mmsiList: [990004],
shipNameRoman: 'MOCK VESSEL 4', shipNameCn: '模拟渔船四号',
pairPermitNo: 'MOCK-P003', pairShipNameCn: '模拟渔船三号',
ownerRoman: 'MOCK Owner B', ownerCn: '模拟B渔业',
});
/*
* Group 3 ( 5 fleet circle)
* 위치: 서해중간(zone 4) 124.8°E 35.2°N
* #11(GN) AIS 2 ais_stale alarm
*/
const GN_01_AIS = makeAis({ mmsi: 990005, lat: 35.20, lon: 124.80, sog: 1.0, cog: 180, name: 'MOCK VESSEL 5' });
const GN_02_AIS = makeAis({ mmsi: 990006, lat: 35.22, lon: 124.85, sog: 1.2, cog: 170, name: 'MOCK VESSEL 6' });
const GN_03_AIS = makeAis({ mmsi: 990007, lat: 35.18, lon: 124.82, sog: 0.8, cog: 200, name: 'MOCK VESSEL 7' });
const OT_01_AIS = makeAis({ mmsi: 990008, lat: 35.25, lon: 124.78, sog: 3.5, cog: 160, name: 'MOCK VESSEL 8' });
const GN_04_AIS = makeAis({
mmsi: 990011, lat: 35.00, lon: 125.20, sog: 1.5, cog: 190, name: 'MOCK VESSEL 10',
messageTimestamp: STALE_TS, receivedDate: STALE_TS,
});
const GN_01_LEG = makeLegacy({
permitNo: 'MOCK-P005', shipCode: 'GN', mmsiList: [990005],
shipNameRoman: 'MOCK VESSEL 5', shipNameCn: '模拟渔船五号',
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
});
const GN_02_LEG = makeLegacy({
permitNo: 'MOCK-P006', shipCode: 'GN', mmsiList: [990006],
shipNameRoman: 'MOCK VESSEL 6', shipNameCn: '模拟渔船六号',
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
});
const GN_03_LEG = makeLegacy({
permitNo: 'MOCK-P007', shipCode: 'GN', mmsiList: [990007],
shipNameRoman: 'MOCK VESSEL 7', shipNameCn: '模拟渔船七号',
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
});
const OT_01_LEG = makeLegacy({
permitNo: 'MOCK-P008', shipCode: 'OT', mmsiList: [990008],
shipNameRoman: 'MOCK VESSEL 8', shipNameCn: '模拟渔船八号',
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
});
const GN_04_LEG = makeLegacy({
permitNo: 'MOCK-P011', shipCode: 'GN', mmsiList: [990011],
shipNameRoman: 'MOCK VESSEL 10', shipNameCn: '模拟渔船十号',
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
});
/*
* Group 4 (FC PS ~0.15 NM transshipment alarm)
* 위치: 서해남부(zone 3) 125.5°E 34.3°N
*/
const FC_01_AIS = makeAis({ mmsi: 990009, lat: 34.30, lon: 125.50, sog: 1.0, cog: 0, name: 'MOCK CARRIER 1' });
const PS_01_AIS = makeAis({ mmsi: 990010, lat: 34.302, lon: 125.502, sog: 0.5, cog: 10, name: 'MOCK VESSEL 9' });
const FC_01_LEG = makeLegacy({
permitNo: 'MOCK-P009', shipCode: 'FC', mmsiList: [990009],
shipNameRoman: 'MOCK CARRIER 1', shipNameCn: '模拟运船一号',
ownerRoman: 'MOCK Owner D', ownerCn: '模拟D渔业',
});
const PS_01_LEG = makeLegacy({
permitNo: 'MOCK-P010', shipCode: 'PS', mmsiList: [990010],
shipNameRoman: 'MOCK VESSEL 9', shipNameCn: '模拟渔船九号',
ownerRoman: 'MOCK Owner E', ownerCn: '模拟E渔业',
});
/*
* Group 5 (PT가 zone 4 zone_violation alarm)
* PT는 zone 2,3 . zone 4() .
* 위치: 서해중간(zone 4) 125.0°E 36.5°N
*/
const PT_05_AIS = makeAis({ mmsi: 990012, lat: 36.50, lon: 125.00, sog: 3.3, cog: 270, name: 'MOCK VESSEL 11' });
const PT_05_LEG = makeLegacy({
permitNo: 'MOCK-P012', shipCode: 'PT', mmsiList: [990012],
shipNameRoman: 'MOCK VESSEL 11', shipNameCn: '模拟渔船十一号',
ownerRoman: 'MOCK Owner F', ownerCn: '模拟F渔业',
});
// ── 공개 API ────────────────────────────────────────────────
/** 더미 AIS 타겟 (12척) */
export const MOCK_AIS_TARGETS: AisTarget[] = [
PT_01_AIS, PT_02_AIS, // Group 1: 정상 쌍끌이
PT_03_AIS, PT_04_AIS, // Group 2: 이격 쌍끌이
GN_01_AIS, GN_02_AIS, GN_03_AIS, OT_01_AIS, GN_04_AIS, // Group 3: 선단 + AIS 지연
FC_01_AIS, PS_01_AIS, // Group 4: 환적 의심
PT_05_AIS, // Group 5: 수역 이탈
];
/** 더미 legacy 매칭 엔트리 (MMSI → LegacyVesselInfo) */
export const MOCK_LEGACY_ENTRIES: [number, LegacyVesselInfo][] = [
[990001, PT_01_LEG],
[990002, PT_02_LEG],
[990003, PT_03_LEG],
[990004, PT_04_LEG],
[990005, GN_01_LEG],
[990006, GN_02_LEG],
[990007, GN_03_LEG],
[990008, OT_01_LEG],
[990009, FC_01_LEG],
[990010, PS_01_LEG],
[990011, GN_04_LEG],
[990012, PT_05_LEG],
];
/** 더미 MMSI 집합 — 필터링/하이라이팅에 활용 */
export const MOCK_MMSI_SET = new Set(MOCK_AIS_TARGETS.map((t) => t.mmsi));

파일 보기

@ -88,3 +88,21 @@ export const LEGACY_ALARM_KIND_LABEL: Record<LegacyAlarmKind, string> = {
ais_stale: "AIS 지연",
zone_violation: "수역 이탈",
};
/** 경고 우선순위 (낮→높). 배열 뒤가 높은 우선순위. */
export const ALARM_KIND_PRIORITY: LegacyAlarmKind[] = [
"ais_stale",
"closed_season",
"transshipment",
"zone_violation",
"pair_separation",
];
/** 경고 배지 — 지도 위 선박 옆에 표시할 약어 + 색상 */
export const ALARM_BADGE: Record<LegacyAlarmKind, { label: string; color: string; rgba: [number, number, number, number] }> = {
pair_separation: { label: "이", color: "#ef4444", rgba: [239, 68, 68, 200] },
zone_violation: { label: "수", color: "#a855f7", rgba: [168, 85, 247, 200] },
transshipment: { label: "환", color: "#f97316", rgba: [249, 115, 22, 200] },
closed_season: { label: "휴", color: "#eab308", rgba: [234, 179, 8, 200] },
ais_stale: { label: "A", color: "#6b7280", rgba: [107, 114, 128, 200] },
};

파일 보기

@ -0,0 +1,240 @@
import type { LiveShipFeature, ViewportBounds } from '../model/liveShip.types';
interface RenderConfig {
defaultMinInterval: number;
maxInterval: number;
targetRenderTime: number;
maxRenderTime: number;
}
interface DensityConfig {
maxZoom: number;
maxPerCell: number;
gridSizeMultiplier: number;
}
type RenderCallback = (ships: LiveShipFeature[], trigger: number) => void;
const RENDER_CONFIG: RenderConfig = {
defaultMinInterval: 1000,
maxInterval: 5000,
targetRenderTime: 100,
maxRenderTime: 500,
};
const ZOOM_MIN_INTERVAL: Record<number, number> = {
7: 4000,
8: 3500,
9: 3000,
10: 2500,
11: 2000,
12: 1500,
13: 1500,
14: 1000,
};
const DENSITY_LIMITS: DensityConfig[] = [
{ maxZoom: 5, maxPerCell: 20, gridSizeMultiplier: 120 },
{ maxZoom: 6, maxPerCell: 25, gridSizeMultiplier: 100 },
{ maxZoom: 7, maxPerCell: 33, gridSizeMultiplier: 80 },
{ maxZoom: 8, maxPerCell: 35, gridSizeMultiplier: 75 },
{ maxZoom: 9, maxPerCell: 38, gridSizeMultiplier: 70 },
{ maxZoom: 10, maxPerCell: 40, gridSizeMultiplier: 55 },
{ maxZoom: 11, maxPerCell: 43, gridSizeMultiplier: 40 },
{ maxZoom: Number.POSITIVE_INFINITY, maxPerCell: Number.POSITIVE_INFINITY, gridSizeMultiplier: 30 },
];
const SHIP_KIND_PRIORITY: Record<string, number> = {
'000021': 2,
'000025': 3,
'000022': 4,
'000024': 6,
'000023': 7,
'000020': 8,
'000027': 9,
'000028': 10,
};
function getShipPriority(ship: LiveShipFeature): number {
return SHIP_KIND_PRIORITY[ship.signalKindCode] ?? 11;
}
function getDensityConfig(zoomLevel: number): DensityConfig {
for (const config of DENSITY_LIMITS) {
if (zoomLevel <= config.maxZoom) return config;
}
return DENSITY_LIMITS[DENSITY_LIMITS.length - 1];
}
function getMinIntervalByZoom(zoom: number): number {
const zoomInt = Math.floor(zoom);
if (zoomInt <= 7) return ZOOM_MIN_INTERVAL[7];
if (zoomInt >= 14) return ZOOM_MIN_INTERVAL[14];
return ZOOM_MIN_INTERVAL[zoomInt] || RENDER_CONFIG.defaultMinInterval;
}
function isInViewport(ship: LiveShipFeature, bounds: ViewportBounds): boolean {
if (ship.latitude < bounds.minLat || ship.latitude > bounds.maxLat) return false;
if (bounds.minLon <= bounds.maxLon) {
return ship.longitude >= bounds.minLon && ship.longitude <= bounds.maxLon;
}
return ship.longitude >= bounds.minLon || ship.longitude <= bounds.maxLon;
}
function applyDensityLimit(ships: LiveShipFeature[], zoomLevel: number): LiveShipFeature[] {
const config = getDensityConfig(zoomLevel);
if (config.maxPerCell === Number.POSITIVE_INFINITY) return ships;
const sorted = [...ships].sort((a, b) => getShipPriority(a) - getShipPriority(b));
const gridSize = Math.pow(2, -zoomLevel) * config.gridSizeMultiplier;
const gridCounts = new Map<string, number>();
const result: LiveShipFeature[] = [];
for (const ship of sorted) {
const gridX = Math.floor(ship.longitude / gridSize);
const gridY = Math.floor(ship.latitude / gridSize);
const key = `${gridX},${gridY}`;
const count = gridCounts.get(key) || 0;
if (count < config.maxPerCell) {
result.push(ship);
gridCounts.set(key, count + 1);
}
}
return result.reverse();
}
class ShipBatchRenderer {
private data: LiveShipFeature[] = [];
private callback: RenderCallback | null = null;
private viewportBounds: ViewportBounds | null = null;
private currentZoom = 10;
private pendingRender = false;
private isRendering = false;
private animationHandle: ReturnType<typeof setTimeout> | number | null = null;
private lastRenderTime = 0;
private currentInterval = RENDER_CONFIG.defaultMinInterval;
private renderTrigger = 0;
private lastRenderedShips: LiveShipFeature[] = [];
initialize(callback: RenderCallback): void {
this.callback = callback;
}
setData(data: LiveShipFeature[]): void {
this.data = data;
this.requestRender();
}
setViewportBounds(bounds: ViewportBounds | null): void {
this.viewportBounds = bounds;
}
setZoom(zoom: number): boolean {
const prevInt = Math.floor(this.currentZoom);
const nextInt = Math.floor(zoom);
this.currentZoom = zoom;
const nextMin = getMinIntervalByZoom(zoom);
if (nextMin > this.currentInterval) {
this.currentInterval = nextMin;
}
return prevInt !== nextInt;
}
requestRender(): void {
this.pendingRender = true;
if (this.animationHandle != null) return;
this.scheduleRender();
}
immediateRender(): void {
if (this.animationHandle != null) {
clearTimeout(this.animationHandle as ReturnType<typeof setTimeout>);
cancelAnimationFrame(this.animationHandle as number);
this.animationHandle = null;
}
this.pendingRender = false;
requestAnimationFrame(() => this.executeRender());
}
getRenderedShips(): LiveShipFeature[] {
return this.lastRenderedShips;
}
dispose(): void {
if (this.animationHandle != null) {
clearTimeout(this.animationHandle as ReturnType<typeof setTimeout>);
cancelAnimationFrame(this.animationHandle as number);
this.animationHandle = null;
}
this.pendingRender = false;
this.callback = null;
this.lastRenderedShips = [];
}
private scheduleRender(): void {
const elapsed = Date.now() - this.lastRenderTime;
const delay = Math.max(0, this.currentInterval - elapsed);
this.animationHandle = setTimeout(() => {
this.animationHandle = requestAnimationFrame(() => this.executeRender());
}, delay);
}
private executeRender(): void {
if (this.isRendering) {
this.animationHandle = null;
return;
}
if (!this.callback) {
this.animationHandle = null;
return;
}
const startTime = performance.now();
this.isRendering = true;
try {
let ships = this.data;
if (this.viewportBounds) {
ships = ships.filter((ship) => isInViewport(ship, this.viewportBounds as ViewportBounds));
}
const densityLimited = applyDensityLimit(ships, this.currentZoom);
this.renderTrigger += 1;
this.lastRenderedShips = densityLimited;
this.callback(densityLimited, this.renderTrigger);
const renderTime = performance.now() - startTime;
this.adjustRenderInterval(renderTime);
} finally {
this.isRendering = false;
this.lastRenderTime = Date.now();
this.animationHandle = null;
if (this.pendingRender) {
this.pendingRender = false;
this.scheduleRender();
}
}
}
private adjustRenderInterval(renderTime: number): void {
const minInterval = getMinIntervalByZoom(this.currentZoom);
if (renderTime > RENDER_CONFIG.maxRenderTime) {
this.currentInterval = Math.min(this.currentInterval * 1.2, RENDER_CONFIG.maxInterval);
return;
}
if (renderTime < RENDER_CONFIG.targetRenderTime) {
this.currentInterval = Math.max(this.currentInterval * 0.9, minInterval);
}
}
}
export { applyDensityLimit, getDensityConfig, getMinIntervalByZoom };
export type { RenderCallback };
export default ShipBatchRenderer;

파일 보기

@ -0,0 +1,11 @@
import { useMemo } from 'react';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { toLiveShipFeatures } from '../lib/adapters';
export function useLiveShipAdapter(
targets: AisTarget[],
legacyHits?: Map<number, LegacyVesselInfo> | null,
) {
return useMemo(() => toLiveShipFeatures(targets, legacyHits), [targets, legacyHits]);
}

파일 보기

@ -0,0 +1,88 @@
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import ShipBatchRenderer from '../core/ShipBatchRenderer';
import type { LiveShipFeature, ViewportBounds } from '../model/liveShip.types';
interface UseLiveShipBatchRenderResult {
renderedFeatures: LiveShipFeature[];
renderedTargets: AisTarget[];
renderedMmsiSet: Set<number>;
}
function getMapBounds(map: maplibregl.Map): ViewportBounds {
const bounds = map.getBounds();
return {
minLon: bounds.getWest(),
maxLon: bounds.getEast(),
minLat: bounds.getSouth(),
maxLat: bounds.getNorth(),
};
}
export function useLiveShipBatchRender(
mapRef: MutableRefObject<maplibregl.Map | null>,
features: LiveShipFeature[],
sourceTargets: AisTarget[],
mapSyncEpoch: number,
): UseLiveShipBatchRenderResult {
const rendererRef = useRef<ShipBatchRenderer | null>(null);
const [renderedFeatures, setRenderedFeatures] = useState<LiveShipFeature[]>([]);
useEffect(() => {
const renderer = new ShipBatchRenderer();
renderer.initialize((ships) => {
setRenderedFeatures(ships);
});
rendererRef.current = renderer;
return () => {
renderer.dispose();
rendererRef.current = null;
};
}, []);
useEffect(() => {
const renderer = rendererRef.current;
if (!renderer) return;
renderer.setData(features);
renderer.immediateRender();
}, [features]);
useEffect(() => {
const map = mapRef.current;
const renderer = rendererRef.current;
if (!map || !renderer) return;
const sync = () => {
if (!rendererRef.current) return;
renderer.setZoom(map.getZoom());
renderer.setViewportBounds(getMapBounds(map));
renderer.requestRender();
};
sync();
map.on('moveend', sync);
map.on('zoomend', sync);
return () => {
map.off('moveend', sync);
map.off('zoomend', sync);
};
}, [mapRef, mapSyncEpoch]);
const renderedMmsiSet = useMemo(() => {
const next = new Set<number>();
for (const feature of renderedFeatures) {
next.add(feature.mmsi);
}
return next;
}, [renderedFeatures]);
const renderedTargets = useMemo(() => {
if (renderedMmsiSet.size === 0) return [];
return sourceTargets.filter((target) => renderedMmsiSet.has(target.mmsi));
}, [sourceTargets, renderedMmsiSet]);
return { renderedFeatures, renderedTargets, renderedMmsiSet };
}

파일 보기

@ -0,0 +1,63 @@
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { SIGNAL_KIND, SIGNAL_SOURCE_AIS, type LiveShipFeature, type SignalKindCode } from '../model/liveShip.types';
function mapVesselTypeToSignalKind(vesselType: string | undefined): SignalKindCode {
if (!vesselType) return SIGNAL_KIND.NORMAL;
const vt = vesselType.toLowerCase();
if (vt.includes('fishing')) return SIGNAL_KIND.FISHING;
if (vt.includes('passenger')) return SIGNAL_KIND.PASSENGER;
if (vt.includes('cargo')) return SIGNAL_KIND.CARGO;
if (vt.includes('tanker')) return SIGNAL_KIND.TANKER;
if (vt.includes('military') || vt.includes('law') || vt.includes('government')) return SIGNAL_KIND.GOV;
if (vt.includes('buoy')) return SIGNAL_KIND.BUOY;
return SIGNAL_KIND.NORMAL;
}
function mapLegacyShipCodeToSignalKind(shipCode: string | undefined): SignalKindCode {
if (!shipCode) return SIGNAL_KIND.NORMAL;
if (shipCode === 'FC') return SIGNAL_KIND.CARGO;
return SIGNAL_KIND.FISHING;
}
export function toLiveShipFeature(target: AisTarget, legacy: LegacyVesselInfo | undefined | null): LiveShipFeature {
const targetId = String(target.mmsi);
const signalKindCode = legacy
? mapLegacyShipCodeToSignalKind(legacy.shipCode)
: mapVesselTypeToSignalKind(target.vesselType);
return {
mmsi: target.mmsi,
featureId: `${SIGNAL_SOURCE_AIS}${targetId}`,
targetId,
originalTargetId: targetId,
signalSourceCode: SIGNAL_SOURCE_AIS,
signalKindCode,
shipName: (target.name || '').trim(),
longitude: target.lon,
latitude: target.lat,
sog: Number.isFinite(target.sog) ? target.sog : 0,
cog: Number.isFinite(target.cog) ? target.cog : 0,
heading: Number.isFinite(target.heading) ? target.heading : 0,
messageTimestamp: target.messageTimestamp || target.receivedDate || new Date().toISOString(),
nationalCode: legacy ? 'CN' : '',
vesselType: target.vesselType,
raw: target,
};
}
export function toLiveShipFeatures(
targets: AisTarget[],
legacyHits?: Map<number, LegacyVesselInfo> | null,
): LiveShipFeature[] {
const out: LiveShipFeature[] = [];
for (const target of targets) {
if (!target) continue;
if (!Number.isFinite(target.mmsi)) continue;
if (!Number.isFinite(target.lon) || !Number.isFinite(target.lat)) continue;
out.push(toLiveShipFeature(target, legacyHits?.get(target.mmsi) ?? null));
}
return out;
}

파일 보기

@ -0,0 +1,42 @@
import type { AisTarget } from '../../../entities/aisTarget/model/types';
export const SIGNAL_SOURCE_AIS = '000001';
export const SIGNAL_KIND = {
FISHING: '000020',
KCGV: '000021',
PASSENGER: '000022',
CARGO: '000023',
TANKER: '000024',
GOV: '000025',
NORMAL: '000027',
BUOY: '000028',
} as const;
export type SignalKindCode = (typeof SIGNAL_KIND)[keyof typeof SIGNAL_KIND] | string;
export interface LiveShipFeature {
mmsi: number;
featureId: string;
targetId: string;
originalTargetId: string;
signalSourceCode: string;
signalKindCode: SignalKindCode;
shipName: string;
longitude: number;
latitude: number;
sog: number;
cog: number;
heading: number;
messageTimestamp: string;
nationalCode: string;
vesselType?: string;
raw?: AisTarget;
}
export interface ViewportBounds {
minLon: number;
maxLon: number;
minLat: number;
maxLat: number;
}

파일 보기

@ -1,3 +1,4 @@
import { ToggleButton } from '@wing/ui';
import type { Map3DSettings } from "../../widgets/map3d/Map3D";
type Props = {
@ -13,11 +14,11 @@ export function Map3DSettingsToggles({ value, onToggle }: Props) {
];
return (
<div className="tog">
<div className="flex flex-wrap gap-0.75 mb-1.5">
{items.map((t) => (
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
<ToggleButton key={t.id} on={value[t.id]} onClick={() => onToggle(t.id)}>
{t.label}
</div>
</ToggleButton>
))}
</div>
);

파일 보기

@ -146,8 +146,7 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span
className={`tog-btn${autoGradient ? ' on' : ''}`}
style={{ fontSize: 8, padding: '1px 5px', marginLeft: 8 }}
className={`ml-2 cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
onClick={toggleAutoGradient}
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
>
@ -176,11 +175,11 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
{/* ── Depth font size ───────────────────────────── */}
<div className="ms-section">
<div className="ms-label"> </div>
<div className="tog" style={{ gap: 3 }}>
<div className="flex flex-wrap gap-0.75">
{FONT_SIZES.map((fs) => (
<div
key={fs.value}
className={`tog-btn${value.depthFontSize === fs.value ? ' on' : ''}`}
className={`cursor-pointer rounded border px-1.5 py-0.5 text-[8px] transition-all duration-150 select-none ${value.depthFontSize === fs.value ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
onClick={() => update('depthFontSize', fs.value)}
>
{fs.label}

파일 보기

@ -1,3 +1,5 @@
import { ToggleButton } from '@wing/ui';
export type MapToggleState = {
pairLines: boolean;
pairRange: boolean;
@ -27,11 +29,16 @@ export function MapToggles({ value, onToggle }: Props) {
];
return (
<div className="tog tog-map">
<div className="flex flex-wrap gap-1">
{items.map((t) => (
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
<ToggleButton
key={t.id}
on={value[t.id]}
onClick={() => onToggle(t.id)}
className="flex-[1_1_calc(25%-4px)] overflow-hidden text-center text-ellipsis whitespace-nowrap"
>
{t.label}
</div>
</ToggleButton>
))}
</div>
);

파일 보기

@ -0,0 +1,125 @@
import { useMemo } from 'react';
import { getCurrentPositions } from '../lib/interpolate';
import { createReplayTrailLayer } from '../layers/replayLayers';
import { createDynamicTrackLayers, createStaticTrackLayers } from '../layers/trackLayers';
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
import { useTrackPlaybackStore } from '../stores/trackPlaybackStore';
import { useTrackQueryStore } from '../stores/trackQueryStore';
export interface TrackReplayDeckRenderState {
trackReplayDeckLayers: unknown[];
enabledTracks: ProcessedTrack[];
currentPositions: CurrentVesselPosition[];
showPoints: boolean;
showVirtualShip: boolean;
showLabels: boolean;
renderEpoch: number;
}
export function useTrackReplayDeckLayers(): TrackReplayDeckRenderState {
const tracks = useTrackQueryStore((state) => state.tracks);
const disabledVesselIds = useTrackQueryStore((state) => state.disabledVesselIds);
const highlightedVesselId = useTrackQueryStore((state) => state.highlightedVesselId);
const setHighlightedVesselId = useTrackQueryStore((state) => state.setHighlightedVesselId);
const showPoints = useTrackQueryStore((state) => state.showPoints);
const showVirtualShip = useTrackQueryStore((state) => state.showVirtualShip);
const showLabels = useTrackQueryStore((state) => state.showLabels);
const showTrail = useTrackQueryStore((state) => state.showTrail);
const renderEpoch = useTrackQueryStore((state) => state.renderEpoch);
const isPlaying = useTrackPlaybackStore((state) => state.isPlaying);
const currentTime = useTrackPlaybackStore((state) => state.currentTime);
const playbackRenderTime = useMemo(() => {
if (!isPlaying) return currentTime;
// Throttle to ~10fps while playing to reduce relayout pressure.
return Math.floor(currentTime / 100) * 100;
}, [isPlaying, currentTime]);
const enabledTracks = useMemo(() => {
if (!tracks.length) return [];
if (disabledVesselIds.size === 0) return tracks;
return tracks.filter((track) => !disabledVesselIds.has(track.vesselId));
}, [tracks, disabledVesselIds]);
const currentPositions = useMemo(() => {
void renderEpoch;
if (enabledTracks.length === 0) return [];
const sampled = getCurrentPositions(enabledTracks, playbackRenderTime);
if (sampled.length > 0 || isPlaying) return sampled;
// Ensure an immediate first-frame marker when query data arrives but
// playback has not started yet (globe static-render case).
return enabledTracks.flatMap((track) => {
if (track.geometry.length === 0) return [];
const firstTs = track.timestampsMs[0] ?? playbackRenderTime;
return [
{
vesselId: track.vesselId,
targetId: track.targetId,
sigSrcCd: track.sigSrcCd,
shipName: track.shipName,
shipKindCode: track.shipKindCode,
nationalCode: track.nationalCode,
position: track.geometry[0],
heading: 0,
speed: track.speeds[0] ?? 0,
timestamp: firstTs,
} as CurrentVesselPosition,
];
});
}, [enabledTracks, playbackRenderTime, isPlaying, renderEpoch]);
const staticLayers = useMemo(
() => {
void renderEpoch;
return createStaticTrackLayers({
tracks: enabledTracks,
showPoints,
highlightedVesselId,
onPathHover: setHighlightedVesselId,
});
},
[enabledTracks, showPoints, highlightedVesselId, setHighlightedVesselId, renderEpoch],
);
const dynamicLayers = useMemo(
() => {
void renderEpoch;
return createDynamicTrackLayers({
currentPositions,
showVirtualShip,
showLabels,
onPathHover: setHighlightedVesselId,
});
},
[currentPositions, showVirtualShip, showLabels, setHighlightedVesselId, renderEpoch],
);
const trailLayer = useMemo(
() => {
void renderEpoch;
return createReplayTrailLayer({
tracks: enabledTracks,
currentTime: playbackRenderTime,
showTrail,
});
},
[enabledTracks, playbackRenderTime, showTrail, renderEpoch],
);
const trackReplayDeckLayers = useMemo(
() => [...staticLayers, ...(trailLayer ? [trailLayer] : []), ...dynamicLayers],
[staticLayers, dynamicLayers, trailLayer],
);
return {
trackReplayDeckLayers,
enabledTracks,
currentPositions,
showPoints,
showVirtualShip,
showLabels,
renderEpoch,
};
}

파일 보기

@ -0,0 +1,60 @@
import { TripsLayer } from '@deck.gl/geo-layers';
import type { Layer } from '@deck.gl/core';
import type { ProcessedTrack } from '../model/track.types';
import { getShipKindColor } from '../lib/adapters';
import { TRACK_REPLAY_LAYER_IDS } from './trackLayers';
import { DEPTH_DISABLED_PARAMS } from '../../../shared/lib/map/mapConstants';
interface ReplayTrip {
vesselId: string;
path: [number, number][];
timestamps: number[];
color: [number, number, number, number];
}
function toReplayTrips(tracks: ProcessedTrack[]): ReplayTrip[] {
const out: ReplayTrip[] = [];
for (const track of tracks) {
if (!track.geometry.length || !track.timestampsMs.length) continue;
const baseTime = track.timestampsMs[0];
out.push({
vesselId: track.vesselId,
path: track.geometry,
timestamps: track.timestampsMs.map((ts) => ts - baseTime),
color: getShipKindColor(track.shipKindCode),
});
}
return out;
}
export function createReplayTrailLayer(options: {
tracks: ProcessedTrack[];
currentTime: number;
showTrail: boolean;
}): Layer | null {
const { tracks, currentTime, showTrail } = options;
if (!showTrail || tracks.length === 0) return null;
const trips = toReplayTrips(tracks);
if (trips.length === 0) return null;
const minBaseTime = Math.min(...tracks.map((track) => track.timestampsMs[0] || 0));
const relativeCurrentTime = Math.max(0, currentTime - minBaseTime);
return new TripsLayer<ReplayTrip>({
id: TRACK_REPLAY_LAYER_IDS.TRAIL,
data: trips,
getPath: (d) => d.path,
getTimestamps: (d) => d.timestamps,
getColor: (d) => d.color,
currentTime: relativeCurrentTime,
trailLength: 1000 * 60 * 60,
fadeTrail: true,
widthMinPixels: 2,
widthMaxPixels: 4,
capRounded: true,
jointRounded: true,
parameters: DEPTH_DISABLED_PARAMS,
pickable: false,
});
}

파일 보기

@ -0,0 +1,201 @@
import { PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import type { Layer, PickingInfo } from '@deck.gl/core';
import { DEPTH_DISABLED_PARAMS } from '../../../shared/lib/map/mapConstants';
import { getShipKindColor } from '../lib/adapters';
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
export const TRACK_REPLAY_LAYER_IDS = {
PATH: 'track-replay-path',
POINTS: 'track-replay-points',
VIRTUAL_SHIP: 'track-replay-virtual-ship',
VIRTUAL_LABEL: 'track-replay-virtual-label',
TRAIL: 'track-replay-trail',
} as const;
interface PathData {
vesselId: string;
path: [number, number][];
color: [number, number, number, number];
}
interface PointData {
vesselId: string;
position: [number, number];
color: [number, number, number, number];
timestamp: number;
speed: number;
index: number;
}
const MAX_POINTS_PER_TRACK = 800;
export function createStaticTrackLayers(options: {
tracks: ProcessedTrack[];
showPoints: boolean;
highlightedVesselId?: string | null;
onPathHover?: (vesselId: string | null) => void;
}): Layer[] {
const { tracks, showPoints, highlightedVesselId, onPathHover } = options;
const layers: Layer[] = [];
if (!tracks || tracks.length === 0) return layers;
const pathData: PathData[] = tracks.map((track) => ({
vesselId: track.vesselId,
path: track.geometry,
color: getShipKindColor(track.shipKindCode),
}));
layers.push(
new PathLayer<PathData>({
id: TRACK_REPLAY_LAYER_IDS.PATH,
data: pathData,
getPath: (d) => d.path,
getColor: (d) =>
highlightedVesselId && highlightedVesselId === d.vesselId
? [255, 255, 0, 255]
: [d.color[0], d.color[1], d.color[2], 235],
getWidth: (d) => (highlightedVesselId && highlightedVesselId === d.vesselId ? 5 : 3),
widthUnits: 'pixels',
widthMinPixels: 1,
widthMaxPixels: 6,
parameters: DEPTH_DISABLED_PARAMS,
jointRounded: true,
capRounded: true,
pickable: true,
onHover: (info: PickingInfo<PathData>) => {
onPathHover?.(info.object?.vesselId ?? null);
},
updateTriggers: {
getColor: [highlightedVesselId],
getWidth: [highlightedVesselId],
},
}),
);
if (showPoints) {
const pointData: PointData[] = [];
for (const track of tracks) {
const color = getShipKindColor(track.shipKindCode);
const len = track.geometry.length;
if (len <= MAX_POINTS_PER_TRACK) {
for (let i = 0; i < len; i++) {
pointData.push({
vesselId: track.vesselId,
position: track.geometry[i],
color,
timestamp: track.timestampsMs[i] || 0,
speed: track.speeds[i] || 0,
index: i,
});
}
} else {
const step = len / MAX_POINTS_PER_TRACK;
for (let i = 0; i < MAX_POINTS_PER_TRACK; i++) {
const idx = Math.min(Math.floor(i * step), len - 1);
pointData.push({
vesselId: track.vesselId,
position: track.geometry[idx],
color,
timestamp: track.timestampsMs[idx] || 0,
speed: track.speeds[idx] || 0,
index: idx,
});
}
}
}
layers.push(
new ScatterplotLayer<PointData>({
id: TRACK_REPLAY_LAYER_IDS.POINTS,
data: pointData,
getPosition: (d) => d.position,
getFillColor: (d) => d.color,
getRadius: 3,
radiusUnits: 'pixels',
radiusMinPixels: 2,
radiusMaxPixels: 5,
parameters: DEPTH_DISABLED_PARAMS,
pickable: false,
}),
);
}
return layers;
}
export function createDynamicTrackLayers(options: {
currentPositions: CurrentVesselPosition[];
showVirtualShip: boolean;
showLabels: boolean;
onIconHover?: (position: CurrentVesselPosition | null, x: number, y: number) => void;
onPathHover?: (vesselId: string | null) => void;
}): Layer[] {
const { currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover } = options;
const layers: Layer[] = [];
if (!currentPositions || currentPositions.length === 0) return layers;
if (showVirtualShip) {
layers.push(
new ScatterplotLayer<CurrentVesselPosition>({
id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_SHIP,
data: currentPositions,
getPosition: (d) => d.position,
getFillColor: (d) => {
const base = getShipKindColor(d.shipKindCode);
return [base[0], base[1], base[2], 230] as [number, number, number, number];
},
getLineColor: [255, 255, 255, 200],
getRadius: 5,
radiusUnits: 'pixels',
radiusMinPixels: 4,
radiusMaxPixels: 8,
stroked: true,
lineWidthMinPixels: 1,
parameters: DEPTH_DISABLED_PARAMS,
pickable: true,
onHover: (info: PickingInfo<CurrentVesselPosition>) => {
if (info.object) {
onPathHover?.(info.object.vesselId);
onIconHover?.(info.object, info.x, info.y);
} else {
onPathHover?.(null);
onIconHover?.(null, 0, 0);
}
},
}),
);
}
if (showLabels) {
const labelData = currentPositions.filter((position) => (position.shipName || '').trim().length > 0);
if (labelData.length > 0) {
layers.push(
new TextLayer<CurrentVesselPosition>({
id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_LABEL,
data: labelData,
getPosition: (d) => d.position,
getText: (d) => d.shipName,
getColor: [226, 232, 240, 240],
getSize: 11,
getTextAnchor: 'start',
getAlignmentBaseline: 'center',
getPixelOffset: [14, 0],
fontFamily: 'Malgun Gothic, Arial, sans-serif',
fontSettings: { sdf: true },
outlineColor: [2, 6, 23, 220],
outlineWidth: 2,
parameters: DEPTH_DISABLED_PARAMS,
pickable: false,
}),
);
}
}
return layers;
}
export function isTrackReplayLayerId(id: unknown): boolean {
return typeof id === 'string' && id.startsWith('track-replay-');
}

파일 보기

@ -0,0 +1,159 @@
import type { TrackPoint } from '../../../entities/vesselTrack/model/types';
import type { ProcessedTrack, TrackStats } from '../model/track.types';
const DEFAULT_SHIP_KIND = '000027';
const DEFAULT_SIGNAL_SOURCE = '000001';
const EPSILON_DISTANCE = 1e-10;
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
export function normalizeTrackTimestampMs(value: string | number | undefined | null): number {
if (typeof value === 'number') {
return value < 1e12 ? value * 1000 : value;
}
if (typeof value === 'string' && value.trim().length > 0) {
if (/^\d{10,}$/.test(value)) {
const asNum = Number(value);
return asNum < 1e12 ? asNum * 1000 : asNum;
}
const parsed = new Date(value).getTime();
if (Number.isFinite(parsed)) return parsed;
}
return Date.now();
}
function calculateStats(points: TrackPoint[]): TrackStats {
let maxSpeed = 0;
let speedSum = 0;
for (const point of points) {
const speed = Number.isFinite(point.sog) ? point.sog : 0;
maxSpeed = Math.max(maxSpeed, speed);
speedSum += speed;
}
return {
totalDistanceNm: 0,
avgSpeed: points.length > 0 ? speedSum / points.length : 0,
maxSpeed,
pointCount: points.length,
};
}
export function convertLegacyTrackPointsToProcessedTrack(
mmsi: number,
points: TrackPoint[],
hints?: {
shipName?: string;
shipKindCode?: string;
nationalCode?: string;
sigSrcCd?: string;
},
): ProcessedTrack | null {
const sorted = [...points].sort(
(a, b) => normalizeTrackTimestampMs(a.messageTimestamp) - normalizeTrackTimestampMs(b.messageTimestamp),
);
if (sorted.length === 0) return null;
const first = sorted[0];
const normalizedPoints = sorted
.map((point) => {
const lon = toFiniteNumber(point.lon);
const lat = toFiniteNumber(point.lat);
if (lon == null || lat == null) return null;
const ts = normalizeTrackTimestampMs(point.messageTimestamp);
const speed = toFiniteNumber(point.sog) ?? 0;
return {
point,
lon,
lat,
ts,
speed,
};
})
.filter((entry): entry is NonNullable<typeof entry> => entry != null);
if (normalizedPoints.length === 0) return null;
const geometry: [number, number][] = [];
const timestampsMs: number[] = [];
const speeds: number[] = [];
const statsPoints: TrackPoint[] = [];
for (const entry of normalizedPoints) {
const lastCoord = geometry[geometry.length - 1];
const isDuplicateCoord =
lastCoord != null &&
Math.abs(lastCoord[0] - entry.lon) <= EPSILON_DISTANCE &&
Math.abs(lastCoord[1] - entry.lat) <= EPSILON_DISTANCE;
const lastTs = timestampsMs[timestampsMs.length - 1];
// Drop exact duplicate samples to avoid zero-length/duplicate segments.
if (isDuplicateCoord && lastTs === entry.ts) continue;
geometry.push([entry.lon, entry.lat]);
timestampsMs.push(entry.ts);
speeds.push(entry.speed);
statsPoints.push(entry.point);
}
if (geometry.length === 0) return null;
const stats = calculateStats(statsPoints);
return {
vesselId: `${hints?.sigSrcCd || DEFAULT_SIGNAL_SOURCE}_${mmsi}`,
targetId: String(mmsi),
sigSrcCd: hints?.sigSrcCd || DEFAULT_SIGNAL_SOURCE,
shipName: (hints?.shipName || first.name || '').trim() || `MMSI ${mmsi}`,
shipKindCode: hints?.shipKindCode || DEFAULT_SHIP_KIND,
nationalCode: hints?.nationalCode || '',
geometry,
timestampsMs,
speeds,
stats,
};
}
export function getTracksTimeRange(tracks: ProcessedTrack[]): { start: number; end: number } | null {
if (tracks.length === 0) return null;
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const track of tracks) {
if (track.timestampsMs.length === 0) continue;
min = Math.min(min, track.timestampsMs[0]);
max = Math.max(max, track.timestampsMs[track.timestampsMs.length - 1]);
}
if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) return null;
return { start: min, end: max };
}
export function getShipKindColor(shipKindCode: string): [number, number, number, number] {
const colors: Record<string, [number, number, number, number]> = {
'000020': [25, 116, 25, 180],
'000021': [0, 41, 255, 180],
'000022': [176, 42, 42, 180],
'000023': [255, 139, 54, 180],
'000024': [255, 0, 0, 180],
'000025': [92, 30, 224, 180],
'000027': [255, 135, 207, 180],
'000028': [232, 95, 27, 180],
};
return colors[shipKindCode] || colors['000027'];
}

파일 보기

@ -0,0 +1,91 @@
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
function calculateHeading(from: [number, number], to: [number, number]): number {
const dx = to[0] - from[0];
const dy = to[1] - from[1];
let angle = (Math.atan2(dx, dy) * 180) / Math.PI;
if (angle < 0) angle += 360;
return angle;
}
function interpolate(
from: [number, number],
to: [number, number],
fromTs: number,
toTs: number,
currentTs: number,
): [number, number] {
if (toTs <= fromTs) return from;
if (currentTs <= fromTs) return from;
if (currentTs >= toTs) return to;
const ratio = (currentTs - fromTs) / (toTs - fromTs);
return [
from[0] + (to[0] - from[0]) * ratio,
from[1] + (to[1] - from[1]) * ratio,
];
}
export function getCurrentPosition(track: ProcessedTrack, currentTime: number): CurrentVesselPosition | null {
const len = track.timestampsMs.length;
if (len === 0 || track.geometry.length === 0) return null;
const firstTime = track.timestampsMs[0];
const lastTime = track.timestampsMs[len - 1];
if (currentTime < firstTime || currentTime > lastTime) return null;
if (len === 1) {
return {
vesselId: track.vesselId,
targetId: track.targetId,
sigSrcCd: track.sigSrcCd,
shipName: track.shipName,
shipKindCode: track.shipKindCode,
nationalCode: track.nationalCode,
position: track.geometry[0],
heading: 0,
speed: track.speeds[0] || 0,
timestamp: firstTime,
};
}
let hi = len - 1;
let lo = 0;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
if (track.timestampsMs[mid] <= currentTime) lo = mid + 1;
else hi = mid - 1;
}
const idx = Math.max(0, Math.min(len - 2, hi));
const from = track.geometry[idx];
const to = track.geometry[idx + 1];
const fromTs = track.timestampsMs[idx];
const toTs = track.timestampsMs[idx + 1];
const position = interpolate(from, to, fromTs, toTs, currentTime);
const heading = calculateHeading(from, to);
const speed = track.speeds[idx] || 0;
return {
vesselId: track.vesselId,
targetId: track.targetId,
sigSrcCd: track.sigSrcCd,
shipName: track.shipName,
shipKindCode: track.shipKindCode,
nationalCode: track.nationalCode,
position,
heading,
speed,
timestamp: currentTime,
};
}
export function getCurrentPositions(tracks: ProcessedTrack[], currentTime: number): CurrentVesselPosition[] {
const out: CurrentVesselPosition[] = [];
for (const track of tracks) {
const pos = getCurrentPosition(track, currentTime);
if (pos) out.push(pos);
}
return out;
}

파일 보기

@ -0,0 +1,45 @@
export type LonLat = [number, number];
export interface TrackStats {
totalDistanceNm: number;
avgSpeed: number;
maxSpeed: number;
pointCount: number;
}
export interface ProcessedTrack {
vesselId: string;
targetId: string;
sigSrcCd: string;
shipName: string;
shipKindCode: string;
nationalCode: string;
geometry: LonLat[];
timestampsMs: number[];
speeds: number[];
stats: TrackStats;
}
export interface CurrentVesselPosition {
vesselId: string;
targetId: string;
sigSrcCd: string;
shipName: string;
shipKindCode: string;
nationalCode: string;
position: LonLat;
heading: number;
speed: number;
timestamp: number;
}
export interface TrackQueryRequest {
mmsi: number;
minutes: number;
}
export interface ReplayStreamQueryRequest {
startTime: string;
endTime: string;
vessels: Array<{ sigSrcCd: string; targetId: string }>;
}

파일 보기

@ -0,0 +1,35 @@
import type { ReplayStreamQueryRequest } from '../model/track.types';
export interface ReplayStreamHandlers {
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: Error) => void;
onChunk?: (chunk: unknown) => void;
onCompleted?: () => void;
}
class ReplayStreamService {
private readonly enabled = String(import.meta.env.VITE_TRACKING_WS_ENABLED || 'false') === 'true';
async connect(handlers?: ReplayStreamHandlers): Promise<boolean> {
void handlers;
if (!this.enabled) return false;
return false;
}
async startQuery(request: ReplayStreamQueryRequest): Promise<boolean> {
void request;
if (!this.enabled) return false;
return false;
}
async cancel(): Promise<void> {
if (!this.enabled) return;
}
disconnect(): void {
if (!this.enabled) return;
}
}
export const replayStreamService = new ReplayStreamService();

파일 보기

@ -0,0 +1,162 @@
import { fetchVesselTrack } from '../../../entities/vesselTrack/api/fetchTrack';
import { convertLegacyTrackPointsToProcessedTrack } from '../lib/adapters';
import type { ProcessedTrack } from '../model/track.types';
type QueryTrackByMmsiParams = {
mmsi: number;
minutes: number;
shipNameHint?: string;
shipKindCodeHint?: string;
nationalCodeHint?: string;
};
type V2TrackResponse = {
vesselId?: string;
targetId?: string;
sigSrcCd?: string;
shipName?: string;
shipKindCode?: string;
nationalCode?: string;
geometry?: [number, number][];
timestamps?: Array<string | number>;
speeds?: number[];
totalDistance?: number;
avgSpeed?: number;
maxSpeed?: number;
pointCount?: number;
};
function normalizeTimestampMs(value: string | number): number {
if (typeof value === 'number') return value < 1e12 ? value * 1000 : value;
if (/^\d{10,}$/.test(value)) {
const asNum = Number(value);
return asNum < 1e12 ? asNum * 1000 : asNum;
}
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : 0;
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] {
const out: ProcessedTrack[] = [];
for (const row of rows) {
if (!row.geometry || row.geometry.length === 0) continue;
const timestamps = Array.isArray(row.timestamps) ? row.timestamps : [];
const timestampsMs = timestamps.map((ts) => normalizeTimestampMs(ts));
const sortedIndices = timestampsMs
.map((_, idx) => idx)
.sort((a, b) => timestampsMs[a] - timestampsMs[b]);
const geometry: [number, number][] = [];
const sortedTimes: number[] = [];
const speeds: number[] = [];
for (const idx of sortedIndices) {
const coord = row.geometry?.[idx];
if (!Array.isArray(coord) || coord.length !== 2) continue;
const nLon = toFiniteNumber(coord[0]);
const nLat = toFiniteNumber(coord[1]);
if (nLon == null || nLat == null) continue;
geometry.push([nLon, nLat]);
sortedTimes.push(timestampsMs[idx]);
speeds.push(toFiniteNumber(row.speeds?.[idx]) ?? 0);
}
if (geometry.length === 0) continue;
const targetId = row.targetId || row.vesselId || '';
const sigSrcCd = row.sigSrcCd || '000001';
out.push({
vesselId: row.vesselId || `${sigSrcCd}_${targetId}`,
targetId,
sigSrcCd,
shipName: (row.shipName || '').trim() || targetId,
shipKindCode: row.shipKindCode || '000027',
nationalCode: row.nationalCode || '',
geometry,
timestampsMs: sortedTimes,
speeds,
stats: {
totalDistanceNm: row.totalDistance || 0,
avgSpeed: row.avgSpeed || 0,
maxSpeed: row.maxSpeed || 0,
pointCount: row.pointCount || geometry.length,
},
});
}
return out;
}
async function queryLegacyTrack(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const response = await fetchVesselTrack(params.mmsi, params.minutes);
if (!response.success || response.data.length === 0) return [];
const converted = convertLegacyTrackPointsToProcessedTrack(params.mmsi, response.data, {
shipName: params.shipNameHint,
shipKindCode: params.shipKindCodeHint,
nationalCode: params.nationalCodeHint,
});
return converted ? [converted] : [];
}
async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '').trim();
if (!base) {
return queryLegacyTrack(params);
}
const end = new Date();
const start = new Date(end.getTime() - params.minutes * 60_000);
const requestBody = {
startTime: start.toISOString().slice(0, 19),
endTime: end.toISOString().slice(0, 19),
vessels: [{ sigSrcCd: '000001', targetId: String(params.mmsi) }],
isIntegration: '0',
};
const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`;
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
body: JSON.stringify(requestBody),
});
if (!res.ok) {
return queryLegacyTrack(params);
}
const json = (await res.json()) as unknown;
const rows = Array.isArray(json)
? (json as V2TrackResponse[])
: Array.isArray((json as { data?: unknown }).data)
? ((json as { data: V2TrackResponse[] }).data)
: [];
const converted = convertV2Tracks(rows);
if (converted.length > 0) return converted;
return queryLegacyTrack(params);
}
export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const mode = String(import.meta.env.VITE_TRACK_SOURCE_MODE || 'legacy').toLowerCase();
if (mode === 'v2') {
return queryV2Track(params);
}
return queryLegacyTrack(params);
}

파일 보기

@ -0,0 +1,162 @@
import { create } from 'zustand';
interface TrackPlaybackState {
isPlaying: boolean;
currentTime: number;
startTime: number;
endTime: number;
playbackSpeed: number;
loop: boolean;
loopStart: number;
loopEnd: number;
play: () => void;
pause: () => void;
stop: () => void;
setCurrentTime: (time: number) => void;
setPlaybackSpeed: (speed: number) => void;
toggleLoop: () => void;
setLoopSection: (start: number, end: number) => void;
setTimeRange: (start: number, end: number) => void;
syncToRangeStart: () => void;
reset: () => void;
}
let animationFrameId: number | null = null;
let lastFrameTime: number | null = null;
function clearAnimation(): void {
if (animationFrameId != null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
lastFrameTime = null;
}
export const useTrackPlaybackStore = create<TrackPlaybackState>()((set, get) => {
const animate = (): void => {
const state = get();
if (!state.isPlaying) return;
const now = performance.now();
if (lastFrameTime == null) {
lastFrameTime = now;
}
const delta = now - lastFrameTime;
lastFrameTime = now;
const advanceMs = delta * state.playbackSpeed;
let nextTime = state.currentTime + advanceMs;
const rangeStart = state.loop ? state.loopStart : state.startTime;
const rangeEnd = state.loop ? state.loopEnd : state.endTime;
if (nextTime >= rangeEnd) {
if (state.loop) {
nextTime = rangeStart;
} else {
nextTime = state.endTime;
set({ currentTime: nextTime, isPlaying: false });
clearAnimation();
return;
}
}
set({ currentTime: nextTime });
animationFrameId = requestAnimationFrame(animate);
};
return {
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 100,
loop: false,
loopStart: 0,
loopEnd: 0,
play: () => {
const state = get();
if (state.endTime <= state.startTime) return;
if (state.currentTime < state.startTime || state.currentTime > state.endTime) {
set({ currentTime: state.startTime });
}
set({ isPlaying: true });
clearAnimation();
animationFrameId = requestAnimationFrame(animate);
},
pause: () => {
clearAnimation();
set({ isPlaying: false });
},
stop: () => {
clearAnimation();
set((state) => ({ isPlaying: false, currentTime: state.startTime }));
},
setCurrentTime: (time: number) => {
const { startTime, endTime } = get();
const clamped = Math.max(startTime, Math.min(endTime, time));
set({ currentTime: clamped });
},
setPlaybackSpeed: (speed: number) => {
const normalized = Number.isFinite(speed) && speed > 0 ? speed : 1;
set({ playbackSpeed: normalized });
},
toggleLoop: () => {
set((state) => ({ loop: !state.loop }));
},
setLoopSection: (start: number, end: number) => {
const state = get();
const clampedStart = Math.max(state.startTime, Math.min(end, start));
const clampedEnd = Math.min(state.endTime, Math.max(start, end));
set({ loopStart: clampedStart, loopEnd: clampedEnd });
},
setTimeRange: (start: number, end: number) => {
const safeStart = Number.isFinite(start) ? start : 0;
const safeEnd = Number.isFinite(end) ? end : safeStart;
clearAnimation();
set({
isPlaying: false,
startTime: safeStart,
endTime: safeEnd,
currentTime: safeStart,
loopStart: safeStart,
loopEnd: safeEnd,
});
},
syncToRangeStart: () => {
clearAnimation();
set((state) => ({
isPlaying: false,
currentTime: state.startTime,
}));
},
reset: () => {
clearAnimation();
set({
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 100,
loop: false,
loopStart: 0,
loopEnd: 0,
});
},
};
});
export const TRACK_PLAYBACK_SPEED_OPTIONS = [1, 5, 10, 25, 50, 100] as const;

파일 보기

@ -0,0 +1,210 @@
import { create } from 'zustand';
import { getTracksTimeRange } from '../lib/adapters';
import type { ProcessedTrack } from '../model/track.types';
import { useTrackPlaybackStore } from './trackPlaybackStore';
export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error';
interface TrackQueryState {
tracks: ProcessedTrack[];
disabledVesselIds: Set<string>;
highlightedVesselId: string | null;
isLoading: boolean;
error: string | null;
queryState: TrackQueryStatus;
renderEpoch: number;
lastQueryKey: string | null;
showPoints: boolean;
showVirtualShip: boolean;
showLabels: boolean;
showTrail: boolean;
hideLiveShips: boolean;
beginQuery: (queryKey: string) => void;
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void;
applyQueryError: (error: string, queryKey?: string | null) => void;
closeQuery: () => void;
setTracks: (tracks: ProcessedTrack[]) => void;
clearTracks: () => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setHighlightedVesselId: (vesselId: string | null) => void;
setShowPoints: (show: boolean) => void;
setShowVirtualShip: (show: boolean) => void;
setShowLabels: (show: boolean) => void;
setShowTrail: (show: boolean) => void;
setHideLiveShips: (hide: boolean) => void;
toggleVesselEnabled: (vesselId: string) => void;
getEnabledTracks: () => ProcessedTrack[];
reset: () => void;
}
export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error: null,
queryState: 'idle',
renderEpoch: 0,
lastQueryKey: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,
showTrail: true,
hideLiveShips: false,
beginQuery: (queryKey: string) => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: true,
error: null,
queryState: 'loading',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey,
hideLiveShips: false,
}));
},
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) {
// Ignore stale async responses from an older query.
return;
}
const range = getTracksTimeRange(tracks);
const playback = useTrackPlaybackStore.getState();
if (range) {
playback.setTimeRange(range.start, range.end);
playback.syncToRangeStart();
playback.setPlaybackSpeed(100);
} else {
playback.reset();
}
set((state) => ({
tracks,
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error: null,
queryState: 'ready',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey ?? state.lastQueryKey,
}));
if (range) {
if (typeof window !== 'undefined') {
window.requestAnimationFrame(() => {
useTrackPlaybackStore.getState().play();
});
} else {
useTrackPlaybackStore.getState().play();
}
}
},
applyQueryError: (error: string, queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) {
// Ignore stale async errors from an older query.
return;
}
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error,
queryState: 'error',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey ?? state.lastQueryKey,
hideLiveShips: false,
}));
},
closeQuery: () => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error: null,
queryState: 'idle',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: null,
hideLiveShips: false,
}));
},
setTracks: (tracks: ProcessedTrack[]) => {
get().applyTracksSuccess(tracks, get().lastQueryKey);
},
clearTracks: () => {
get().closeQuery();
},
setLoading: (loading: boolean) =>
set((state) => ({
isLoading: loading,
queryState: loading ? 'loading' : state.error ? 'error' : state.tracks.length > 0 ? 'ready' : 'idle',
})),
setError: (error: string | null) =>
set((state) => ({
error,
queryState: error ? 'error' : state.isLoading ? 'loading' : state.tracks.length > 0 ? 'ready' : 'idle',
})),
setHighlightedVesselId: (vesselId: string | null) => set({ highlightedVesselId: vesselId }),
setShowPoints: (show: boolean) => set({ showPoints: show }),
setShowVirtualShip: (show: boolean) => set({ showVirtualShip: show }),
setShowLabels: (show: boolean) => set({ showLabels: show }),
setShowTrail: (show: boolean) => set({ showTrail: show }),
setHideLiveShips: (hide: boolean) => set({ hideLiveShips: hide }),
toggleVesselEnabled: (vesselId: string) => {
const next = new Set(get().disabledVesselIds);
if (next.has(vesselId)) next.delete(vesselId);
else next.add(vesselId);
set((state) => ({
disabledVesselIds: next,
renderEpoch: state.renderEpoch + 1,
}));
},
getEnabledTracks: () => {
const { tracks, disabledVesselIds } = get();
if (disabledVesselIds.size === 0) return tracks;
return tracks.filter((track) => !disabledVesselIds.has(track.vesselId));
},
reset: () => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error: null,
queryState: 'idle',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,
showTrail: true,
hideLiveShips: false,
}));
},
}));

파일 보기

@ -9,26 +9,26 @@ type Props = {
onToggleAll: () => void;
};
const TB = "cursor-pointer rounded-[5px] border p-1 text-center transition-all duration-150 select-none";
const TB_ON = "border-wing-accent bg-wing-accent/10";
const TB_OFF = "border-transparent bg-wing-card hover:border-wing-border";
export function TypeFilterGrid({ enabled, totalCount, countsByType, onToggle, onToggleAll }: Props) {
const allOn = VESSEL_TYPE_ORDER.every((c) => enabled[c]);
return (
<div className="tg">
<div className={`tb ${allOn ? "on" : ""}`} onClick={onToggleAll} style={{ gridColumn: "1/-1" }}>
<div className="c" style={{ color: "var(--accent)" }}>
</div>
<div className="n">{totalCount}</div>
<div className="grid grid-cols-3 gap-0.75">
<div className={`col-span-full ${TB} ${allOn ? TB_ON : TB_OFF}`} onClick={onToggleAll}>
<div className="text-[11px] font-extrabold text-wing-accent"></div>
<div className="text-[8px] text-wing-muted">{totalCount}</div>
</div>
{VESSEL_TYPE_ORDER.map((code) => {
const t = VESSEL_TYPES[code];
const cnt = countsByType[code] ?? 0;
return (
<div key={code} className={`tb ${enabled[code] ? "on" : ""}`} onClick={() => onToggle(code)}>
<div className="c" style={{ color: t.color }}>
{code}
</div>
<div className="n">{cnt}</div>
<div key={code} className={`${TB} ${enabled[code] ? TB_ON : TB_OFF}`} onClick={() => onToggle(code)}>
<div className="text-[11px] font-extrabold" style={{ color: t.color }}>{code}</div>
<div className="text-[8px] text-wing-muted">{cnt}</div>
</div>
);
})}

파일 보기

@ -0,0 +1,415 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl';
import { config as maptilerConfig } from '@maptiler/sdk';
import {
WindLayer,
TemperatureLayer,
PrecipitationLayer,
PressureLayer,
RadarLayer,
ColorRamp,
} from '@maptiler/weather';
import { getMapTilerKey } from '../../shared/lib/map/mapTilerKey';
/** 6종 기상 레이어 ID */
export type WeatherLayerId =
| 'wind'
| 'temperature'
| 'precipitation'
| 'pressure'
| 'radar'
| 'clouds';
export interface WeatherLayerMeta {
id: WeatherLayerId;
label: string;
icon: string;
}
export const WEATHER_LAYERS: WeatherLayerMeta[] = [
{ id: 'wind', label: '바람', icon: '💨' },
{ id: 'temperature', label: '기온', icon: '🌡' },
{ id: 'precipitation', label: '강수', icon: '🌧' },
{ id: 'pressure', label: '기압', icon: '◎' },
{ id: 'radar', label: '레이더', icon: '📡' },
{ id: 'clouds', label: '구름', icon: '☁' },
];
const LAYER_ID_PREFIX = 'maptiler-weather-';
/** 한중일 + 남중국해 영역 [west, south, east, north] */
const TILE_BOUNDS: [number, number, number, number] = [100, 10, 150, 50];
type AnyWeatherLayer = WindLayer | TemperatureLayer | PrecipitationLayer | PressureLayer | RadarLayer;
const DEFAULT_ENABLED: Record<WeatherLayerId, boolean> = {
wind: false,
temperature: false,
precipitation: false,
pressure: false,
radar: false,
clouds: false,
};
/** 각 레이어별 범례 정보 */
export interface LegendInfo {
label: string;
unit: string;
colorRamp: ColorRamp;
}
export const LEGEND_META: Record<WeatherLayerId, LegendInfo> = {
wind: { label: '풍속', unit: 'm/s', colorRamp: ColorRamp.builtin.WIND_ROCKET },
temperature: { label: '기온', unit: '°C', colorRamp: ColorRamp.builtin.TEMPERATURE_3 },
precipitation: { label: '강수량', unit: 'mm/h', colorRamp: ColorRamp.builtin.PRECIPITATION },
pressure: { label: '기압', unit: 'hPa', colorRamp: ColorRamp.builtin.PRESSURE_2 },
radar: { label: '레이더', unit: 'dBZ', colorRamp: ColorRamp.builtin.RADAR },
clouds: { label: '구름', unit: 'dBZ', colorRamp: ColorRamp.builtin.RADAR_CLOUD },
};
/**
* .
* animateByFactor(value) 1 value초 .
* 3600 = 1/, 7200 = 2/ ...
*/
export const SPEED_OPTIONS = [
{ value: 1800, label: '30분/초' },
{ value: 3600, label: '1시간/초' },
{ value: 7200, label: '2시간/초' },
{ value: 14400, label: '4시간/초' },
];
// bounds는 TileLayerOptions에 정의되나 개별 레이어 생성자 타입에 누락되어 as any 필요
/* eslint-disable @typescript-eslint/no-explicit-any */
function createLayerInstance(layerId: WeatherLayerId, opacity: number): AnyWeatherLayer {
const id = `${LAYER_ID_PREFIX}${layerId}`;
const opts = { id, opacity, bounds: TILE_BOUNDS };
switch (layerId) {
case 'wind':
return new WindLayer({
...opts,
colorramp: ColorRamp.builtin.WIND_ROCKET,
speed: 0.001,
fadeFactor: 0.03,
maxAmount: 256,
density: 4,
fastColor: [255, 100, 80, 230],
} as any);
case 'temperature':
return new TemperatureLayer({
...opts,
colorramp: ColorRamp.builtin.TEMPERATURE_3,
} as any);
case 'precipitation':
return new PrecipitationLayer({
...opts,
colorramp: ColorRamp.builtin.PRECIPITATION,
} as any);
case 'pressure':
return new PressureLayer({
...opts,
colorramp: ColorRamp.builtin.PRESSURE_2,
} as any);
case 'radar':
return new RadarLayer({
...opts,
colorramp: ColorRamp.builtin.RADAR,
} as any);
case 'clouds':
return new RadarLayer({
...opts,
colorramp: ColorRamp.builtin.RADAR_CLOUD,
} as any);
}
}
/* eslint-enable @typescript-eslint/no-explicit-any */
/** 타임라인 step 간격 (3시간 = 10 800초) */
const STEP_INTERVAL_SEC = 3 * 3600;
/** start~end 를 STEP_INTERVAL_SEC 단위로 나눈 epoch-초 배열 */
function buildSteps(startSec: number, endSec: number): number[] {
const steps: number[] = [];
for (let t = startSec; t <= endSec; t += STEP_INTERVAL_SEC) {
steps.push(t);
}
// 마지막 step이 endSec 와 다르면 보정
if (steps.length > 0 && steps[steps.length - 1] < endSec) {
steps.push(endSec);
}
return steps;
}
export interface WeatherOverlayState {
enabled: Record<WeatherLayerId, boolean>;
activeLayerId: WeatherLayerId | null;
opacity: number;
isPlaying: boolean;
animationSpeed: number;
currentTime: Date | null;
startTime: Date | null;
endTime: Date | null;
/** step epoch(초) 배열 — 타임라인 눈금 */
steps: number[];
isReady: boolean;
}
export interface WeatherOverlayActions {
toggleLayer: (id: WeatherLayerId) => void;
setOpacity: (v: number) => void;
play: () => void;
pause: () => void;
setSpeed: (factor: number) => void;
/** epoch 초 단위로 seek (SDK 내부 시간 = 초) */
seekTo: (epochSec: number) => void;
}
/**
* MapTiler Weather SDK 6 .
* map null이면 , / .
*/
export function useWeatherOverlay(
map: maplibregl.Map | null,
): WeatherOverlayState & WeatherOverlayActions {
const [enabled, setEnabled] = useState<Record<WeatherLayerId, boolean>>({ ...DEFAULT_ENABLED });
const [opacity, setOpacityState] = useState(0.6);
const [isPlaying, setIsPlaying] = useState(false);
const [animationSpeed, setAnimationSpeed] = useState(3600);
const [currentTime, setCurrentTime] = useState<Date | null>(null);
const [startTime, setStartTime] = useState<Date | null>(null);
const [endTime, setEndTime] = useState<Date | null>(null);
const [isReady, setIsReady] = useState(false);
const [steps, setSteps] = useState<number[]>([]);
const layerInstancesRef = useRef<Map<WeatherLayerId, AnyWeatherLayer>>(new Map());
const apiKeySetRef = useRef(false);
/** SDK raw 시간 범위 (초 단위) */
const animRangeRef = useRef<{ start: number; end: number } | null>(null);
// 레이어 add effect 안의 async 콜백에서 최신 isPlaying/animationSpeed를 읽기 위한 ref
const isPlayingRef = useRef(isPlaying);
isPlayingRef.current = isPlaying;
const animationSpeedRef = useRef(animationSpeed);
animationSpeedRef.current = animationSpeed;
// API key 설정 + ServiceWorker 등록
useEffect(() => {
if (apiKeySetRef.current) return;
const key = getMapTilerKey();
if (key) {
maptilerConfig.apiKey = key;
apiKeySetRef.current = true;
}
// 타일 캐시 SW 등록 (실패해도 무시 — 캐시 없이도 동작)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw-weather-cache.js').catch(() => {});
}
}, []);
// maplibre-gl Map에 MapTiler SDK 전용 메서드/프로퍼티 패치
useEffect(() => {
if (!map) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const m = map as any;
if (typeof m.getSdkConfig === 'function') return;
m.getSdkConfig = () => maptilerConfig;
m.getMaptilerSessionId = () => '';
m.isGlobeProjection = () => map.getProjection?.()?.type === 'globe';
if (!m.telemetry) {
m.telemetry = { registerModule: () => {} };
}
}, [map]);
// enabled 변경 시 레이어 추가/제거
useEffect(() => {
if (!map) return;
if (!apiKeySetRef.current) return;
const instances = layerInstancesRef.current;
for (const meta of WEATHER_LAYERS) {
const isOn = enabled[meta.id];
const existing = instances.get(meta.id);
if (isOn && !existing) {
const layer = createLayerInstance(meta.id, opacity);
instances.set(meta.id, layer);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.addLayer(layer as any);
// 소스가 준비되면 시간 범위 설정 + 재생 상태 적용
layer.onSourceReadyAsync().then(() => {
if (!instances.has(meta.id)) return;
// SDK 내부 시간은 epoch 초 단위
const rawStart = layer.getAnimationStart();
const rawEnd = layer.getAnimationEnd();
animRangeRef.current = { start: rawStart, end: rawEnd };
setStartTime(layer.getAnimationStartDate());
setEndTime(layer.getAnimationEndDate());
setSteps(buildSteps(rawStart, rawEnd));
// 시작 시간으로 초기화 (초 단위 전달)
layer.setAnimationTime(rawStart);
setCurrentTime(layer.getAnimationStartDate());
setIsReady(true);
// 재생 중이었다면 새 레이어에도 재생 적용
if (isPlayingRef.current) {
layer.animateByFactor(animationSpeedRef.current);
}
});
} catch (e) {
if (import.meta.env.DEV) {
console.warn(`[WeatherOverlay] Failed to add layer ${meta.id}:`, e);
}
instances.delete(meta.id);
}
} else if (!isOn && existing) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (map.getLayer((existing as any).id)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.removeLayer((existing as any).id);
}
} catch {
// ignore
}
instances.delete(meta.id);
}
}
if (instances.size === 0) {
setIsReady(false);
setStartTime(null);
setEndTime(null);
setCurrentTime(null);
setSteps([]);
animRangeRef.current = null;
}
}, [enabled, map, opacity]);
// opacity 변경 시 기존 레이어에 반영
useEffect(() => {
for (const layer of layerInstancesRef.current.values()) {
layer.setOpacity(opacity);
}
}, [opacity]);
// 애니메이션 상태 동기화
useEffect(() => {
for (const layer of layerInstancesRef.current.values()) {
if (isPlaying) {
layer.animateByFactor(animationSpeed);
} else {
layer.animateByFactor(0);
}
}
}, [isPlaying, animationSpeed]);
// 재생 중 rAF 폴링으로 currentTime 동기화 (~4fps)
useEffect(() => {
if (!isPlaying) return;
const instances = layerInstancesRef.current;
if (instances.size === 0) return;
let rafId: number;
let lastUpdate = 0;
const poll = () => {
const now = performance.now();
if (now - lastUpdate > 250) {
lastUpdate = now;
const first = instances.values().next().value;
if (first) {
setCurrentTime(first.getAnimationTimeDate());
}
}
rafId = requestAnimationFrame(poll);
};
rafId = requestAnimationFrame(poll);
return () => cancelAnimationFrame(rafId);
}, [isPlaying]);
// cleanup on map change or unmount
useEffect(() => {
return () => {
for (const [id, layer] of layerInstancesRef.current) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (map?.getLayer((layer as any).id)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.removeLayer((layer as any).id);
}
} catch {
// ignore
}
void id;
}
layerInstancesRef.current.clear();
};
}, [map]);
// 라디오 버튼 동작: 하나만 활성, 다시 누르면 전부 off
const toggleLayer = useCallback((id: WeatherLayerId) => {
setEnabled((prev) => {
const next = { ...DEFAULT_ENABLED };
if (!prev[id]) next[id] = true;
return next;
});
// 레이어 전환 시 isReady 리셋 (새 소스 로딩 대기)
setIsReady(false);
}, []);
const setOpacity = useCallback((v: number) => {
setOpacityState(Math.max(0, Math.min(1, v)));
}, []);
const play = useCallback(() => setIsPlaying(true), []);
const pause = useCallback(() => setIsPlaying(false), []);
const setSpeed = useCallback((factor: number) => {
setAnimationSpeed(factor);
}, []);
/** epochSec = SDK 내부 초 단위 시간 */
const seekTo = useCallback((epochSec: number) => {
for (const layer of layerInstancesRef.current.values()) {
layer.setAnimationTime(epochSec);
}
setCurrentTime(new Date(epochSec * 1000));
// SDK CustomLayerInterface.render() 가 호출되어야 타일이 실제 갱신됨
// 여러 프레임에 걸쳐 repaint 트리거
if (map) {
let count = 0;
const kick = () => {
map.triggerRepaint();
if (++count < 6) requestAnimationFrame(kick);
};
kick();
}
}, [map]);
const activeLayerId = (Object.keys(enabled) as WeatherLayerId[]).find((k) => enabled[k]) ?? null;
return {
enabled,
activeLayerId,
opacity,
isPlaying,
animationSpeed,
currentTime,
startTime,
endTime,
steps,
isReady,
toggleLayer,
setOpacity,
play,
pause,
setSpeed,
seekTo,
};
}

파일 보기

@ -0,0 +1,97 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
import { ZONE_META, type ZoneId } from '../../entities/zone/model/meta';
import type { WeatherQueryPoint, WeatherSnapshot } from '../../entities/weather/model/types';
import { fetchWeatherForPoints } from '../../entities/weather/api/fetchWeather';
import { computeMultiPolygonCentroid } from '../../entities/weather/lib/weatherUtils';
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5분
/**
* zones GeoJSON에서 (centroid) .
* zoneId Feature만 .
*/
function buildQueryPoints(zones: ZonesGeoJson): WeatherQueryPoint[] {
const points: WeatherQueryPoint[] = [];
for (const feature of zones.features) {
const zoneId = feature.properties?.zoneId as ZoneId | undefined;
if (!zoneId || !ZONE_META[zoneId]) continue;
const meta = ZONE_META[zoneId];
const [lon, lat] = computeMultiPolygonCentroid(
feature.geometry.coordinates as number[][][][],
);
points.push({
label: `${meta.label} ${meta.name}`,
color: meta.color,
lat,
lon,
zoneId,
});
}
return points;
}
export interface WeatherPollingResult {
snapshot: WeatherSnapshot | null;
isLoading: boolean;
error: string | null;
refresh: () => void;
}
/**
* 5 .
* zonesGeoJson이 null이면 ( ).
*/
export function useWeatherPolling(
zonesGeoJson: ZonesGeoJson | null,
): WeatherPollingResult {
const [snapshot, setSnapshot] = useState<WeatherSnapshot | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const queryPoints = useMemo(() => {
if (!zonesGeoJson) return null;
return buildQueryPoints(zonesGeoJson);
}, [zonesGeoJson]);
const doFetch = useCallback(async () => {
if (!queryPoints || queryPoints.length === 0) return;
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
setIsLoading(true);
setError(null);
try {
const result = await fetchWeatherForPoints(queryPoints);
if (ac.signal.aborted) return;
setSnapshot(result);
} catch (e) {
if (ac.signal.aborted) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (!ac.signal.aborted) setIsLoading(false);
}
}, [queryPoints]);
// 마운트 + queryPoints 변경 시 즉시 1회 호출 + 5분 간격 폴링
useEffect(() => {
if (!queryPoints || queryPoints.length === 0) return;
void doFetch();
const timer = setInterval(() => void doFetch(), POLL_INTERVAL_MS);
return () => {
clearInterval(timer);
abortRef.current?.abort();
};
}, [queryPoints, doFetch]);
return { snapshot, isLoading, error, refresh: doFetch };
}

파일 보기

@ -1,39 +1,31 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "../../shared/auth";
import { usePersistedState } from "../../shared/hooks";
import { useTheme } from "../../shared/hooks";
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
import { MapToggles } from "../../features/mapToggles/MapToggles";
import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid";
import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types";
import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
import { ALARM_KIND_PRIORITY, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
import type { VesselTypeCode } from "../../entities/vessel/model/types";
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
import { useZones } from "../../entities/zone/api/useZones";
import { useSubcables } from "../../entities/subcable/api/useSubcables";
import type { VesselTypeCode } from "../../entities/vessel/model/types";
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel";
import { MapLegend } from "../../widgets/legend/MapLegend";
import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D";
import type { MapViewState } from "../../widgets/map3d/types";
import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
import { Map3D } from "../../widgets/map3d/Map3D";
import { Topbar } from "../../widgets/topbar/Topbar";
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
import { VesselList } from "../../widgets/vesselList/VesselList";
import type { ActiveTrack } from "../../entities/vesselTrack/model/types";
import { fetchVesselTrack } from "../../entities/vesselTrack/api/fetchTrack";
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
import { DepthLegend } from "../../widgets/legend/DepthLegend";
import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types";
import type { MapStyleSettings } from "../../features/mapSettings/types";
import { fmtDateTimeFull, fmtIsoFull } from "../../shared/lib/datetime";
import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService";
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling";
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
import {
buildLegacyHitMap,
computeCountsByType,
@ -44,18 +36,17 @@ import {
deriveLegacyVessels,
filterByShipCodes,
} from "../../features/legacyDashboard/model/derive";
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
import { MOCK_AIS_TARGETS, MOCK_LEGACY_ENTRIES } from "../../features/legacyDashboard/dev/mockOverlayData";
import { useDashboardState } from "./useDashboardState";
import type { Bbox } from "./useDashboardState";
import { DashboardSidebar } from "./DashboardSidebar";
const AIS_API_BASE = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, "");
const AIS_CENTER = {
lon: 126.95,
lat: 35.95,
radiusMeters: 2_000_000,
};
type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax]
type FleetRelationSortMode = "count" | "range";
function inBbox(lon: number, lat: number, bbox: Bbox) {
const [lonMin, latMin, lonMax, latMax] = bbox;
if (lat < latMin || lat > latMax) return false;
@ -63,27 +54,59 @@ function inBbox(lon: number, lat: number, bbox: Bbox) {
return lon >= lonMin || lon <= lonMax;
}
function fmtBbox(b: Bbox | null) {
if (!b) return "-";
return `${b[0].toFixed(4)},${b[1].toFixed(4)},${b[2].toFixed(4)},${b[3].toFixed(4)}`;
}
function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | null {
function useLegacyIndex(data: LegacyVesselDataset | null) {
return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]);
}
export function DashboardPage() {
const { user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const uid = user?.id ?? null;
const isDevMode = user?.name?.includes('(DEV)') ?? false;
// ── Data fetching ──
const { data: zones, error: zonesError } = useZones();
const { data: legacyData, error: legacyError } = useLegacyVessels();
const { data: subcableData } = useSubcables();
const legacyIndex = useLegacyIndex(legacyData);
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
const [useViewportFilter, setUseViewportFilter] = useState(false);
const [useApiBbox, setUseApiBbox] = useState(false);
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
// ── UI state ──
const state = useDashboardState(uid);
const {
mapInstance, handleMapReady,
viewBbox, setViewBbox,
useViewportFilter,
useApiBbox, apiBbox,
selectedMmsi, setSelectedMmsi,
highlightedMmsiSet,
hoveredMmsiSet, setHoveredMmsiSet,
hoveredFleetMmsiSet, setHoveredFleetMmsiSet,
hoveredPairMmsiSet, setHoveredPairMmsiSet,
hoveredFleetOwnerKey, setHoveredFleetOwnerKey,
typeEnabled,
showTargets, showOthers,
baseMap, projection,
mapStyleSettings, setMapStyleSettings,
overlays, settings,
mapView, setMapView,
fleetFocus, setFleetFocus,
hoveredCableId, setHoveredCableId,
selectedCableId, setSelectedCableId,
trackContextMenu, handleOpenTrackMenu, handleCloseTrackMenu,
handleProjectionLoadingChange,
setIsGlobeShipsReady,
showMapLoader,
clock, adminMode, onLogoClick,
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
alarmKindEnabled,
} = state;
// ── Weather ──
const weather = useWeatherPolling(zones);
const weatherOverlay = useWeatherOverlay(mapInstance);
// ── AIS polling ──
const { targets, snapshot } = useAisTargetPolling({
chnprmshipMinutes: 120,
incrementalMinutes: 2,
@ -95,105 +118,45 @@ export function DashboardPage() {
radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters,
});
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
const [highlightedMmsiSet, setHighlightedMmsiSet] = useState<number[]>([]);
const [hoveredMmsiSet, setHoveredMmsiSet] = useState<number[]>([]);
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
const uid = user?.id ?? null;
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
uid, 'typeEnabled', { PT: true, "PT-S": true, GN: true, OT: true, PS: true, FC: true },
);
const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true);
const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false);
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [baseMap, _setBaseMap] = useState<BaseMapId>("enhanced");
// 항상 mercator로 시작 — 3D 전환은 globe 레이어 준비 후 사용자가 수동 전환
const [projection, setProjection] = useState<MapProjectionId>('mercator');
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
pairLines: true, pairRange: true, fcLines: true, zones: true,
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
});
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', "count");
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
uid, 'alarmKindEnabled',
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
);
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
// 항적 (vessel track)
const [activeTrack, setActiveTrack] = useState<ActiveTrack | null>(null);
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null);
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => {
setTrackContextMenu(info);
}, []);
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
// ── Track request ──
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
const trackStore = useTrackQueryStore.getState();
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
trackStore.beginQuery(queryKey);
try {
const res = await fetchVesselTrack(mmsi, minutes);
if (res.success && res.data.length > 0) {
const sorted = [...res.data].sort(
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
);
setActiveTrack({ mmsi, minutes, points: sorted, fetchedAt: Date.now() });
const target = targets.find((item) => item.mmsi === mmsi);
const tracks = await queryTrackByMmsi({
mmsi,
minutes,
shipNameHint: target?.name,
});
if (tracks.length > 0) {
trackStore.applyTracksSuccess(tracks, queryKey);
} else {
console.warn('Track: no data', res.message);
trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey);
}
} catch (e) {
console.warn('Track fetch failed:', e);
trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
}
}, []);
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
showShips: true, showDensity: false, showSeamark: false,
});
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
// 초기값 false: globe 레이어가 백그라운드에서 준비될 때까지 토글 비활성화
const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false);
const handleProjectionLoadingChange = useCallback((loading: boolean) => {
setIsProjectionLoading(loading);
}, []);
const showMapLoader = isProjectionLoading;
// globe 레이어 미준비 또는 전환 중일 때 토글 비활성화
const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading;
const [clock, setClock] = useState(() => fmtDateTimeFull(new Date()));
useEffect(() => {
const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 1000);
return () => window.clearInterval(id);
}, []);
// Secret admin toggle: 7 clicks within 900ms on the logo.
const [adminMode, setAdminMode] = useState(false);
const clicksRef = useRef<number[]>([]);
const onLogoClick = () => {
const now = Date.now();
clicksRef.current = clicksRef.current.filter((t) => now - t < 900);
clicksRef.current.push(now);
if (clicksRef.current.length >= 7) {
clicksRef.current = [];
setAdminMode((v) => !v);
}
};
}, [targets]);
// ── Derived data ──
const targetsInScope = useMemo(() => {
if (!useViewportFilter || !viewBbox) return targets;
return targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
}, [targets, useViewportFilter, viewBbox]);
const base = (!useViewportFilter || !viewBbox)
? targets
: targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
return isDevMode ? [...base, ...MOCK_AIS_TARGETS] : base;
}, [targets, useViewportFilter, viewBbox, isDevMode]);
const legacyHits = useMemo(() => buildLegacyHitMap(targetsInScope, legacyIndex), [targetsInScope, legacyIndex]);
const legacyHits = useMemo(() => {
const hits = buildLegacyHitMap(targetsInScope, legacyIndex);
if (isDevMode) {
for (const [mmsi, info] of MOCK_LEGACY_ENTRIES) hits.set(mmsi, info);
}
return hits;
}, [targetsInScope, legacyIndex, isDevMode]);
const legacyVesselsAll = useMemo(() => deriveLegacyVessels({ targets: targetsInScope, legacyHits, zones }), [targetsInScope, legacyHits, zones]);
const legacyCounts = useMemo(() => computeCountsByType(legacyVesselsAll), [legacyVesselsAll]);
@ -226,17 +189,14 @@ export function DashboardPage() {
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
const alarmKindCounts = useMemo(() => {
const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record<LegacyAlarmKind, number>;
const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record<typeof LEGACY_ALARM_KINDS[number], number>;
for (const a of alarms) {
base[a.kind] = (base[a.kind] ?? 0) + 1;
}
return base;
}, [alarms]);
const enabledAlarmKinds = useMemo(() => {
return LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]);
}, [alarmKindEnabled]);
const enabledAlarmKinds = useMemo(() => LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]), [alarmKindEnabled]);
const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length;
const filteredAlarms = useMemo(() => {
@ -245,6 +205,17 @@ export function DashboardPage() {
return alarms.filter((a) => enabled.has(a.kind));
}, [alarms, enabledAlarmKinds, allAlarmKindsEnabled]);
const alarmMmsiMap = useMemo(() => {
const m = new Map<number, LegacyAlarmKind>();
for (const kind of ALARM_KIND_PRIORITY) {
for (const alarm of filteredAlarms) {
if (alarm.kind !== kind) continue;
for (const mmsi of alarm.relatedMmsi) m.set(mmsi, kind);
}
}
return m;
}, [filteredAlarms]);
const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
@ -273,14 +244,11 @@ export function DashboardPage() {
[highlightedMmsiSet, availableTargetMmsiSet],
);
const setUniqueSorted = (items: number[]) =>
Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b);
const setSortedIfChanged = (next: number[]) => {
const sorted = setUniqueSorted(next);
return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted);
};
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length;
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => {
if (!mmsis.length) return;
const members = mmsis
@ -294,413 +262,54 @@ export function DashboardPage() {
const sumLon = members.reduce((acc, v) => acc + v.lon, 0);
const sumLat = members.reduce((acc, v) => acc + v.lat, 0);
const center: [number, number] = [sumLon / members.length, sumLat / members.length];
setFleetFocus({
id: `${ownerKey}-${Date.now()}`,
center,
zoom: 9,
});
setFleetFocus({ id: `${ownerKey}-${Date.now()}`, center, zoom: 9 });
};
const toggleHighlightedMmsi = (mmsi: number) => {
setHighlightedMmsiSet((prev) => {
const next = new Set(prev);
if (next.has(mmsi)) next.delete(mmsi);
else next.add(mmsi);
return Array.from(next).sort((a, b) => a - b);
});
};
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length;
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
const enabledAlarmKindCount = enabledAlarmKinds.length;
const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKindCount}/${LEGACY_ALARM_KINDS.length}`;
// ── Render ──
return (
<div className="app">
<div className="grid h-screen grid-cols-[310px_1fr] grid-rows-[44px_1fr] max-md:grid-cols-[1fr] max-md:grid-rows-[auto_1fr]">
<Topbar
total={legacyVesselsAll.length}
fishing={fishingCount}
transit={transitCount}
pairLinks={pairLinksAll.length}
alarms={alarms.length}
pollingStatus={snapshot.status}
lastFetchMinutes={snapshot.lastFetchMinutes}
clock={clock}
adminMode={adminMode}
onLogoClick={onLogoClick}
userName={user?.name}
onLogout={logout}
theme={theme}
onToggleTheme={toggleTheme}
isSidebarOpen={isSidebarOpen}
onMenuToggle={() => setIsSidebarOpen((v) => !v)}
/>
<div className="sidebar">
<div className="sb">
<div className="sb-t"> </div>
<div className="tog">
<div
className={`tog-btn ${showTargets ? "on" : ""}`}
onClick={() => {
setShowTargets((v) => {
const next = !v;
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
return next;
});
}}
title="레거시(CN permit) 대상 선박 표시"
>
</div>
<div className={`tog-btn ${showOthers ? "on" : ""}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
AIS
</div>
</div>
<TypeFilterGrid
enabled={typeEnabled}
totalCount={legacyVesselsAll.length}
countsByType={legacyCounts}
onToggle={(code) => {
// When hiding the currently selected legacy vessel's type, clear selection.
if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null);
setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] }));
}}
onToggleAll={() => {
const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]);
const nextVal = !allOn; // any-off -> true, all-on -> false
if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null);
setTypeEnabled({
PT: nextVal,
"PT-S": nextVal,
GN: nextVal,
OT: nextVal,
PS: nextVal,
FC: nextVal,
});
}}
/>
</div>
<DashboardSidebar
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
state={state}
legacyVesselsAll={legacyVesselsAll}
legacyVesselsFiltered={legacyVesselsFiltered}
legacyCounts={legacyCounts}
selectedLegacyVessel={selectedLegacyVessel}
activeHighlightedMmsiSet={activeHighlightedMmsiSet}
legacyHits={legacyHits}
filteredAlarms={filteredAlarms}
alarms={alarms}
alarmKindCounts={alarmKindCounts}
speedPanelType={speedPanelType}
onFleetContextMenu={handleFleetContextMenu}
snapshot={snapshot}
legacyError={legacyError}
legacyData={legacyData}
targetsInScope={targetsInScope}
zonesError={zonesError}
zones={zones}
legacyIndex={legacyIndex}
/>
<div className="sb">
<div className="sb-t" style={{ display: "flex", alignItems: "center" }}>
<div style={{ flex: 1 }} />
<div
className={`tog-btn ${projection === "globe" ? "on" : ""}${isProjectionToggleDisabled ? " disabled" : ""}`}
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
title={isProjectionToggleDisabled ? "3D 모드 준비 중..." : "3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"}
style={{ fontSize: 9, padding: "2px 8px", opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? "not-allowed" : "pointer" }}
>
3D
</div>
</div>
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
{/* enhanced ,
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center", marginTop: 8 }}>
<div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)">
</div>
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
</div>
</div> */}
</div>
<div className="sb">
<div className="sb-t"> </div>
<SpeedProfilePanel selectedType={speedPanelType} />
</div>
<div className="sb" style={{ maxHeight: 260, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div className="sb-t sb-t-row">
<div>
{" "}
<span style={{ color: "var(--accent)", fontSize: 8 }}>
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"}
</span>
</div>
<div className="relation-sort">
<label className="relation-sort__option">
<input
type="radio"
name="fleet-relation-sort"
checked={fleetRelationSortMode === "count"}
onChange={() => setFleetRelationSortMode("count")}
/>
</label>
<label className="relation-sort__option">
<input
type="radio"
name="fleet-relation-sort"
checked={fleetRelationSortMode === "range"}
onChange={() => setFleetRelationSortMode("range")}
/>
</label>
</div>
</div>
<div style={{ overflowY: "auto", minHeight: 0 }}>
<RelationsPanel
selectedVessel={selectedLegacyVessel}
vessels={legacyVesselsAll}
fleetVessels={legacyVesselsFiltered}
onSelectMmsi={setSelectedMmsi}
onToggleHighlightMmsi={toggleHighlightedMmsi}
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
onClearHover={() => setHoveredMmsiSet([])}
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
onClearPairHover={() => setHoveredPairMmsiSet([])}
onHoverFleet={(ownerKey, fleetMmsis) => {
setHoveredFleetOwnerKey(ownerKey);
setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis));
}}
onClearFleetHover={() => {
setHoveredFleetOwnerKey(null);
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
}}
fleetSortMode={fleetRelationSortMode}
hoveredFleetOwnerKey={hoveredFleetOwnerKey}
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
onContextMenuFleet={handleFleetContextMenu}
/>
</div>
</div>
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<div className="sb-t">
{" "}
<span style={{ color: "var(--accent)", fontSize: 8 }}>
({legacyVesselsFiltered.length})
</span>
</div>
<VesselList
vessels={legacyVesselsFiltered}
selectedMmsi={selectedMmsi}
highlightedMmsiSet={activeHighlightedMmsiSet}
onSelectMmsi={setSelectedMmsi}
onToggleHighlightMmsi={toggleHighlightedMmsi}
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
onClearHover={() => setHoveredMmsiSet([])}
/>
</div>
<div className="sb" style={{ maxHeight: 130, display: "flex", flexDirection: "column", overflow: "visible" }}>
<div className="sb-t sb-t-row" style={{ marginBottom: 6 }}>
<div>
{" "}
<span style={{ color: "var(--accent)", fontSize: 8 }}>
({filteredAlarms.length}/{alarms.length})
</span>
</div>
{LEGACY_ALARM_KINDS.length <= 3 ? (
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{LEGACY_ALARM_KINDS.map((k) => (
<label key={k} style={{ display: "inline-flex", gap: 4, alignItems: "center", cursor: "pointer", userSelect: "none" }}>
<input
type="checkbox"
checked={!!alarmKindEnabled[k]}
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
/>
<span style={{ fontSize: 8, color: "var(--muted)", whiteSpace: "nowrap" }}>{LEGACY_ALARM_KIND_LABEL[k]}</span>
</label>
))}
</div>
) : (
<details className="alarm-filter">
<summary className="alarm-filter__summary" title="경고 종류 필터">
{alarmFilterSummary}
</summary>
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
<label className="alarm-filter__row">
<input
type="checkbox"
checked={allAlarmKindsEnabled}
onChange={() =>
setAlarmKindEnabled((prev) => {
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
const nextVal = allOn ? false : true;
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
})
}
/>
<span className="alarm-filter__cnt">{alarms.length}</span>
</label>
<div className="alarm-filter__sep" />
{LEGACY_ALARM_KINDS.map((k) => (
<label key={k} className="alarm-filter__row">
<input
type="checkbox"
checked={!!alarmKindEnabled[k]}
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
/>
{LEGACY_ALARM_KIND_LABEL[k]}
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
</label>
))}
</div>
</details>
)}
</div>
<div style={{ overflowY: "auto", minHeight: 0, flex: 1 }}>
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
</div>
</div>
{adminMode ? (
<>
<div className="sb">
<div className="sb-t">ADMIN · AIS Target Polling</div>
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ wordBreak: "break-all" }}>{AIS_API_BASE}/api/ais-target/search</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div>
<div>
<b style={{ color: snapshot.status === "ready" ? "#22C55E" : snapshot.status === "error" ? "#EF4444" : "#F59E0B" }}>
{snapshot.status.toUpperCase()}
</b>
{snapshot.error ? <span style={{ marginLeft: 6, color: "#EF4444" }}>{snapshot.error}</span> : null}
</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}> fetch</div>
<div>
{fmtIsoFull(snapshot.lastFetchAt)}{" "}
<span style={{ color: "var(--muted)", fontSize: 10 }}>
({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
</span>
</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ color: "var(--text)", fontSize: 10 }}>{snapshot.lastMessage ?? "-"}</div>
</div>
</div>
<div className="sb">
<div className="sb-t">ADMIN · Legacy (CN Permit)</div>
{legacyError ? (
<div style={{ fontSize: 11, color: "#EF4444" }}>legacy load error: {legacyError}</div>
) : (
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ wordBreak: "break-all", fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>( scope)</div>
<div>
<b style={{ color: "#F59E0B" }}>{legacyVesselsAll.length}</b>{" "}
<span style={{ color: "var(--muted)", fontSize: 10 }}>/ {targetsInScope.length}</span>
</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : "loading..."}</div>
</div>
)}
</div>
<div className="sb">
<div className="sb-t">ADMIN · Viewport / BBox</div>
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: "var(--muted)", fontSize: 10 }}> View BBox</div>
<div style={{ wordBreak: "break-all", fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
<div style={{ marginTop: 8, display: "flex", gap: 6, flexWrap: "wrap" }}>
<button
onClick={() => setUseViewportFilter((v) => !v)}
style={{
fontSize: 10,
padding: "4px 8px",
borderRadius: 6,
border: "1px solid var(--border)",
background: useViewportFilter ? "rgba(59,130,246,.18)" : "var(--card)",
color: "var(--text)",
cursor: "pointer",
}}
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
>
Viewport filter {useViewportFilter ? "ON" : "OFF"}
</button>
<button
onClick={() => {
if (!viewBbox) return;
setUseApiBbox((v) => {
const next = !v;
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
if (!next) setApiBbox(undefined);
return next;
});
}}
style={{
fontSize: 10,
padding: "4px 8px",
borderRadius: 6,
border: "1px solid var(--border)",
background: useApiBbox ? "rgba(245,158,11,.14)" : "var(--card)",
color: viewBbox ? "var(--text)" : "var(--muted)",
cursor: viewBbox ? "pointer" : "not-allowed",
}}
title="서버에서 bbox로 필터링해서 내려받기(페이로드 감소). 켜는 순간 현재 view bbox로 고정됨."
disabled={!viewBbox}
>
API bbox {useApiBbox ? "ON" : "OFF"}
</button>
<button
onClick={() => {
if (!viewBbox) return;
setApiBbox(fmtBbox(viewBbox));
setUseApiBbox(true);
}}
style={{
fontSize: 10,
padding: "4px 8px",
borderRadius: 6,
border: "1px solid var(--border)",
background: "var(--card)",
color: viewBbox ? "var(--text)" : "var(--muted)",
cursor: viewBbox ? "pointer" : "not-allowed",
}}
disabled={!viewBbox}
title="현재 view bbox로 API bbox를 갱신"
>
bbox=viewport
</button>
</div>
<div style={{ marginTop: 8, color: "var(--muted)", fontSize: 10 }}>
: <b style={{ color: "var(--text)" }}>{targetsInScope.length}</b> / :{" "}
<b style={{ color: "var(--text)" }}>{snapshot.total}</b>
</div>
</div>
</div>
<div className="sb">
<div className="sb-t">ADMIN · Map (Extras)</div>
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
<div style={{ fontSize: 10, color: "var(--muted)", marginTop: 6 }}> WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
</div>
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<div className="sb-t">ADMIN · AIS Targets (All)</div>
<AisTargetList
targets={targetsInScope}
selectedMmsi={selectedMmsi}
onSelectMmsi={setSelectedMmsi}
legacyIndex={legacyIndex}
/>
</div>
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
<div className="sb-t">ADMIN · </div>
{zonesError ? (
<div style={{ fontSize: 11, color: "#EF4444" }}>zones load error: {zonesError}</div>
) : (
<div style={{ fontSize: 11, color: "var(--muted)" }}>
{zones ? `loaded (${zones.features.length} features)` : "loading..."}
</div>
)}
</div>
</>
) : null}
</div>
<div className="map-area">
<div className="relative bg-[#010610]">
{showMapLoader ? (
<div className="map-loader-overlay" role="status" aria-live="polite">
<div className="map-loader-overlay__panel">
@ -754,12 +363,22 @@ export function DashboardPage() {
mapStyleSettings={mapStyleSettings}
initialView={mapView}
onViewStateChange={setMapView}
activeTrack={activeTrack}
activeTrack={null}
trackContextMenu={trackContextMenu}
onRequestTrack={handleRequestTrack}
onCloseTrackMenu={handleCloseTrackMenu}
onOpenTrackMenu={handleOpenTrackMenu}
onMapReady={handleMapReady}
alarmMmsiMap={alarmMmsiMap}
/>
<GlobalTrackReplayPanel />
<WeatherPanel
snapshot={weather.snapshot}
isLoading={weather.isLoading}
error={weather.error}
onRefresh={weather.refresh}
/>
<WeatherOverlayPanel {...weatherOverlay} />
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
<DepthLegend depthStops={mapStyleSettings.depthStops} />
<MapLegend />

파일 보기

@ -0,0 +1,406 @@
import { useEffect, useState } from 'react';
import { ToggleButton, Section } from '@wing/ui';
import type { AisTarget } from '../../entities/aisTarget/model/types';
import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib';
import type { LegacyVesselDataset, LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
import type { VesselTypeCode } from '../../entities/vessel/model/types';
import { VESSEL_TYPE_ORDER } from '../../entities/vessel/model/meta';
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
import type { AisPollingSnapshot } from '../../features/aisPolling/useAisTargetPolling';
import { Map3DSettingsToggles } from '../../features/map3dSettings/Map3DSettingsToggles';
import type { DerivedLegacyVessel, LegacyAlarm, LegacyAlarmKind } from '../../features/legacyDashboard/model/types';
import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from '../../features/legacyDashboard/model/types';
import { MapToggles } from '../../features/mapToggles/MapToggles';
import { TypeFilterGrid } from '../../features/typeFilter/TypeFilterGrid';
import { AisTargetList } from '../../widgets/aisTargetList/AisTargetList';
import { AlarmsPanel } from '../../widgets/alarms/AlarmsPanel';
import { RelationsPanel } from '../../widgets/relations/RelationsPanel';
import { SpeedProfilePanel } from '../../widgets/speed/SpeedProfilePanel';
import { VesselList } from '../../widgets/vesselList/VesselList';
import { fmtIsoFull } from '../../shared/lib/datetime';
import type { useDashboardState } from './useDashboardState';
import type { Bbox } from './useDashboardState';
const AIS_API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, '');
function fmtBbox(b: Bbox | null) {
if (!b) return '-';
return `${b[0].toFixed(4)},${b[1].toFixed(4)},${b[2].toFixed(4)},${b[3].toFixed(4)}`;
}
interface DashboardSidebarProps {
isOpen: boolean;
onClose: () => void;
state: ReturnType<typeof useDashboardState>;
legacyVesselsAll: DerivedLegacyVessel[];
legacyVesselsFiltered: DerivedLegacyVessel[];
legacyCounts: Record<VesselTypeCode, number>;
selectedLegacyVessel: DerivedLegacyVessel | null;
activeHighlightedMmsiSet: number[];
legacyHits: Map<number, LegacyVesselInfo>;
filteredAlarms: LegacyAlarm[];
alarms: LegacyAlarm[];
alarmKindCounts: Record<LegacyAlarmKind, number>;
speedPanelType: VesselTypeCode;
onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void;
snapshot: AisPollingSnapshot;
legacyError: string | null;
legacyData: LegacyVesselDataset | null;
targetsInScope: AisTarget[];
zonesError: string | null;
zones: ZonesGeoJson | null;
legacyIndex: LegacyVesselIndex | null;
}
export function DashboardSidebar({
isOpen,
onClose,
state,
legacyVesselsAll,
legacyVesselsFiltered,
legacyCounts,
selectedLegacyVessel,
activeHighlightedMmsiSet,
legacyHits,
filteredAlarms,
alarms,
alarmKindCounts,
speedPanelType,
onFleetContextMenu,
snapshot,
legacyError,
legacyData,
targetsInScope,
zonesError,
zones,
legacyIndex,
}: DashboardSidebarProps) {
const {
showTargets, setShowTargets, showOthers, setShowOthers,
typeEnabled, setTypeEnabled,
overlays, setOverlays,
projection, setProjection, isProjectionToggleDisabled,
selectedMmsi, setSelectedMmsi,
fleetRelationSortMode, setFleetRelationSortMode,
hoveredFleetOwnerKey, hoveredFleetMmsiSet,
setHoveredMmsiSet, setHoveredPairMmsiSet,
setHoveredFleetOwnerKey, setHoveredFleetMmsiSet,
alarmKindEnabled, setAlarmKindEnabled,
adminMode,
viewBbox, useViewportFilter, setUseViewportFilter,
useApiBbox, setUseApiBbox, setApiBbox,
settings, setSettings,
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
} = state;
const [isAlarmFilterOpen, setIsAlarmFilterOpen] = useState(false);
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [isOpen, onClose]);
return (
<>
{isOpen && (
<div className="fixed inset-0 z-[1100] bg-black/50 md:hidden" onClick={onClose} aria-hidden />
)}
<div
className={`
fixed inset-y-0 left-0 z-[1200] w-[310px] max-w-[100vw] transform overflow-y-auto
border-r border-wing-border bg-wing-surface transition-transform duration-200
${isOpen ? 'translate-x-0' : '-translate-x-full'}
md:static md:z-auto md:translate-x-0 md:transition-none md:overflow-hidden md:flex md:flex-col
`}
>
<div className="h-[44px] md:hidden" />
<Section title="업종 필터" className="md:shrink-0">
<div className="flex flex-wrap gap-0.75 mb-1.5">
<ToggleButton
on={showTargets}
onClick={() => {
setShowTargets((v) => {
const next = !v;
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
return next;
});
}}
title="레거시(CN permit) 대상 선박 표시"
>
</ToggleButton>
<ToggleButton on={showOthers} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
AIS
</ToggleButton>
</div>
<TypeFilterGrid
enabled={typeEnabled}
totalCount={legacyVesselsAll.length}
countsByType={legacyCounts}
onToggle={(code) => {
if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null);
setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] }));
}}
onToggleAll={() => {
const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]);
const nextVal = !allOn;
if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null);
setTypeEnabled({ PT: nextVal, 'PT-S': nextVal, GN: nextVal, OT: nextVal, PS: nextVal, FC: nextVal });
}}
/>
</Section>
<Section
title="지도 표시 설정"
className="md:shrink-0"
actions={
<ToggleButton
on={projection === 'globe'}
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
title={isProjectionToggleDisabled ? '3D 모드 준비 중...' : '3D 지구본 투영'}
className={`px-2 py-0.5 text-[9px]${isProjectionToggleDisabled ? " opacity-40 cursor-not-allowed" : ""}`}
>
3D
</ToggleButton>
}
>
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
</Section>
<Section title="속도 프로파일" defaultOpen={false} className="md:shrink-0">
<SpeedProfilePanel selectedType={speedPanelType} />
</Section>
<Section
title={
<>
{' '}
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : '(선박 클릭 시 표시)'}
</span>
</>
}
actions={
<div className="relation-sort">
<label className="relation-sort__option">
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'count'} onChange={() => setFleetRelationSortMode('count')} />
</label>
<label className="relation-sort__option">
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'range'} onChange={() => setFleetRelationSortMode('range')} />
</label>
</div>
}
className="md:shrink-0 max-h-[260px] flex flex-col overflow-hidden"
contentClassName="flex-1 min-h-0 overflow-y-auto"
>
<RelationsPanel
selectedVessel={selectedLegacyVessel}
vessels={legacyVesselsAll}
fleetVessels={legacyVesselsFiltered}
onSelectMmsi={setSelectedMmsi}
onToggleHighlightMmsi={toggleHighlightedMmsi}
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
onClearHover={() => setHoveredMmsiSet([])}
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
onClearPairHover={() => setHoveredPairMmsiSet([])}
onHoverFleet={(ownerKey, fleetMmsis) => {
setHoveredFleetOwnerKey(ownerKey);
setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis));
}}
onClearFleetHover={() => {
setHoveredFleetOwnerKey(null);
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
}}
fleetSortMode={fleetRelationSortMode}
hoveredFleetOwnerKey={hoveredFleetOwnerKey}
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
onContextMenuFleet={onFleetContextMenu}
/>
</Section>
<Section
title={
<>
{' '}
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({legacyVesselsFiltered.length})</span>
</>
}
className="md:flex-1 md:min-h-0 flex flex-col overflow-hidden"
contentClassName="md:flex-1 md:min-h-0 flex flex-col overflow-hidden"
>
<VesselList
vessels={legacyVesselsFiltered}
selectedMmsi={selectedMmsi}
highlightedMmsiSet={activeHighlightedMmsiSet}
onSelectMmsi={setSelectedMmsi}
onToggleHighlightMmsi={toggleHighlightedMmsi}
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
onClearHover={() => setHoveredMmsiSet([])}
/>
</Section>
<Section
title={
<>
{' '}
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({filteredAlarms.length}/{alarms.length})</span>
</>
}
actions={
<ToggleButton on={isAlarmFilterOpen} onClick={() => setIsAlarmFilterOpen((v) => !v)}>
</ToggleButton>
}
className="md:shrink-0 max-h-[180px] flex flex-col overflow-hidden"
contentClassName="flex-1 min-h-0 overflow-y-auto"
>
{isAlarmFilterOpen && (
<div className="flex flex-wrap gap-x-2.5 gap-y-1 mb-1.5">
{LEGACY_ALARM_KINDS.map((k) => (
<label key={k} className="inline-flex gap-1 items-center cursor-pointer select-none">
<input type="checkbox" checked={!!alarmKindEnabled[k]} onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))} />
<span className="text-[8px] text-wing-muted whitespace-nowrap">
{LEGACY_ALARM_KIND_LABEL[k]} <span className="text-wing-text">{alarmKindCounts[k] ?? 0}</span>
</span>
</label>
))}
</div>
)}
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
</Section>
{adminMode ? (
<>
<Section title="ADMIN · AIS Target Polling" defaultOpen={false} className="md:shrink-0">
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ wordBreak: 'break-all' }}>{AIS_API_BASE}/api/ais-target/search</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}></div>
<div>
<b style={{ color: snapshot.status === 'ready' ? 'var(--wing-success)' : snapshot.status === 'error' ? 'var(--wing-danger)' : 'var(--wing-warning)' }}>
{snapshot.status.toUpperCase()}
</b>
{snapshot.error ? <span style={{ marginLeft: 6, color: 'var(--wing-danger)' }}>{snapshot.error}</span> : null}
</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}> fetch</div>
<div>
{fmtIsoFull(snapshot.lastFetchAt)}{' '}
<span style={{ color: 'var(--muted)', fontSize: 10 }}>
({snapshot.lastFetchMinutes ?? '-'}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
</span>
</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ color: 'var(--text)', fontSize: 10 }}>{snapshot.lastMessage ?? '-'}</div>
</div>
</Section>
<Section title="ADMIN · Legacy (CN Permit)" defaultOpen={false} className="md:shrink-0">
{legacyError ? (
<div style={{ fontSize: 11, color: 'var(--wing-danger)' }}>legacy load error: {legacyError}</div>
) : (
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>( scope)</div>
<div>
<b style={{ color: 'var(--wing-warning)' }}>{legacyVesselsAll.length}</b>{' '}
<span style={{ color: 'var(--muted)', fontSize: 10 }}>/ {targetsInScope.length}</span>
</div>
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ fontSize: 10, color: 'var(--text)' }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : 'loading...'}</div>
</div>
)}
</Section>
<Section title="ADMIN · Viewport / BBox" defaultOpen={false} className="md:shrink-0">
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: 'var(--muted)', fontSize: 10 }}> View BBox</div>
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button
onClick={() => setUseViewportFilter((v) => !v)}
style={{
fontSize: 10, padding: '4px 8px', borderRadius: 6,
border: '1px solid var(--border)',
background: useViewportFilter ? 'rgba(59,130,246,.18)' : 'var(--card)',
color: 'var(--text)', cursor: 'pointer',
}}
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
>
Viewport filter {useViewportFilter ? 'ON' : 'OFF'}
</button>
<button
onClick={() => {
if (!viewBbox) return;
setUseApiBbox((v) => {
const next = !v;
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
if (!next) setApiBbox(undefined);
return next;
});
}}
style={{
fontSize: 10, padding: '4px 8px', borderRadius: 6,
border: '1px solid var(--border)',
background: useApiBbox ? 'rgba(245,158,11,.14)' : 'var(--card)',
color: viewBbox ? 'var(--text)' : 'var(--muted)',
cursor: viewBbox ? 'pointer' : 'not-allowed',
}}
title="서버에서 bbox로 필터링해서 내려받기"
disabled={!viewBbox}
>
API bbox {useApiBbox ? 'ON' : 'OFF'}
</button>
<button
onClick={() => { if (!viewBbox) return; setApiBbox(fmtBbox(viewBbox)); setUseApiBbox(true); }}
style={{
fontSize: 10, padding: '4px 8px', borderRadius: 6,
border: '1px solid var(--border)', background: 'var(--card)',
color: viewBbox ? 'var(--text)' : 'var(--muted)', cursor: viewBbox ? 'pointer' : 'not-allowed',
}}
disabled={!viewBbox}
title="현재 view bbox로 API bbox를 갱신"
>
bbox=viewport
</button>
</div>
<div style={{ marginTop: 8, color: 'var(--muted)', fontSize: 10 }}>
: <b style={{ color: 'var(--text)' }}>{targetsInScope.length}</b> / :{' '}
<b style={{ color: 'var(--text)' }}>{snapshot.total}</b>
</div>
</div>
</Section>
<Section title="ADMIN · Map (Extras)" defaultOpen={false} className="md:shrink-0">
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}> WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
</Section>
<Section
title="ADMIN · AIS Targets (All)"
defaultOpen={false}
className="md:shrink-0 md:max-h-[200px] flex flex-col md:overflow-hidden"
contentClassName="md:flex-1 md:min-h-0 md:overflow-y-auto"
>
<AisTargetList targets={targetsInScope} selectedMmsi={selectedMmsi} onSelectMmsi={setSelectedMmsi} legacyIndex={legacyIndex} />
</Section>
<Section title="ADMIN · 수역 데이터" defaultOpen={false} className="md:shrink-0">
{zonesError ? (
<div style={{ fontSize: 11, color: 'var(--wing-danger)' }}>zones load error: {zonesError}</div>
) : (
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{zones ? `loaded (${zones.features.length} features)` : 'loading...'}</div>
)}
</Section>
</>
) : null}
</div>
</>
);
}

파일 보기

@ -0,0 +1,147 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { usePersistedState } from '../../shared/hooks';
import type { VesselTypeCode } from '../../entities/vessel/model/types';
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
import type { LegacyAlarmKind } from '../../features/legacyDashboard/model/types';
import { LEGACY_ALARM_KINDS } from '../../features/legacyDashboard/model/types';
import type { BaseMapId, Map3DSettings, MapProjectionId } from '../../widgets/map3d/Map3D';
import type { MapViewState } from '../../widgets/map3d/types';
import { DEFAULT_MAP_STYLE_SETTINGS } from '../../features/mapSettings/types';
import type { MapStyleSettings } from '../../features/mapSettings/types';
import { fmtDateTimeFull } from '../../shared/lib/datetime';
export type Bbox = [number, number, number, number];
export type FleetRelationSortMode = 'count' | 'range';
export function useDashboardState(uid: number | null) {
// ── Map instance ──
const [mapInstance, setMapInstance] = useState<import('maplibre-gl').Map | null>(null);
const handleMapReady = useCallback((map: import('maplibre-gl').Map) => setMapInstance(map), []);
// ── Viewport / API BBox ──
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
const [useViewportFilter, setUseViewportFilter] = useState(false);
const [useApiBbox, setUseApiBbox] = useState(false);
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
// ── Selection & hover ──
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
const [highlightedMmsiSet, setHighlightedMmsiSet] = useState<number[]>([]);
const [hoveredMmsiSet, setHoveredMmsiSet] = useState<number[]>([]);
const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState<number[]>([]);
const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState<number[]>([]);
const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState<string | null>(null);
// ── Filters (persisted) ──
const [typeEnabled, setTypeEnabled] = usePersistedState<Record<VesselTypeCode, boolean>>(
uid, 'typeEnabled', { PT: true, 'PT-S': true, GN: true, OT: true, PS: true, FC: true },
);
const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true);
const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false);
// ── Map settings (persisted) ──
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [baseMap, _setBaseMap] = useState<BaseMapId>('enhanced');
const [projection, setProjection] = useState<MapProjectionId>('mercator');
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
pairLines: true, pairRange: true, fcLines: true, zones: true,
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false,
});
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
showShips: true, showDensity: false, showSeamark: false,
});
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
// ── Sort & alarm filters (persisted) ──
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', 'count');
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
uid, 'alarmKindEnabled',
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>,
);
// ── Fleet focus ──
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
// ── Cable ──
const [hoveredCableId, setHoveredCableId] = useState<string | null>(null);
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
// ── Track context menu ──
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null);
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => setTrackContextMenu(info), []);
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
// ── Projection loading ──
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
const [isGlobeShipsReady, setIsGlobeShipsReady] = useState(false);
const handleProjectionLoadingChange = useCallback((loading: boolean) => setIsProjectionLoading(loading), []);
const showMapLoader = isProjectionLoading;
const isProjectionToggleDisabled = !isGlobeShipsReady || isProjectionLoading;
// ── Clock ──
const [clock, setClock] = useState(() => fmtDateTimeFull(new Date()));
useEffect(() => {
const id = window.setInterval(() => setClock(fmtDateTimeFull(new Date())), 1000);
return () => window.clearInterval(id);
}, []);
// ── Admin mode (7 clicks within 900ms) ──
const [adminMode, setAdminMode] = useState(false);
const clicksRef = useRef<number[]>([]);
const onLogoClick = () => {
const now = Date.now();
clicksRef.current = clicksRef.current.filter((t) => now - t < 900);
clicksRef.current.push(now);
if (clicksRef.current.length >= 7) {
clicksRef.current = [];
setAdminMode((v) => !v);
}
};
// ── Helpers ──
const setUniqueSorted = (items: number[]) =>
Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b);
const setSortedIfChanged = (next: number[]) => {
const sorted = setUniqueSorted(next);
return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted);
};
const toggleHighlightedMmsi = (mmsi: number) => {
setHighlightedMmsiSet((prev) => {
const s = new Set(prev);
if (s.has(mmsi)) s.delete(mmsi);
else s.add(mmsi);
return Array.from(s).sort((a, b) => a - b);
});
};
return {
mapInstance, handleMapReady,
viewBbox, setViewBbox, useViewportFilter, setUseViewportFilter,
useApiBbox, setUseApiBbox, apiBbox, setApiBbox,
selectedMmsi, setSelectedMmsi,
highlightedMmsiSet,
hoveredMmsiSet, setHoveredMmsiSet,
hoveredFleetMmsiSet, setHoveredFleetMmsiSet,
hoveredPairMmsiSet, setHoveredPairMmsiSet,
hoveredFleetOwnerKey, setHoveredFleetOwnerKey,
typeEnabled, setTypeEnabled, showTargets, setShowTargets, showOthers, setShowOthers,
baseMap, projection, setProjection,
mapStyleSettings, setMapStyleSettings,
overlays, setOverlays, settings, setSettings,
mapView, setMapView,
fleetRelationSortMode, setFleetRelationSortMode,
alarmKindEnabled, setAlarmKindEnabled,
fleetFocus, setFleetFocus,
hoveredCableId, setHoveredCableId, selectedCableId, setSelectedCableId,
trackContextMenu, handleOpenTrackMenu, handleCloseTrackMenu,
handleProjectionLoadingChange,
isGlobeShipsReady, setIsGlobeShipsReady,
showMapLoader, isProjectionToggleDisabled,
clock, adminMode, onLogoClick,
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
};
}

파일 보기

@ -1 +1,2 @@
export { usePersistedState } from './usePersistedState';
export { useTheme } from './useTheme';

파일 보기

@ -0,0 +1,47 @@
import { useState, useEffect, useCallback } from 'react';
type Theme = 'dark' | 'light';
const STORAGE_KEY = 'wing:theme';
const DEFAULT_THEME: Theme = 'dark';
function readTheme(): Theme {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === 'light' || raw === 'dark') return raw;
} catch {
// storage unavailable
}
return DEFAULT_THEME;
}
function applyTheme(theme: Theme) {
document.documentElement.dataset.theme = theme;
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
const t = readTheme();
applyTheme(t);
return t;
});
useEffect(() => {
applyTheme(theme);
}, [theme]);
const setTheme = useCallback((t: Theme) => {
setThemeState(t);
try {
localStorage.setItem(STORAGE_KEY, t);
} catch {
// quota exceeded or unavailable
}
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === 'dark' ? 'light' : 'dark');
}, [theme, setTheme]);
return { theme, setTheme, toggleTheme } as const;
}

파일 보기

@ -1,7 +0,0 @@
export function hexToRgb(hex: string): [number, number, number] {
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
if (!m) return [255, 255, 255];
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

파일 보기

@ -0,0 +1,20 @@
// ── Shared map constants ──
// Moved from widgets/map3d/constants.ts to resolve FSD layer violation
// (features/ must not import from widgets/).
export const SHIP_ICON_MAPPING = {
ship: {
x: 0,
y: 0,
width: 128,
height: 128,
anchorX: 64,
anchorY: 64,
mask: true,
},
} as const;
export const DEPTH_DISABLED_PARAMS = {
depthCompare: 'always',
depthWriteEnabled: false,
} as const;

파일 보기

@ -0,0 +1,9 @@
// Moved from widgets/map3d/lib/mapCore.ts to resolve FSD layer violation
// (features/ must not import from widgets/).
export function getMapTilerKey(): string | null {
const k = import.meta.env.VITE_MAPTILER_KEY;
if (typeof k !== 'string') return null;
const v = k.trim();
return v ? v : null;
}

파일 보기

@ -1,114 +1,121 @@
import { useState } from 'react';
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
export function MapLegend() {
const [isOpen, setIsOpen] = useState(true);
return (
<div className="map-legend">
<div className="lt"></div>
{ZONE_IDS.map((z) => (
<div key={z} className="li">
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
{ZONE_META[z].name}
</div>
))}
<button
className="flex w-full cursor-pointer items-center justify-between border-none bg-transparent p-0 text-left"
onClick={() => setIsOpen((v) => !v)}
>
<span className="lt" style={{ marginBottom: 0 }}></span>
<span className="text-[9px] text-wing-muted">{isOpen ? '▾' : '▸'}</span>
</button>
<div className="lt" style={{ marginTop: 8 }}>
AIS ()
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
SOG 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
1 SOG &lt; 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
SOG &lt; 1 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
SOG unknown
</div>
{isOpen && (
<>
<div className="lt" style={{ marginTop: 6 }}></div>
{ZONE_IDS.map((z) => (
<div key={z} className="li">
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
{ZONE_META[z].name}
</div>
))}
<div className="lt" style={{ marginTop: 8 }}>
CN Permit()
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
PT (ring + )
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
PT-S
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.GN, borderRadius: 999 }} />
GN
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.OT, borderRadius: 999 }} />
OT 1
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PS, borderRadius: 999 }} />
PS
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.FC, borderRadius: 999 }} />
FC
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.C21, borderRadius: 999 }} />
C21
</div>
<div className="lt" style={{ marginTop: 8 }}> AIS ()</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
SOG 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
1 SOG &lt; 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
SOG &lt; 1 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
SOG unknown
</div>
<div className="lt" style={{ marginTop: 8 }}>
(3D)
</div>
<div className="li" style={{ color: "var(--muted)" }}>
Hexagon: 화면 AIS
</div>
<div className="lt" style={{ marginTop: 8 }}>CN Permit()</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
PT
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
PT-S
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.GN, borderRadius: 999 }} />
GN
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.OT, borderRadius: 999 }} />
OT 1
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PS, borderRadius: 999 }} />
PS
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.FC, borderRadius: 999 }} />
FC
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.C21, borderRadius: 999 }} />
C21
</div>
<div className="lt" style={{ marginTop: 8 }}>
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
PTPT-S ()
</div>
<div className="li">
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.pairNormal, 0.6)}` }} />
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.pairWarn), borderRadius: 1 }} />
(&gt;3NM)
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.fcTransfer), borderRadius: 1 }} />
FC (dashed)
</div>
<div className="li">
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.fleetRange, 0.75)}`, opacity: 0.8 }} />
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
FC ()
</div>
<div className="li">
<div
style={{
width: 20,
height: 2,
borderRadius: 1,
background: "repeating-linear-gradient(to right, rgba(226,232,240,0.55), rgba(226,232,240,0.55) 4px, rgba(0,0,0,0) 4px, rgba(0,0,0,0) 7px)",
}}
/>
(15)
</div>
<div className="lt" style={{ marginTop: 8 }}>(3D)</div>
<div className="li" style={{ color: "var(--muted)" }}>
Hexagon: 화면 AIS
</div>
<div className="lt" style={{ marginTop: 8 }}></div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
PTPT-S ()
</div>
<div className="li">
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.pairNormal, 0.6)}` }} />
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.pairWarn), borderRadius: 1 }} />
(&gt;3NM)
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.fcTransfer), borderRadius: 1 }} />
FC (dashed)
</div>
<div className="li">
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.fleetRange, 0.75)}`, opacity: 0.8 }} />
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
FC ()
</div>
<div className="li">
<div
style={{
width: 20,
height: 2,
borderRadius: 1,
background: "repeating-linear-gradient(to right, rgba(226,232,240,0.55), rgba(226,232,240,0.55) 4px, rgba(0,0,0,0) 4px, rgba(0,0,0,0) 7px)",
}}
/>
(15)
</div>
</>
)}
</div>
);
}

파일 보기

@ -26,9 +26,13 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays';
import { useGlobeInteraction } from './hooks/useGlobeInteraction';
import { useDeckLayers } from './hooks/useDeckLayers';
import { useSubcablesLayer } from './hooks/useSubcablesLayer';
import { useVesselTrackLayer } from './hooks/useVesselTrackLayer';
import { useTrackReplayLayer } from './hooks/useTrackReplayLayer';
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
import { VesselContextMenu } from './components/VesselContextMenu';
import { useLiveShipAdapter } from '../../features/liveRenderer/hooks/useLiveShipAdapter';
import { useLiveShipBatchRender } from '../../features/liveRenderer/hooks/useLiveShipBatchRender';
import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore';
import { useTrackReplayDeckLayers } from '../../features/trackReplay/hooks/useTrackReplayDeckLayers';
export type { Map3DSettings, BaseMapId, MapProjectionId } from './types';
@ -76,14 +80,9 @@ export function Map3D({
onRequestTrack,
onCloseTrackMenu,
onOpenTrackMenu,
onMapReady,
alarmMmsiMap,
}: Props) {
void onHoverFleet;
void onClearFleetHover;
void onHoverMmsi;
void onClearMmsiHover;
void onHoverPair;
void onClearPairHover;
// ── Shared refs ──────────────────────────────────────────────────────
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
@ -95,6 +94,7 @@ export function Map3D({
const projectionBusyRef = useRef(false);
const deckHoverRafRef = useRef<number | null>(null);
const deckHoverHasHitRef = useRef(false);
const mapInitiatedSelectRef = useRef(false);
useEffect(() => { baseMapRef.current = baseMap; }, [baseMap]);
useEffect(() => { projectionRef.current = projection; }, [projection]);
@ -200,20 +200,38 @@ export function Map3D({
);
// ── Ship data memos ──────────────────────────────────────────────────
const shipData = useMemo(() => {
const rawShipData = useMemo(() => {
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi));
}, [targets]);
const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips);
const liveShipFeatures = useLiveShipAdapter(rawShipData, legacyHits ?? null);
const { renderedTargets: batchRenderedTargets } = useLiveShipBatchRender(
mapRef,
liveShipFeatures,
rawShipData,
mapSyncEpoch,
);
const shipData = useMemo(
() => (hideLiveShips ? [] : rawShipData),
[hideLiveShips, rawShipData],
);
const shipByMmsi = useMemo(() => {
const byMmsi = new Map<number, AisTarget>();
for (const t of shipData) byMmsi.set(t.mmsi, t);
for (const t of rawShipData) byMmsi.set(t.mmsi, t);
return byMmsi;
}, [shipData]);
}, [rawShipData]);
const shipLayerData = useMemo(() => {
if (shipData.length === 0) return shipData;
return [...shipData];
}, [shipData]);
if (hideLiveShips) return [];
// Fallback to raw targets when batch result is temporarily empty
// (e.g. overlay update race or viewport sync delay).
if (batchRenderedTargets.length === 0) return rawShipData;
return [...batchRenderedTargets];
}, [hideLiveShips, batchRenderedTargets, rawShipData]);
const shipHighlightSet = useMemo(() => {
const out = new Set(highlightedMmsiSetForShips);
@ -221,12 +239,15 @@ export function Map3D({
return out;
}, [highlightedMmsiSetForShips, selectedMmsi]);
// Globe: 직접 호버/선택된 선박만 hover overlay에 포함
// 선단/쌍 멤버는 feature-state(outline 색상)로 하이라이트 → hover overlay 불필요
// → alarm badge 레이어 가림 방지
const shipHoverOverlaySet = useMemo(
() =>
projection === 'globe'
? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet)
? mergeNumberSets(shipHighlightSet, hoveredDeckMmsiSetRef)
: shipHighlightSet,
[projection, highlightedMmsiSetCombined, shipHighlightSet],
[projection, shipHighlightSet, hoveredDeckMmsiSetRef],
);
const shipOverlayLayerData = useMemo(() => {
@ -235,6 +256,8 @@ export function Map3D({
return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi));
}, [shipHighlightSet, shipLayerData]);
const trackReplayRenderState = useTrackReplayDeckLayers();
// ── Deck hover management ────────────────────────────────────────────
const hasAuxiliarySelectModifier = useCallback(
(ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null): boolean => {
@ -254,6 +277,13 @@ export function Map3D({
return out;
}, []);
// 지도 내부 클릭에서의 선택 — fly-to 비활성화 플래그 설정
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const onMapSelectMmsi = useCallback((mmsi: number | null) => {
mapInitiatedSelectRef.current = true;
onSelectMmsi(mmsi);
}, [onSelectMmsi]);
const onDeckSelectOrHighlight = useCallback(
(info: unknown, allowMultiSelect = false) => {
const obj = info as {
@ -269,12 +299,12 @@ export function Map3D({
return;
}
if (!allowMultiSelect && selectedMmsi === mmsi) {
onSelectMmsi(null);
onMapSelectMmsi(null);
return;
}
onSelectMmsi(mmsi);
onMapSelectMmsi(mmsi);
},
[hasAuxiliarySelectModifier, onSelectMmsi, onToggleHighlightMmsi, selectedMmsi],
[hasAuxiliarySelectModifier, onMapSelectMmsi, onToggleHighlightMmsi, selectedMmsi],
);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
@ -294,22 +324,51 @@ export function Map3D({
ownerKey: null,
vesselMmsis: [],
});
const mapDrivenMmsiHoverRef = useRef(false);
const mapDrivenPairHoverRef = useRef(false);
const mapDrivenFleetHoverRef = useRef(false);
const clearMapFleetHoverState = useCallback(() => {
const prev = mapFleetHoverStateRef.current;
mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] };
setHoveredDeckFleetOwner(null);
setHoveredDeckFleetMmsis([]);
}, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]);
if (
mapDrivenFleetHoverRef.current &&
(prev.ownerKey != null || prev.vesselMmsis.length > 0) &&
hoveredFleetOwnerKey === prev.ownerKey &&
equalNumberArrays(hoveredFleetMmsiSet, prev.vesselMmsis)
) {
onClearFleetHover?.();
}
mapDrivenFleetHoverRef.current = false;
}, [
setHoveredDeckFleetOwner,
setHoveredDeckFleetMmsis,
onClearFleetHover,
hoveredFleetOwnerKey,
hoveredFleetMmsiSet,
]);
const clearDeckHoverPairs = useCallback(() => {
const prev = mapDeckPairHoverRef.current;
mapDeckPairHoverRef.current = [];
setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
}, [setHoveredDeckPairMmsiSet]);
if (mapDrivenPairHoverRef.current && prev.length > 0 && equalNumberArrays(hoveredPairMmsiSet, prev)) {
onClearPairHover?.();
}
mapDrivenPairHoverRef.current = false;
}, [setHoveredDeckPairMmsiSet, onClearPairHover, hoveredPairMmsiSet]);
const clearDeckHoverMmsi = useCallback(() => {
const prev = mapDeckMmsiHoverRef.current;
mapDeckMmsiHoverRef.current = [];
setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : []));
}, [setHoveredDeckMmsiSet]);
if (mapDrivenMmsiHoverRef.current && prev.length > 0 && equalNumberArrays(hoveredMmsiSet, prev)) {
onClearMmsiHover?.();
}
mapDrivenMmsiHoverRef.current = false;
}, [setHoveredDeckMmsiSet, onClearMmsiHover, hoveredMmsiSet]);
const scheduleDeckHoverResolve = useCallback(() => {
if (deckHoverRafRef.current != null) return;
@ -335,21 +394,41 @@ export function Map3D({
const setDeckHoverMmsi = useCallback(
(next: number[]) => {
const normalized = makeUniqueSorted(next);
const prev = mapDeckMmsiHoverRef.current;
touchDeckHoverState(normalized.length > 0);
setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
mapDeckMmsiHoverRef.current = normalized;
if (!equalNumberArrays(prev, normalized)) {
if (normalized.length > 0) {
mapDrivenMmsiHoverRef.current = true;
onHoverMmsi?.(normalized);
} else if (mapDrivenMmsiHoverRef.current && prev.length > 0) {
onClearMmsiHover?.();
mapDrivenMmsiHoverRef.current = false;
}
}
},
[setHoveredDeckMmsiSet, touchDeckHoverState],
[setHoveredDeckMmsiSet, touchDeckHoverState, onHoverMmsi, onClearMmsiHover],
);
const setDeckHoverPairs = useCallback(
(next: number[]) => {
const normalized = makeUniqueSorted(next);
const prev = mapDeckPairHoverRef.current;
touchDeckHoverState(normalized.length > 0);
setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
mapDeckPairHoverRef.current = normalized;
if (!equalNumberArrays(prev, normalized)) {
if (normalized.length > 0) {
mapDrivenPairHoverRef.current = true;
onHoverPair?.(normalized);
} else if (mapDrivenPairHoverRef.current && prev.length > 0) {
onClearPairHover?.();
mapDrivenPairHoverRef.current = false;
}
}
},
[setHoveredDeckPairMmsiSet, touchDeckHoverState],
[setHoveredDeckPairMmsiSet, touchDeckHoverState, onHoverPair, onClearPairHover],
);
const setMapFleetHoverState = useCallback(
@ -363,8 +442,21 @@ export function Map3D({
setHoveredDeckFleetOwner(ownerKey);
setHoveredDeckFleetMmsis(normalized);
mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized };
if (ownerKey != null || normalized.length > 0) {
mapDrivenFleetHoverRef.current = true;
onHoverFleet?.(ownerKey, normalized);
} else if (mapDrivenFleetHoverRef.current) {
onClearFleetHover?.();
mapDrivenFleetHoverRef.current = false;
}
},
[setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState],
[
setHoveredDeckFleetOwner,
setHoveredDeckFleetMmsis,
touchDeckHoverState,
onHoverFleet,
onClearFleetHover,
],
);
// hover RAF cleanup
@ -412,37 +504,37 @@ export function Map3D({
}, [pairLinks]);
const pairLinksInteractive = useMemo(() => {
if (!overlays.pairLines || (pairLinks?.length ?? 0) === 0) return [];
if (hoveredPairMmsiSetRef.size < 2) return [];
if ((pairLinks?.length ?? 0) === 0) return [];
if (effectiveHoveredPairMmsiSet.size < 2) return [];
const links = pairLinks || [];
return links.filter((link) =>
hoveredPairMmsiSetRef.has(link.aMmsi) && hoveredPairMmsiSetRef.has(link.bMmsi),
effectiveHoveredPairMmsiSet.has(link.aMmsi) && effectiveHoveredPairMmsiSet.has(link.bMmsi),
);
}, [pairLinks, hoveredPairMmsiSetRef, overlays.pairLines]);
}, [pairLinks, effectiveHoveredPairMmsiSet]);
const pairRangesInteractive = useMemo(() => {
if (!overlays.pairRange || pairRanges.length === 0) return [];
if (hoveredPairMmsiSetRef.size < 2) return [];
if (pairRanges.length === 0) return [];
if (effectiveHoveredPairMmsiSet.size < 2) return [];
return pairRanges.filter((range) =>
hoveredPairMmsiSetRef.has(range.aMmsi) && hoveredPairMmsiSetRef.has(range.bMmsi),
effectiveHoveredPairMmsiSet.has(range.aMmsi) && effectiveHoveredPairMmsiSet.has(range.bMmsi),
);
}, [pairRanges, hoveredPairMmsiSetRef, overlays.pairRange]);
}, [pairRanges, effectiveHoveredPairMmsiSet]);
const fcLinesInteractive = useMemo(() => {
if (!overlays.fcLines || fcDashed.length === 0) return [];
if (fcDashed.length === 0) return [];
if (highlightedMmsiSetCombined.size === 0) return [];
return fcDashed.filter(
(line) =>
[line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))),
);
}, [fcDashed, overlays.fcLines, highlightedMmsiSetCombined]);
}, [fcDashed, highlightedMmsiSetCombined]);
const fleetCirclesInteractive = useMemo(() => {
if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return [];
if ((fleetCircles?.length ?? 0) === 0) return [];
if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return [];
const circles = fleetCircles || [];
return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis));
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]);
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, highlightedMmsiSetCombined]);
// ── Hook orchestration ───────────────────────────────────────────────
const { ensureMercatorOverlay, pulseMapSync } = useMapInit(
@ -479,11 +571,11 @@ export function Map3D({
useGlobeShips(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
{
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
projection, settings, shipData: shipLayerData, shipHighlightSet, shipHoverOverlaySet,
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
onSelectMmsi, onToggleHighlightMmsi, targets, overlays,
onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
onGlobeShipsReady,
onGlobeShipsReady, alarmMmsiMap,
},
);
@ -508,7 +600,7 @@ export function Map3D({
useDeckLayers(
mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef,
{
projection, settings, shipLayerData, shipOverlayLayerData, shipData,
projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges,
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
@ -516,8 +608,8 @@ export function Map3D({
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, projectionRef,
onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, alarmMmsiMap,
},
);
@ -535,9 +627,9 @@ export function Map3D({
},
);
useVesselTrackLayer(
mapRef, overlayRef, projectionBusyRef, reorderGlobeFeatureLayers,
{ activeTrack, projection, mapSyncEpoch },
useTrackReplayLayer(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
{ activeTrack, projection, mapSyncEpoch, renderState: trackReplayRenderState },
);
// 우클릭 컨텍스트 메뉴 — 대상선박(legacyHits)만 허용
@ -552,22 +644,30 @@ export function Map3D({
e.preventDefault();
if (!onOpenTrackMenu) return;
const map = mapRef.current;
if (!map || !map.isStyleLoaded() || projectionBusyRef.current) return;
if (!map || projectionBusyRef.current) return;
let mmsi: number | null = null;
if (projectionRef.current === 'globe') {
// Globe: MapLibre 네이티브 레이어에서 쿼리
const point: [number, number] = [e.offsetX, e.offsetY];
const shipLayerIds = [
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
].filter((id) => map.getLayer(id));
// Globe/Mercator 공통: MapLibre 레이어에서 bbox 쿼리 (호버 상태 무관)
let shipLayerIds: string[] = [];
try {
shipLayerIds = projectionRef.current === 'globe'
? [
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
].filter((id) => map.getLayer(id))
: [];
} catch { /* ignore */ }
if (shipLayerIds.length > 0) {
const tolerance = 8;
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
[e.offsetX - tolerance, e.offsetY - tolerance],
[e.offsetX + tolerance, e.offsetY + tolerance],
];
let features: maplibregl.MapGeoJSONFeature[] = [];
try {
if (shipLayerIds.length > 0) {
features = map.queryRenderedFeatures(point, { layers: shipLayerIds });
}
features = map.queryRenderedFeatures(bbox, { layers: shipLayerIds });
} catch { /* ignore */ }
if (features.length > 0) {
@ -575,8 +675,10 @@ export function Map3D({
const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi);
if (Number.isFinite(raw) && raw > 0) mmsi = raw;
}
} else {
// Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용
}
// Mercator fallback: Deck.gl 호버 상태에서 MMSI 참조
if (mmsi == null && projectionRef.current !== 'globe') {
const hovered = hoveredDeckMmsiRef.current;
if (hovered.length > 0) mmsi = hovered[0];
}
@ -593,9 +695,17 @@ export function Map3D({
useFlyTo(
mapRef, projectionRef,
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
{ selectedMmsi, shipData, mapInitiatedSelectRef, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
);
// Map ready 콜백 — mapSyncEpoch 초기 증가 시 1회 호출
const mapReadyFiredRef = useRef(false);
useEffect(() => {
if (mapReadyFiredRef.current || !onMapReady || !mapRef.current) return;
mapReadyFiredRef.current = true;
onMapReady(mapRef.current);
}, [mapSyncEpoch, onMapReady]);
return (
<>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />

파일 보기

@ -15,18 +15,9 @@ const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
// ── Ship icon mapping (Deck.gl IconLayer) ──
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
export const SHIP_ICON_MAPPING = {
ship: {
x: 0,
y: 0,
width: 128,
height: 128,
anchorX: 64,
anchorY: 64,
mask: true,
},
} as const;
export { SHIP_ICON_MAPPING } from '../../shared/lib/map/mapConstants';
// ── Ship constants ──
@ -70,10 +61,8 @@ export const DECK_VIEW_ID = 'mapbox';
// ── Depth params ──
export const DEPTH_DISABLED_PARAMS = {
depthCompare: 'always',
depthWriteEnabled: false,
} as const;
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
export { DEPTH_DISABLED_PARAMS } from '../../shared/lib/map/mapConstants';
export const GLOBE_OVERLAY_PARAMS = {
depthCompare: 'less-equal',
@ -104,7 +93,7 @@ export const FLEET_RANGE_LINE_DECK: [number, number, number, number] = [
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 140,
];
export const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 6,
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 0,
];
// ── Highlighted variants ──
@ -131,7 +120,7 @@ export const FLEET_RANGE_LINE_DECK_HL: [number, number, number, number] = [
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 220,
];
export const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 42,
OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 120,
];
// ── MapLibre overlay colors ──
@ -151,8 +140,8 @@ export const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95);
export const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98);
export const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98);
export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02);
export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16);
export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.12);
export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.42);
export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65);
export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95);

파일 보기

@ -1,47 +1,15 @@
import { useEffect, useMemo, type MutableRefObject } from 'react';
import { HexagonLayer } from '@deck.gl/aggregation-layers';
import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers';
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { type PickingInfo } from '@deck.gl/core';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { ScatterplotLayer } from '@deck.gl/layers';
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
import {
SHIP_ICON_MAPPING,
FLAT_SHIP_ICON_SIZE,
FLAT_SHIP_ICON_SIZE_SELECTED,
FLAT_SHIP_ICON_SIZE_HIGHLIGHTED,
FLAT_LEGACY_HALO_RADIUS,
FLAT_LEGACY_HALO_RADIUS_SELECTED,
FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED,
EMPTY_MMSI_SET,
DEPTH_DISABLED_PARAMS,
GLOBE_OVERLAY_PARAMS,
HALO_OUTLINE_COLOR,
HALO_OUTLINE_COLOR_SELECTED,
HALO_OUTLINE_COLOR_HIGHLIGHTED,
PAIR_RANGE_NORMAL_DECK,
PAIR_RANGE_WARN_DECK,
PAIR_LINE_NORMAL_DECK,
PAIR_LINE_WARN_DECK,
FC_LINE_NORMAL_DECK,
FC_LINE_SUSPICIOUS_DECK,
FLEET_RANGE_LINE_DECK,
FLEET_RANGE_FILL_DECK,
PAIR_RANGE_NORMAL_DECK_HL,
PAIR_RANGE_WARN_DECK_HL,
PAIR_LINE_NORMAL_DECK_HL,
PAIR_LINE_WARN_DECK_HL,
FC_LINE_NORMAL_DECK_HL,
FC_LINE_SUSPICIOUS_DECK_HL,
FLEET_RANGE_LINE_DECK_HL,
FLEET_RANGE_FILL_DECK_HL,
} from '../constants';
import { toSafeNumber } from '../lib/setUtils';
import { getDisplayHeading, getShipColor } from '../lib/shipUtils';
import {
getShipTooltipHtml,
getPairLinkTooltipHtml,
@ -50,7 +18,7 @@ import {
getFleetCircleTooltipHtml,
} from '../lib/tooltips';
import { sanitizeDeckLayerList } from '../lib/mapCore';
import { getCachedShipIcon } from '../lib/shipIconCache';
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
// NOTE:
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
@ -66,6 +34,7 @@ export function useDeckLayers(
opts: {
projection: MapProjectionId;
settings: Map3DSettings;
trackReplayDeckLayers: unknown[];
shipLayerData: AisTarget[];
shipOverlayLayerData: AisTarget[];
shipData: AisTarget[];
@ -99,11 +68,11 @@ export function useDeckLayers(
onSelectMmsi: (mmsi: number | null) => void;
onToggleHighlightMmsi?: (mmsi: number) => void;
ensureMercatorOverlay: () => MapboxOverlay | null;
projectionRef: MutableRefObject<MapProjectionId>;
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
},
) {
const {
projection, settings, shipLayerData, shipOverlayLayerData, shipData,
projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
@ -112,7 +81,7 @@ export function useDeckLayers(
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, projectionRef,
ensureMercatorOverlay, alarmMmsiMap,
} = opts;
const legacyTargets = useMemo(() => {
@ -132,6 +101,14 @@ export function useDeckLayers(
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
}, [legacyTargets, shipHighlightSet]);
const alarmTargets = useMemo(() => {
if (!alarmMmsiMap || alarmMmsiMap.size === 0) return [];
return shipData.filter((t) => alarmMmsiMap.has(t.mmsi));
}, [shipData, alarmMmsiMap]);
const mercatorLayersRef = useRef<unknown[]>([]);
const alarmRafRef = useRef(0);
// Mercator Deck layers
useEffect(() => {
const map = mapRef.current;
@ -150,345 +127,46 @@ export function useDeckLayers(
const deckTarget = ensureMercatorOverlay();
if (!deckTarget) return;
const layers: unknown[] = [];
const overlayParams = DEPTH_DISABLED_PARAMS;
const clearDeckHover = () => {
touchDeckHoverState(false);
};
const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false);
const shipOtherData: AisTarget[] = [];
const shipTargetData: AisTarget[] = [];
for (const t of shipLayerData) {
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
else shipOtherData.push(t);
}
const shipOverlayOtherData: AisTarget[] = [];
const shipOverlayTargetData: AisTarget[] = [];
for (const t of shipOverlayLayerData) {
if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t);
else shipOverlayOtherData.push(t);
}
const layers = buildMercatorDeckLayers({
shipLayerData,
shipOverlayLayerData,
legacyTargetsOrdered,
legacyOverlayTargets,
legacyHits,
pairLinks,
fcDashed,
fleetCircles,
pairRanges,
pairLinksInteractive,
pairRangesInteractive,
fcLinesInteractive,
fleetCirclesInteractive,
overlays,
showDensity: settings.showDensity,
showShips: settings.showShips,
selectedMmsi,
shipHighlightSet,
touchDeckHoverState,
setDeckHoverPairs,
setDeckHoverMmsi,
clearDeckHoverPairs,
clearMapFleetHoverState,
setMapFleetHoverState,
toFleetMmsiList,
hasAuxiliarySelectModifier,
onSelectMmsi,
onToggleHighlightMmsi,
onDeckSelectOrHighlight,
alarmTargets,
alarmMmsiMap,
alarmPulseRadius: 8,
alarmPulseHoverRadius: 12,
});
if (settings.showDensity) {
layers.push(
new HexagonLayer<AisTarget>({
id: 'density',
data: shipLayerData,
pickable: true,
extruded: true,
radius: 2500,
elevationScale: 35,
coverage: 0.92,
opacity: 0.35,
getPosition: (d) => [d.lon, d.lat],
}),
);
}
if (overlays.pairRange && pairRanges.length > 0) {
layers.push(
new ScatterplotLayer<PairRangeCircle>({
id: 'pair-range',
data: pairRanges,
pickable: true,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
radiusMinPixels: 10,
lineWidthUnits: 'pixels',
getLineWidth: () => 1,
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
getPosition: (d) => d.center,
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
touchDeckHoverState(true);
const p = info.object as PairRangeCircle;
setDeckHoverPairs([p.aMmsi, p.bMmsi]);
setDeckHoverMmsi([p.aMmsi, p.bMmsi]);
clearMapFleetHoverState();
},
onClick: (info) => {
if (!info.object) { onSelectMmsi(null); return; }
const obj = info.object as PairRangeCircle;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
onToggleHighlightMmsi?.(obj.aMmsi);
onToggleHighlightMmsi?.(obj.bMmsi);
return;
}
onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
},
}),
);
}
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
layers.push(
new LineLayer<PairLink>({
id: 'pair-lines',
data: pairLinks,
pickable: true,
parameters: overlayParams,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK),
getWidth: (d) => (d.warn ? 2.2 : 1.4),
widthUnits: 'pixels',
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
touchDeckHoverState(true);
const obj = info.object as PairLink;
setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
clearMapFleetHoverState();
},
onClick: (info) => {
if (!info.object) return;
const obj = info.object as PairLink;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
onToggleHighlightMmsi?.(obj.aMmsi);
onToggleHighlightMmsi?.(obj.bMmsi);
return;
}
onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
},
}),
);
}
if (overlays.fcLines && fcDashed.length > 0) {
layers.push(
new LineLayer<DashSeg>({
id: 'fc-lines',
data: fcDashed,
pickable: true,
parameters: overlayParams,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
getWidth: () => 1.3,
widthUnits: 'pixels',
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
touchDeckHoverState(true);
const obj = info.object as DashSeg;
if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; }
setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]);
setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]);
clearMapFleetHoverState();
},
onClick: (info) => {
if (!info.object) return;
const obj = info.object as DashSeg;
if (obj.fromMmsi == null || obj.toMmsi == null) return;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
onToggleHighlightMmsi?.(obj.fromMmsi);
onToggleHighlightMmsi?.(obj.toMmsi);
return;
}
onDeckSelectOrHighlight({ mmsi: obj.fromMmsi });
},
}),
);
}
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
layers.push(
new ScatterplotLayer<FleetCircle>({
id: 'fleet-circles',
data: fleetCircles,
pickable: true,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
lineWidthUnits: 'pixels',
getLineWidth: () => 1.1,
getLineColor: () => FLEET_RANGE_LINE_DECK,
getPosition: (d) => d.center,
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
touchDeckHoverState(true);
const obj = info.object as FleetCircle;
const list = toFleetMmsiList(obj.vesselMmsis);
setMapFleetHoverState(obj.ownerKey || null, list);
setDeckHoverMmsi(list);
clearDeckHoverPairs();
},
onClick: (info) => {
if (!info.object) return;
const obj = info.object as FleetCircle;
const list = toFleetMmsiList(obj.vesselMmsis);
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) {
for (const mmsi of list) onToggleHighlightMmsi?.(mmsi);
return;
}
const first = list[0];
if (first != null) onDeckSelectOrHighlight({ mmsi: first });
},
}),
);
layers.push(
new ScatterplotLayer<FleetCircle>({
id: 'fleet-circles-fill',
data: fleetCircles,
pickable: false,
billboard: false,
parameters: overlayParams,
filled: true,
stroked: false,
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
getFillColor: () => FLEET_RANGE_FILL_DECK,
getPosition: (d) => d.center,
}),
);
}
if (settings.showShips) {
const shipOnHover = (info: PickingInfo) => {
if (!info.object) { clearDeckHover(); return; }
touchDeckHoverState(true);
const obj = info.object as AisTarget;
setDeckHoverMmsi([obj.mmsi]);
clearDeckHoverPairs();
clearMapFleetHoverState();
};
const shipOnClick = (info: PickingInfo) => {
if (!info.object) { onSelectMmsi(null); return; }
onDeckSelectOrHighlight(
{
mmsi: (info.object as AisTarget).mmsi,
srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent,
},
true,
);
};
if (shipOtherData.length > 0) {
layers.push(
new IconLayer<AisTarget>({
id: 'ships-other',
data: shipOtherData,
pickable: true,
billboard: false,
parameters: overlayParams,
iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number],
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
sizeUnits: 'pixels',
getSize: () => FLAT_SHIP_ICON_SIZE,
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
onHover: shipOnHover,
onClick: shipOnClick,
alphaCutoff: 0.05,
}),
);
}
if (shipOverlayOtherData.length > 0) {
layers.push(
new IconLayer<AisTarget>({
id: 'ships-overlay-other',
data: shipOverlayOtherData,
pickable: false,
billboard: false,
parameters: overlayParams,
iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number],
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
sizeUnits: 'pixels',
getSize: (d) => {
if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED;
if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED;
return 0;
},
getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet),
alphaCutoff: 0.05,
}),
);
}
if (legacyTargetsOrdered.length > 0) {
layers.push(
new ScatterplotLayer<AisTarget>({
id: 'legacy-halo',
data: legacyTargetsOrdered,
pickable: false,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'pixels',
getRadius: () => FLAT_LEGACY_HALO_RADIUS,
lineWidthUnits: 'pixels',
getLineWidth: () => 2,
getLineColor: () => HALO_OUTLINE_COLOR,
getPosition: (d) => [d.lon, d.lat] as [number, number],
}),
);
}
if (shipTargetData.length > 0) {
layers.push(
new IconLayer<AisTarget>({
id: 'ships-target',
data: shipTargetData,
pickable: true,
billboard: false,
parameters: overlayParams,
iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number],
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
sizeUnits: 'pixels',
getSize: () => FLAT_SHIP_ICON_SIZE,
getColor: (d) => getShipColor(d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
onHover: shipOnHover,
onClick: shipOnClick,
alphaCutoff: 0.05,
}),
);
}
}
if (overlays.pairRange && pairRangesInteractive.length > 0) {
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
}
if (overlays.pairLines && pairLinksInteractive.length > 0) {
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' }));
}
if (overlays.fcLines && fcLinesInteractive.length > 0) {
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' }));
}
if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) {
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
}
if (settings.showShips && legacyOverlayTargets.length > 0) {
layers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-overlay', data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
}
if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) {
const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi));
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } }));
}
const normalizedLayers = sanitizeDeckLayerList(layers);
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]);
mercatorLayersRef.current = normalizedLayers;
const deckProps = {
layers: normalizedLayers,
getTooltip: (info: PickingInfo) => {
@ -540,12 +218,6 @@ export function useDeckLayers(
return;
}
onSelectMmsi(t.mmsi);
const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
if (projectionRef.current === 'globe') {
map.flyTo(clickOpts);
} else {
map.easeTo(clickOpts);
}
}
},
};
@ -553,12 +225,7 @@ export function useDeckLayers(
try {
deckTarget.setProps(deckProps as never);
} catch (e) {
console.error('Failed to apply base mercator deck props. Falling back to empty layer set.', e);
try {
deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never);
} catch {
// Ignore secondary failure.
}
console.error('Failed to apply base mercator deck props. Keeping previous layer set.', e);
}
}, [
ensureMercatorOverlay,
@ -581,8 +248,10 @@ export function useDeckLayers(
overlays.pairLines,
overlays.fcLines,
overlays.fleetCircles,
overlays.shipLabels,
settings.showDensity,
settings.showShips,
trackReplayDeckLayers,
onDeckSelectOrHighlight,
onSelectMmsi,
onToggleHighlightMmsi,
@ -593,8 +262,73 @@ export function useDeckLayers(
toFleetMmsiList,
touchDeckHoverState,
hasAuxiliarySelectModifier,
alarmTargets,
alarmMmsiMap,
]);
// Mercator alarm pulse breathing animation (rAF)
useEffect(() => {
if (projection !== 'mercator' || !alarmMmsiMap || alarmMmsiMap.size === 0 || alarmTargets.length === 0) {
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
alarmRafRef.current = 0;
return;
}
const animate = () => {
// 프로젝션 전환 중에는 overlay에 접근하지 않음 — WebGL 자원 무효화 방지
if (projectionBusyRef.current) {
alarmRafRef.current = requestAnimationFrame(animate);
return;
}
const currentOverlay = overlayRef.current;
if (!currentOverlay) {
alarmRafRef.current = requestAnimationFrame(animate);
return;
}
const t = (Math.sin(Date.now() / 1500 * Math.PI * 2) + 1) / 2;
const normalR = 8 + t * 6;
const hoverR = 12 + t * 6;
const pulseLyr = new ScatterplotLayer<AisTarget>({
id: 'alarm-pulse',
data: alarmTargets,
pickable: false,
billboard: false,
filled: true,
stroked: false,
radiusUnits: 'pixels',
getRadius: (d) => {
const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi);
return isHover ? hoverR : normalR;
},
getFillColor: (d) => {
const kind = alarmMmsiMap.get(d.mmsi);
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
},
getPosition: (d) => [d.lon, d.lat] as [number, number],
updateTriggers: { getRadius: [normalR, hoverR] },
});
const updated = mercatorLayersRef.current.map((l) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
);
try {
currentOverlay.setProps({ layers: updated } as never);
} catch {
// ignore
}
alarmRafRef.current = requestAnimationFrame(animate);
};
alarmRafRef.current = requestAnimationFrame(animate);
return () => {
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
alarmRafRef.current = 0;
};
}, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet]);
// Globe Deck overlay
useEffect(() => {
const map = mapRef.current;
@ -611,32 +345,28 @@ export function useDeckLayers(
return;
}
const overlayParams = GLOBE_OVERLAY_PARAMS;
const globeLayers: unknown[] = [];
if (overlays.pairRange && pairRanges.length > 0) {
globeLayers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-globe', data: pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const p = info.object as PairRangeCircle; setDeckHoverPairs([p.aMmsi, p.bMmsi]); setDeckHoverMmsi([p.aMmsi, p.bMmsi]); clearMapFleetHoverState(); } }));
}
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
const links = pairLinks || [];
globeLayers.push(new LineLayer<PairLink>({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as PairLink; setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); clearMapFleetHoverState(); } }));
}
if (overlays.fcLines && fcDashed.length > 0) {
globeLayers.push(new LineLayer<DashSeg>({ id: 'fc-lines-globe', data: fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); clearMapFleetHoverState(); } }));
}
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
const circles = fleetCircles || [];
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); clearMapFleetHoverState(); return; } touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = toFleetMmsiList(obj.vesselMmsis); setMapFleetHoverState(obj.ownerKey || null, list); setDeckHoverMmsi(list); clearDeckHoverPairs(); } }));
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center }));
}
if (settings.showShips && legacyTargetsOrdered.length > 0) {
globeLayers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-globe', data: legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
}
const globeLayers = buildGlobeDeckLayers({
pairRanges,
pairLinks,
fcDashed,
fleetCircles,
legacyTargetsOrdered,
legacyHits,
overlays,
showShips: settings.showShips,
selectedMmsi,
isHighlightedFleet,
isHighlightedPair,
isHighlightedMmsi,
touchDeckHoverState,
setDeckHoverPairs,
setDeckHoverMmsi,
clearDeckHoverPairs,
clearDeckHoverMmsi,
clearMapFleetHoverState,
setMapFleetHoverState,
toFleetMmsiList,
});
const normalizedLayers = sanitizeDeckLayerList(globeLayers);
const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined };

파일 보기

@ -1,6 +1,5 @@
import { useEffect, type MutableRefObject } from 'react';
import { useEffect, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import { onMapStyleReady } from '../lib/mapCore';
import type { MapProjectionId } from '../types';
export function useFlyTo(
@ -9,53 +8,62 @@ export function useFlyTo(
opts: {
selectedMmsi: number | null;
shipData: { mmsi: number; lon: number; lat: number }[];
/** true일 때 selectedMmsi fly-to 스킵 (지도 클릭 선택 시) */
mapInitiatedSelectRef: MutableRefObject<boolean>;
fleetFocusId: string | number | undefined;
fleetFocusLon: number | undefined;
fleetFocusLat: number | undefined;
fleetFocusZoom: number | undefined;
},
) {
const { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
const { selectedMmsi, shipData, mapInitiatedSelectRef, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!selectedMmsi) return;
const t = shipData.find((x) => x.mmsi === selectedMmsi);
if (!t) return;
const flyOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
if (projectionRef.current === 'globe') {
map.flyTo(flyOpts);
} else {
map.easeTo(flyOpts);
}
}, [selectedMmsi, shipData]);
// shipData를 ref로 — 의존성에서 제외하여 AIS poll마다 재실행 방지
const shipDataRef = useRef(shipData);
useEffect(() => { shipDataRef.current = shipData; }, [shipData]);
// 패널(좌측 목록)에서 선택 시 해당 선박 위치로 즉시 이동
useEffect(() => {
const map = mapRef.current;
if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
// 지도 내부 클릭에서 발생한 선택이면 스킵
if (mapInitiatedSelectRef.current) {
mapInitiatedSelectRef.current = false;
return;
const lon = fleetFocusLon;
const lat = fleetFocusLat;
const zoom = fleetFocusZoom ?? 10;
}
const apply = () => {
const flyOpts = { center: [lon, lat] as [number, number], zoom, duration: 700 };
const map = mapRef.current;
if (!map || selectedMmsi == null) return;
const target = shipDataRef.current.find((t) => t.mmsi === selectedMmsi);
if (!target || !Number.isFinite(target.lon) || !Number.isFinite(target.lat)) return;
try {
const flyOpts = { center: [target.lon, target.lat] as [number, number], duration: 400 };
if (projectionRef.current === 'globe') {
map.flyTo(flyOpts);
} else {
map.easeTo(flyOpts);
}
};
if (map.isStyleLoaded()) {
apply();
return;
} catch {
// ignore — style not ready 등
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMmsi]);
const stop = onMapStyleReady(map, apply);
return () => {
stop();
};
// 선단 포커스 이동
useEffect(() => {
const map = mapRef.current;
if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
return;
try {
const flyOpts = { center: [fleetFocusLon, fleetFocusLat] as [number, number], zoom: fleetFocusZoom ?? 10, duration: 500 };
if (projectionRef.current === 'globe') {
map.flyTo(flyOpts);
} else {
map.easeTo(flyOpts);
}
} catch {
// ignore
}
}, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
}

파일 보기

@ -0,0 +1,357 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { FcLink, FleetCircle } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { DashSeg, MapProjectionId } from '../types';
import {
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
FLEET_LINE_ML, FLEET_LINE_ML_HL,
} from '../constants';
import { makeUniqueSorted } from '../lib/setUtils';
import {
makeFcSegmentFeatureId,
makeFleetCircleFeatureId,
} from '../lib/featureIds';
import {
makeMmsiAnyEndpointExpr,
makeFleetOwnerMatchExpr,
makeFleetMemberMatchExpr,
} from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { circleRingLngLat } from '../lib/geometry';
import { dashifyLine } from '../lib/dashifyLine';
// ── Overlay line width constants ──
const FC_LINE_W_NORMAL = 2.2;
const FC_LINE_W_HL = 3.2;
const FLEET_LINE_W_NORMAL = 2.0;
const FLEET_LINE_W_HL = 3.0;
// ── Breathing animation constants ──
const BREATHE_AMP = 2.0;
const BREATHE_PERIOD_MS = 1200;
/** Globe FC lines + fleet circles 오버레이 (stroke only — fill 제거) */
export function useGlobeFcFleetOverlay(
mapRef: MutableRefObject<maplibregl.Map | null>,
_projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
overlays: MapToggleState;
fcLinks: FcLink[] | undefined;
fleetCircles: FleetCircle[] | undefined;
projection: MapProjectionId;
mapSyncEpoch: number;
hoveredFleetMmsiList: number[];
hoveredFleetOwnerKeyList: string[];
hoveredPairMmsiList: number[];
},
) {
const {
overlays, fcLinks, fleetCircles, projection, mapSyncEpoch,
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
} = opts;
const breatheRafRef = useRef<number>(0);
// paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용하기 위해 사용
const paintStateRef = useRef<() => void>(() => {});
// ── FC lines 데이터 effect ──
// projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리
// 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'fc-lines-ml-src';
const layerId = 'fc-lines-ml';
const ensure = () => {
if (projection !== 'globe') {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
return;
}
const segs: DashSeg[] = [];
for (const l of fcLinks || []) {
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
}
if (segs.length === 0) {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
return;
}
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: segs.map((s, idx) => ({
type: 'Feature',
id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx),
geometry: { type: 'LineString', coordinates: [s.from, s.to] },
properties: {
type: 'fc',
suspicious: s.suspicious,
distanceNm: s.distanceNm,
fcMmsi: s.fromMmsi ?? -1,
otherMmsi: s.toMmsi ?? -1,
},
})),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
} catch {
return; // 다음 poll에서 재시도
}
const needReorder = !map.getLayer(layerId);
if (needReorder) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': FC_LINE_NORMAL_ML,
'line-width': FC_LINE_W_NORMAL,
'line-opacity': 0,
},
} as unknown as LayerSpecification,
undefined,
);
} catch {
return; // 다음 poll에서 재시도
}
reorderGlobeFeatureLayers();
}
paintStateRef.current();
kickRepaint(map);
};
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
const stop = onMapStyleReady(map, ensure);
ensure();
return () => { stop(); };
}, [projection, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
// ── Fleet circles 데이터 effect (stroke only — fill 제거) ──
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'fleet-circles-ml-src';
const layerId = 'fleet-circles-ml';
const ensure = () => {
if (projection !== 'globe' || (fleetCircles?.length ?? 0) === 0) {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
return;
}
const circles = fleetCircles || [];
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: circles.map((c) => {
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
return {
type: 'Feature',
id: makeFleetCircleFeatureId(c.ownerKey),
geometry: { type: 'LineString', coordinates: ring },
properties: {
type: 'fleet',
ownerKey: c.ownerKey,
ownerLabel: c.ownerLabel,
count: c.count,
vesselMmsis: c.vesselMmsis,
},
};
}),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fcLine);
else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification);
} catch {
return; // 다음 poll에서 재시도
}
const needReorder = !map.getLayer(layerId);
if (needReorder) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': FLEET_LINE_ML,
'line-width': FLEET_LINE_W_NORMAL,
'line-opacity': 0,
},
} as unknown as LayerSpecification,
undefined,
);
} catch {
return; // 다음 poll에서 재시도
}
reorderGlobeFeatureLayers();
}
paintStateRef.current();
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
ensure();
return () => { stop(); };
}, [projection, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]);
// ── FC + Fleet paint state update (가시성 + 하이라이트 통합) ──
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const updateFcFleetPaintStates = useCallback(() => {
if (projection !== 'globe') return;
const map = mapRef.current;
if (!map) return;
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
: false;
const fleetOwnerMatchExpr =
hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
const fleetMemberExpr =
hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
const fleetHighlightExpr =
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
: false;
// ── FC lines ──
const pairActive = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0;
const fcVisible = overlays.fcLines || pairActive;
// ── Fleet circles ──
const fleetActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
const fleetVisible = overlays.fleetCircles || fleetActive;
try {
if (map.getLayer('fc-lines-ml')) {
map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0);
if (fcVisible) {
map.setPaintProperty(
'fc-lines-ml', 'line-color',
fcEndpointHighlightExpr !== false
? ['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never
: ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
);
map.setPaintProperty(
'fc-lines-ml', 'line-width',
fcEndpointHighlightExpr !== false
? ['case', fcEndpointHighlightExpr, FC_LINE_W_HL, FC_LINE_W_NORMAL] as never
: FC_LINE_W_NORMAL,
);
}
}
} catch {
// ignore
}
try {
if (map.getLayer('fleet-circles-ml')) {
map.setPaintProperty('fleet-circles-ml', 'line-opacity', fleetVisible ? 0.85 : 0);
if (fleetVisible) {
map.setPaintProperty('fleet-circles-ml', 'line-color',
fleetHighlightExpr !== false
? ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never
: FLEET_LINE_ML,
);
map.setPaintProperty('fleet-circles-ml', 'line-width',
fleetHighlightExpr !== false
? ['case', fleetHighlightExpr, FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never
: FLEET_LINE_W_NORMAL,
);
}
}
} catch {
// ignore
}
kickRepaint(map);
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, overlays.fcLines, overlays.fleetCircles]);
// paintStateRef를 최신 콜백으로 유지
useEffect(() => {
paintStateRef.current = updateFcFleetPaintStates;
}, [updateFcFleetPaintStates]);
// paint state 동기화
useEffect(() => {
updateFcFleetPaintStates();
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, overlays.fcLines, overlays.fleetCircles, updateFcFleetPaintStates, fcLinks, fleetCircles]);
// ── Breathing animation ──
useEffect(() => {
const map = mapRef.current;
const hasFleetHover = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
const hasFcHover = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0;
if (!map || (!hasFleetHover && !hasFcHover) || projection !== 'globe') {
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
breatheRafRef.current = 0;
return;
}
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
: false;
const fleetOwnerMatchExpr = hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
const fleetMemberExpr = hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
const fleetHighlightExpr =
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
: false;
const animate = () => {
if (!map.isStyleLoaded()) {
breatheRafRef.current = requestAnimationFrame(animate);
return;
}
const t = (Math.sin(Date.now() / BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2;
try {
if (map.getLayer('fc-lines-ml') && fcEndpointHighlightExpr !== false) {
const hlW = FC_LINE_W_HL + t * BREATHE_AMP;
map.setPaintProperty('fc-lines-ml', 'line-width',
['case', fcEndpointHighlightExpr, hlW, FC_LINE_W_NORMAL] as never);
}
if (map.getLayer('fleet-circles-ml') && fleetHighlightExpr !== false) {
const hlW = FLEET_LINE_W_HL + t * BREATHE_AMP;
map.setPaintProperty('fleet-circles-ml', 'line-width',
['case', fleetHighlightExpr, hlW, FLEET_LINE_W_NORMAL] as never);
}
} catch {
// ignore
}
breatheRafRef.current = requestAnimationFrame(animate);
};
breatheRafRef.current = requestAnimationFrame(animate);
return () => {
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
breatheRafRef.current = 0;
};
}, [hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection]);
}

파일 보기

@ -14,6 +14,10 @@ import {
} from '../lib/tooltips';
import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils';
// setData() 후 타일 재빌드 중 queryRenderedFeatures가 일시적으로 빈 배열을 반환.
// 즉시 clear 대신 딜레이를 두어 깜박임 방지.
const TOOLTIP_CLEAR_DELAY_MS = 150;
export function useGlobeInteraction(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
@ -57,7 +61,7 @@ export function useGlobeInteraction(
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
if (!map) return;
if (!mapTooltipRef.current) {
mapTooltipRef.current = new maplibregl.Popup({
closeButton: false,
@ -74,6 +78,12 @@ export function useGlobeInteraction(
mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map);
}, []);
// buildGlobeFeatureTooltip을 ref로 관리 — legacyHits/shipByMmsi가 매 AIS poll마다 변경되므로
// useCallback 의존성으로 넣으면 effect가 재실행되어 cleanup에서 tooltip이 제거됨
// ref로 관리하면 effect 재실행 없이 항상 최신 함수 참조
type TooltipFeature = { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined;
const buildTooltipRef = useRef<(feature: TooltipFeature) => { html: string } | null>(() => null);
const buildGlobeFeatureTooltip = useCallback(
(feature: { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined) => {
if (!feature) return null;
@ -136,17 +146,14 @@ export function useGlobeInteraction(
[legacyHits, shipByMmsi],
);
useEffect(() => {
buildTooltipRef.current = buildGlobeFeatureTooltip;
}, [buildGlobeFeatureTooltip]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const clearDeckGlobeHoverState = () => {
clearDeckHoverMmsi();
clearDeckHoverPairs();
setHoveredZoneId((prev) => (prev === null ? prev : null));
clearMapFleetHoverState();
};
const resetGlobeHoverStates = () => {
clearDeckHoverMmsi();
clearDeckHoverPairs();
@ -155,36 +162,52 @@ export function useGlobeInteraction(
};
const normalizeMmsiList = (value: unknown): number[] => {
if (!Array.isArray(value)) return [];
let arr = value;
// MapLibre는 GeoJSON 배열 프로퍼티를 JSON 문자열로 반환할 수 있음
if (typeof arr === 'string') {
try { arr = JSON.parse(arr); } catch { return []; }
}
if (!Array.isArray(arr)) return [];
const out: number[] = [];
for (const n of value) {
for (const n of arr) {
const m = toIntMmsi(n);
if (m != null) out.push(m);
}
return out;
};
// 지연 clear 타이머 — setData() 타일 재빌드 중 일시적 빈 결과를 무시
let clearTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleClear = () => {
if (clearTimer) return; // 이미 예약됨
clearTimer = setTimeout(() => {
clearTimer = null;
resetGlobeHoverStates();
clearGlobeTooltip();
}, TOOLTIP_CLEAR_DELAY_MS);
};
const cancelClear = () => {
if (clearTimer) { clearTimeout(clearTimer); clearTimer = null; }
};
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
if (projection !== 'globe') {
cancelClear();
clearGlobeTooltip();
resetGlobeHoverStates();
return;
}
if (projectionBusyRef.current) {
resetGlobeHoverStates();
clearGlobeTooltip();
return;
}
if (!map.isStyleLoaded()) {
clearDeckGlobeHoverState();
clearGlobeTooltip();
return;
return; // 전환 중에는 기존 상태 유지 (clear하면 깜박임)
}
let candidateLayerIds: string[] = [];
try {
candidateLayerIds = [
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
'pair-lines-ml', 'fc-lines-ml',
'fleet-circles-ml',
'pair-range-ml',
@ -195,14 +218,18 @@ export function useGlobeInteraction(
}
if (candidateLayerIds.length === 0) {
resetGlobeHoverStates();
clearGlobeTooltip();
scheduleClear();
return;
}
let rendered: Array<{ properties?: Record<string, unknown> | null; layer?: { id?: string } }> = [];
try {
rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{
const tolerance = 10;
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
[e.point.x - tolerance, e.point.y - tolerance],
[e.point.x + tolerance, e.point.y + tolerance],
];
rendered = map.queryRenderedFeatures(bbox, { layers: candidateLayerIds }) as unknown as Array<{
properties?: Record<string, unknown> | null;
layer?: { id?: string };
}>;
@ -211,7 +238,8 @@ export function useGlobeInteraction(
}
const priority = [
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
'fleet-circles-ml',
'zones-fill', 'zones-line', 'zones-label',
@ -222,14 +250,23 @@ export function useGlobeInteraction(
| undefined;
if (!first) {
resetGlobeHoverStates();
clearGlobeTooltip();
// 피처 없음 — 타일 재빌드 중 일시적 누락일 수 있으므로 지연 clear
scheduleClear();
return;
}
// 피처 발견 — 지연 clear 취소
cancelClear();
const layerId = first.layer?.id;
const props = first.properties || {};
const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline';
const isShipLayer =
layerId === 'ships-globe' ||
layerId === 'ships-globe-lite' ||
layerId === 'ships-globe-halo' ||
layerId === 'ships-globe-outline' ||
layerId === 'ships-globe-alarm-pulse' ||
layerId === 'ships-globe-alarm-badge';
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
const isFcLayer = layerId === 'fc-lines-ml';
const isFleetLayer = layerId === 'fleet-circles-ml';
@ -273,7 +310,7 @@ export function useGlobeInteraction(
resetGlobeHoverStates();
}
const tooltip = buildGlobeFeatureTooltip(first);
const tooltip = buildTooltipRef.current(first);
if (!tooltip) {
if (!isZoneLayer) {
resetGlobeHoverStates();
@ -291,6 +328,7 @@ export function useGlobeInteraction(
};
const onMouseOut = () => {
cancelClear();
resetGlobeHoverStates();
clearGlobeTooltip();
};
@ -299,13 +337,14 @@ export function useGlobeInteraction(
map.on('mouseout', onMouseOut);
return () => {
cancelClear();
map.off('mousemove', onMouseMove);
map.off('mouseout', onMouseOut);
clearGlobeTooltip();
// cleanup에서 tooltip 제거하지 않음 — 의존성 변경(AIS poll 등)으로 effect가
// 재실행될 때 tooltip이 사라지는 문제 방지. tooltip은 mousemove/mouseout 이벤트가 처리.
};
}, [
projection,
buildGlobeFeatureTooltip,
clearGlobeTooltip,
clearMapFleetHoverState,
clearDeckHoverPairs,
@ -315,4 +354,9 @@ export function useGlobeInteraction(
setMapFleetHoverState,
setGlobeTooltip,
]);
// 컴포넌트 unmount 시에만 tooltip 제거
useEffect(() => {
return () => { clearGlobeTooltip(); };
}, [clearGlobeTooltip]);
}

파일 보기

@ -1,34 +1,10 @@
import { useCallback, useEffect, type MutableRefObject } from 'react';
import type { MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { DashSeg, MapProjectionId, PairRangeCircle } from '../types';
import {
PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML,
PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL,
PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML,
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
FLEET_LINE_ML, FLEET_LINE_ML_HL,
} from '../constants';
import { makeUniqueSorted } from '../lib/setUtils';
import {
makePairLinkFeatureId,
makeFcSegmentFeatureId,
makeFleetCircleFeatureId,
} from '../lib/featureIds';
import {
makeMmsiPairHighlightExpr,
makeMmsiAnyEndpointExpr,
makeFleetOwnerMatchExpr,
makeFleetMemberMatchExpr,
} from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { circleRingLngLat } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
import { dashifyLine } from '../lib/dashifyLine';
import type { MapProjectionId } from '../types';
import { useGlobePairOverlay } from './useGlobePairOverlay';
import { useGlobeFcFleetOverlay } from './useGlobeFcFleetOverlay';
export function useGlobeOverlays(
mapRef: MutableRefObject<maplibregl.Map | null>,
@ -46,479 +22,24 @@ export function useGlobeOverlays(
hoveredPairMmsiList: number[];
},
) {
const {
overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch,
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
} = opts;
// Pair lines + pair range
useGlobePairOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
overlays: opts.overlays,
pairLinks: opts.pairLinks,
projection: opts.projection,
mapSyncEpoch: opts.mapSyncEpoch,
hoveredPairMmsiList: opts.hoveredPairMmsiList,
});
// Pair lines
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'pair-lines-ml-src';
const layerId = 'pair-lines-ml';
const remove = () => {
guardedSetVisibility(map, layerId, 'none');
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) {
remove();
return;
}
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: (pairLinks || []).map((p) => ({
type: 'Feature',
id: makePairLinkFeatureId(p.aMmsi, p.bMmsi),
geometry: { type: 'LineString', coordinates: [p.from, p.to] },
properties: {
type: 'pair',
aMmsi: p.aMmsi,
bMmsi: p.bMmsi,
distanceNm: p.distanceNm,
warn: p.warn,
},
})),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Pair lines source setup failed:', e);
return;
}
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': [
'case',
['==', ['get', 'highlighted'], 1],
['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL],
['boolean', ['get', 'warn'], false],
PAIR_LINE_WARN_ML,
PAIR_LINE_NORMAL_ML,
] as never,
'line-width': [
'case',
['==', ['get', 'highlighted'], 1], 2.8,
['boolean', ['get', 'warn'], false], 2.2,
1.4,
] as never,
'line-opacity': 0.9,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Pair lines layer add failed:', e);
}
} else {
guardedSetVisibility(map, layerId, 'visible');
}
reorderGlobeFeatureLayers();
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
ensure();
return () => {
stop();
};
}, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
// FC lines
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'fc-lines-ml-src';
const layerId = 'fc-lines-ml';
const remove = () => {
guardedSetVisibility(map, layerId, 'none');
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.fcLines) {
remove();
return;
}
const segs: DashSeg[] = [];
for (const l of fcLinks || []) {
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
}
if (segs.length === 0) {
remove();
return;
}
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: segs.map((s, idx) => ({
type: 'Feature',
id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx),
geometry: { type: 'LineString', coordinates: [s.from, s.to] },
properties: {
type: 'fc',
suspicious: s.suspicious,
distanceNm: s.distanceNm,
fcMmsi: s.fromMmsi ?? -1,
otherMmsi: s.toMmsi ?? -1,
},
})),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('FC lines source setup failed:', e);
return;
}
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': [
'case',
['==', ['get', 'highlighted'], 1],
['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL],
['boolean', ['get', 'suspicious'], false],
FC_LINE_SUSPICIOUS_ML,
FC_LINE_NORMAL_ML,
] as never,
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never,
'line-opacity': 0.9,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('FC lines layer add failed:', e);
}
} else {
guardedSetVisibility(map, layerId, 'visible');
}
reorderGlobeFeatureLayers();
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
ensure();
return () => {
stop();
};
}, [projection, overlays.fcLines, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
// Fleet circles
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'fleet-circles-ml-src';
const layerId = 'fleet-circles-ml';
// fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인
// 라인만으로 fleet circle 시각화 충분
const remove = () => {
guardedSetVisibility(map, layerId, 'none');
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) {
remove();
return;
}
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: (fleetCircles || []).map((c) => {
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
return {
type: 'Feature',
id: makeFleetCircleFeatureId(c.ownerKey),
geometry: { type: 'LineString', coordinates: ring },
properties: {
type: 'fleet',
ownerKey: c.ownerKey,
ownerLabel: c.ownerLabel,
count: c.count,
vesselMmsis: c.vesselMmsis,
highlighted: 0,
},
};
}),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fcLine);
else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Fleet circles source setup failed:', e);
return;
}
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never,
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never,
'line-opacity': 0.85,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Fleet circles layer add failed:', e);
}
} else {
guardedSetVisibility(map, layerId, 'visible');
}
reorderGlobeFeatureLayers();
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
ensure();
return () => {
stop();
};
}, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]);
// Pair range
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'pair-range-ml-src';
const layerId = 'pair-range-ml';
const remove = () => {
guardedSetVisibility(map, layerId, 'none');
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !overlays.pairRange) {
remove();
return;
}
const ranges: PairRangeCircle[] = [];
for (const p of pairLinks || []) {
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2];
ranges.push({
center,
radiusNm: Math.max(0.05, p.distanceNm / 2),
warn: p.warn,
aMmsi: p.aMmsi,
bMmsi: p.bMmsi,
distanceNm: p.distanceNm,
});
}
if (ranges.length === 0) {
remove();
return;
}
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: ranges.map((c) => {
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
return {
type: 'Feature',
id: makePairLinkFeatureId(c.aMmsi, c.bMmsi),
geometry: { type: 'LineString', coordinates: ring },
properties: {
type: 'pair-range',
warn: c.warn,
aMmsi: c.aMmsi,
bMmsi: c.bMmsi,
distanceNm: c.distanceNm,
highlighted: 0,
},
};
}),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Pair range source setup failed:', e);
return;
}
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': [
'case',
['==', ['get', 'highlighted'], 1],
['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL],
['boolean', ['get', 'warn'], false],
PAIR_RANGE_WARN_ML,
PAIR_RANGE_NORMAL_ML,
] as never,
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never,
'line-opacity': 0.85,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Pair range layer add failed:', e);
}
} else {
guardedSetVisibility(map, layerId, 'visible');
}
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
ensure();
return () => {
stop();
};
}, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
// Paint state updates for hover highlights
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const updateGlobeOverlayPaintStates = useCallback(() => {
if (projection !== 'globe' || projectionBusyRef.current) return;
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
: false;
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
: false;
const fleetOwnerMatchExpr =
hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
const fleetMemberExpr =
hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
const fleetHighlightExpr =
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
: false;
try {
if (map.getLayer('pair-lines-ml')) {
map.setPaintProperty(
'pair-lines-ml', 'line-color',
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never,
);
map.setPaintProperty(
'pair-lines-ml', 'line-width',
['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never,
);
}
} catch {
// ignore
}
try {
if (map.getLayer('fc-lines-ml')) {
map.setPaintProperty(
'fc-lines-ml', 'line-color',
['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
);
map.setPaintProperty(
'fc-lines-ml', 'line-width',
['case', fcEndpointHighlightExpr, 2.0, 1.3] as never,
);
}
} catch {
// ignore
}
try {
if (map.getLayer('pair-range-ml')) {
map.setPaintProperty(
'pair-range-ml', 'line-color',
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
);
map.setPaintProperty(
'pair-range-ml', 'line-width',
['case', pairHighlightExpr, 1.6, 1.0] as never,
);
}
} catch {
// ignore
}
try {
// fleet-circles-ml-fill 제거됨 (vertex 65535 경고 원인)
if (map.getLayer('fleet-circles-ml')) {
map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never);
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never);
}
} catch {
// ignore
}
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates);
updateGlobeOverlayPaintStates();
return () => {
stop();
};
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]);
// FC lines + fleet circles
useGlobeFcFleetOverlay(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
overlays: opts.overlays,
fcLinks: opts.fcLinks,
fleetCircles: opts.fleetCircles,
projection: opts.projection,
mapSyncEpoch: opts.mapSyncEpoch,
hoveredFleetMmsiList: opts.hoveredFleetMmsiList,
hoveredFleetOwnerKeyList: opts.hoveredFleetOwnerKeyList,
hoveredPairMmsiList: opts.hoveredPairMmsiList,
});
}

파일 보기

@ -0,0 +1,343 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { PairLink } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { MapProjectionId, PairRangeCircle } from '../types';
import {
PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML,
PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL,
PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML,
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
} from '../constants';
import { makePairLinkFeatureId } from '../lib/featureIds';
import { makeMmsiPairHighlightExpr } from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { circleRingLngLat } from '../lib/geometry';
// ── Overlay line width constants ──
const PAIR_LINE_W_NORMAL = 2.5;
const PAIR_LINE_W_WARN = 3.5;
const PAIR_LINE_W_HL = 4.5;
const PAIR_RANGE_W_NORMAL = 1.8;
const PAIR_RANGE_W_HL = 2.8;
// ── Breathing animation constants ──
const BREATHE_AMP = 2.0;
const BREATHE_PERIOD_MS = 1200;
/** Globe pair lines + pair range 오버레이 */
export function useGlobePairOverlay(
mapRef: MutableRefObject<maplibregl.Map | null>,
_projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
overlays: MapToggleState;
pairLinks: PairLink[] | undefined;
projection: MapProjectionId;
mapSyncEpoch: number;
hoveredPairMmsiList: number[];
},
) {
const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts;
const breatheRafRef = useRef<number>(0);
// paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용
const paintStateRef = useRef<() => void>(() => {});
// ── Pair lines 데이터 effect ──
// projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리
// 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'pair-lines-ml-src';
const layerId = 'pair-lines-ml';
const ensure = () => {
if (projection !== 'globe') {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
return;
}
if ((pairLinks?.length ?? 0) === 0) {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
return;
}
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: (pairLinks || []).map((p) => ({
type: 'Feature',
id: makePairLinkFeatureId(p.aMmsi, p.bMmsi),
geometry: { type: 'LineString', coordinates: [p.from, p.to] },
properties: {
type: 'pair',
aMmsi: p.aMmsi,
bMmsi: p.bMmsi,
distanceNm: p.distanceNm,
warn: p.warn,
},
})),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
} catch {
return; // 다음 poll에서 재시도
}
const needReorder = !map.getLayer(layerId);
if (needReorder) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
paint: {
'line-color': PAIR_LINE_NORMAL_ML,
'line-width': PAIR_LINE_W_NORMAL,
'line-opacity': 0,
},
} as unknown as LayerSpecification,
undefined,
);
} catch {
return; // 다음 poll에서 재시도
}
reorderGlobeFeatureLayers();
}
// 즉시 올바른 paint state 적용 — 타이밍 간극으로 opacity:0 고착 방지
paintStateRef.current();
kickRepaint(map);
};
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
const stop = onMapStyleReady(map, ensure);
ensure();
return () => { stop(); };
}, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
// ── Pair range 데이터 effect ──
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'pair-range-ml-src';
const layerId = 'pair-range-ml';
const ensure = () => {
if (projection !== 'globe') {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
return;
}
const ranges: PairRangeCircle[] = [];
for (const p of pairLinks || []) {
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2];
ranges.push({
center,
radiusNm: Math.max(0.05, p.distanceNm / 2),
warn: p.warn,
aMmsi: p.aMmsi,
bMmsi: p.bMmsi,
distanceNm: p.distanceNm,
});
}
if (ranges.length === 0) {
try {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
} catch { /* ignore */ }
return;
}
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: ranges.map((c) => {
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
return {
type: 'Feature',
id: makePairLinkFeatureId(c.aMmsi, c.bMmsi),
geometry: { type: 'LineString', coordinates: ring },
properties: {
type: 'pair-range',
warn: c.warn,
aMmsi: c.aMmsi,
bMmsi: c.bMmsi,
distanceNm: c.distanceNm,
},
};
}),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
} catch {
return; // 다음 poll에서 재시도
}
const needReorder = !map.getLayer(layerId);
if (needReorder) {
try {
map.addLayer(
{
id: layerId,
type: 'line',
source: srcId,
layout: {
'line-cap': 'round',
'line-join': 'round',
visibility: 'visible',
},
paint: {
'line-color': PAIR_RANGE_NORMAL_ML,
'line-width': PAIR_RANGE_W_NORMAL,
'line-opacity': 0,
},
} as unknown as LayerSpecification,
undefined,
);
} catch {
return; // 다음 poll에서 재시도
}
reorderGlobeFeatureLayers();
}
paintStateRef.current();
kickRepaint(map);
};
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
const stop = onMapStyleReady(map, ensure);
ensure();
return () => { stop(); };
}, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
// ── Pair paint state update (가시성 + 하이라이트 통합) ──
// setLayoutProperty(visibility) 대신 setPaintProperty(line-opacity)로 가시성 제어
// → style._changed 미트리거 → alarm badge symbol placement 재계산 방지
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const updatePairPaintStates = useCallback(() => {
if (projection !== 'globe') return;
const map = mapRef.current;
if (!map) return;
const active = hoveredPairMmsiList.length >= 2;
const pairHighlightExpr = active
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
: false;
// ── Pair lines: 가시성 + 하이라이트 ──
const pairLinesVisible = overlays.pairLines || active;
try {
if (map.getLayer('pair-lines-ml')) {
map.setPaintProperty('pair-lines-ml', 'line-opacity', pairLinesVisible ? 0.9 : 0);
if (pairLinesVisible) {
map.setPaintProperty(
'pair-lines-ml', 'line-color',
pairHighlightExpr !== false
? ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never
: ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never,
);
map.setPaintProperty(
'pair-lines-ml', 'line-width',
pairHighlightExpr !== false
? ['case', pairHighlightExpr, PAIR_LINE_W_HL, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never
: ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never,
);
}
}
} catch {
// ignore
}
// ── Pair range: 가시성 + 하이라이트 ──
const pairRangeVisible = overlays.pairRange || active;
try {
if (map.getLayer('pair-range-ml')) {
map.setPaintProperty('pair-range-ml', 'line-opacity', pairRangeVisible ? 0.85 : 0);
if (pairRangeVisible) {
map.setPaintProperty(
'pair-range-ml', 'line-color',
pairHighlightExpr !== false
? ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never
: ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
);
map.setPaintProperty(
'pair-range-ml', 'line-width',
pairHighlightExpr !== false
? ['case', pairHighlightExpr, PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never
: PAIR_RANGE_W_NORMAL,
);
}
}
} catch {
// ignore
}
kickRepaint(map);
}, [projection, hoveredPairMmsiList, overlays.pairLines, overlays.pairRange]);
// paintStateRef를 최신 콜백으로 유지 — useEffect 내에서만 ref 업데이트 (react-hooks/refs 준수)
useEffect(() => {
paintStateRef.current = updatePairPaintStates;
}, [updatePairPaintStates]);
// paint state 동기화: 호버/토글/epoch 변경 시 즉시 반영
useEffect(() => {
updatePairPaintStates();
}, [mapSyncEpoch, hoveredPairMmsiList, projection, overlays.pairLines, overlays.pairRange, updatePairPaintStates, pairLinks]);
// ── Breathing animation for highlighted pair overlays ──
useEffect(() => {
const map = mapRef.current;
if (!map || hoveredPairMmsiList.length < 2 || projection !== 'globe') {
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
breatheRafRef.current = 0;
return;
}
const pairHighlightExpr = makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList);
const animate = () => {
if (!map.isStyleLoaded()) {
breatheRafRef.current = requestAnimationFrame(animate);
return;
}
const t = (Math.sin(Date.now() / BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2;
try {
if (map.getLayer('pair-lines-ml')) {
const hlW = PAIR_LINE_W_HL + t * BREATHE_AMP;
map.setPaintProperty('pair-lines-ml', 'line-width',
['case', pairHighlightExpr, hlW, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never);
}
if (map.getLayer('pair-range-ml')) {
const hlW = PAIR_RANGE_W_HL + t * BREATHE_AMP;
map.setPaintProperty('pair-range-ml', 'line-width',
['case', pairHighlightExpr, hlW, PAIR_RANGE_W_NORMAL] as never);
}
} catch {
// ignore
}
breatheRafRef.current = requestAnimationFrame(animate);
};
breatheRafRef.current = requestAnimationFrame(animate);
return () => {
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
breatheRafRef.current = 0;
};
}, [hoveredPairMmsiList, projection]);
}

파일 보기

@ -0,0 +1,373 @@
import { useEffect, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import type { Map3DSettings, MapProjectionId } from '../types';
import {
GLOBE_ICON_HEADING_OFFSET_DEG,
DEG2RAD,
} from '../constants';
import { isFiniteNumber } from '../lib/setUtils';
import { GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR } from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { getDisplayHeading, getGlobeBaseShipColor } from '../lib/shipUtils';
import { ensureFallbackShipImage } from '../lib/globeShipIcon';
import { clampNumber } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
/** Globe 호버 오버레이 + 클릭 선택 */
export function useGlobeShipHover(
mapRef: MutableRefObject<maplibregl.Map | null>,
_projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
projection: MapProjectionId;
settings: Map3DSettings;
shipLayerData: AisTarget[];
shipHoverOverlaySet: Set<number>;
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
selectedMmsi: number | null;
mapSyncEpoch: number;
onSelectMmsi: (mmsi: number | null) => void;
onToggleHighlightMmsi?: (mmsi: number) => void;
targets: AisTarget[];
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
},
) {
const {
projection, settings, shipLayerData, shipHoverOverlaySet, legacyHits,
selectedMmsi, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi,
targets, hasAuxiliarySelectModifier,
} = opts;
const epochRef = useRef(-1);
const hoverSignatureRef = useRef('');
// Globe hover overlay ships
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const imgId = 'ship-globe-icon';
const srcId = 'ships-globe-hover-src';
const haloId = 'ships-globe-hover-halo';
const outlineId = 'ships-globe-hover-outline';
const symbolId = 'ships-globe-hover';
const hideHover = () => {
for (const id of [symbolId, outlineId, haloId]) {
guardedSetVisibility(map, id, 'none');
}
};
const ensure = () => {
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
hideHover();
return;
}
if (epochRef.current !== mapSyncEpoch) {
epochRef.current = mapSyncEpoch;
}
try {
ensureFallbackShipImage(map, imgId);
} catch { /* ignore */ }
if (!map.hasImage(imgId)) {
return;
}
const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi));
if (hovered.length === 0) {
hideHover();
return;
}
const hoverSignature = hovered
.map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`)
.join('|');
const hasHoverSource = map.getSource(srcId) != null;
const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id));
if (hoverSignature === hoverSignatureRef.current && hasHoverSource && hasHoverLayers) {
return;
}
hoverSignatureRef.current = hoverSignature;
const needReorder = !hasHoverSource || !hasHoverLayers;
const hoverGeojson: GeoJSON.FeatureCollection<GeoJSON.Point> = {
type: 'FeatureCollection',
features: hovered.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const heading = getDisplayHeading({
cog: t.cog,
heading: t.heading,
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
});
const hull = clampNumber(
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
50,
420,
);
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
const selected = t.mmsi === selectedMmsi;
const scale = selected ? 1.16 : 1.1;
return {
type: 'Feature',
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
name: t.name || '',
cog: heading,
heading,
sog: isFiniteNumber(t.sog) ? t.sog : 0,
shipColor: getGlobeBaseShipColor({
legacy: legacy?.shipCode || null,
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45),
iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7),
iconSize10: clampNumber(0.58 * sizeScale * scale, 0.35, 2.1),
iconSize14: clampNumber(0.85 * sizeScale * scale, 0.45, 3.0),
iconSize18: clampNumber(2.5 * sizeScale * scale, 1.0, 7.0),
selected: selected ? 1 : 0,
permitted: legacy ? 1 : 0,
},
};
}),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(hoverGeojson);
else map.addSource(srcId, { type: 'geojson', data: hoverGeojson } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Ship hover source setup failed:', e);
return;
}
const before = undefined;
if (!map.getLayer(haloId)) {
try {
map.addLayer(
{
id: haloId,
type: 'circle',
source: srcId,
layout: {
visibility: 'visible',
'circle-sort-key': [
'case',
['==', ['get', 'selected'], 1], 120,
['==', ['get', 'permitted'], 1], 115,
110,
] as never,
},
paint: {
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR,
'circle-color': [
'case',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)',
'rgba(245,158,11,1)',
] as never,
'circle-opacity': 0.42,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship hover halo layer add failed:', e);
}
} else {
guardedSetVisibility(map, haloId, 'visible');
}
if (!map.getLayer(outlineId)) {
try {
map.addLayer(
{
id: outlineId,
type: 'circle',
source: srcId,
paint: {
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR,
'circle-color': 'rgba(0,0,0,0)',
'circle-stroke-color': [
'case',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
'rgba(245,158,11,0.95)',
] as never,
'circle-stroke-width': [
'case',
['==', ['get', 'selected'], 1], 3.8,
2.2,
] as never,
'circle-stroke-opacity': 0.9,
},
layout: {
visibility: 'visible',
'circle-sort-key': [
'case',
['==', ['get', 'selected'], 1], 121,
['==', ['get', 'permitted'], 1], 116,
111,
] as never,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship hover outline layer add failed:', e);
}
} else {
guardedSetVisibility(map, outlineId, 'visible');
}
if (!map.getLayer(symbolId)) {
try {
map.addLayer(
{
id: symbolId,
type: 'symbol',
source: srcId,
layout: {
visibility: 'visible',
'symbol-sort-key': [
'case',
['==', ['get', 'selected'], 1], 122,
['==', ['get', 'permitted'], 1], 117,
112,
] as never,
'icon-image': imgId,
'icon-size': [
'interpolate', ['linear'], ['zoom'],
3, ['to-number', ['get', 'iconSize3'], 0.35],
7, ['to-number', ['get', 'iconSize7'], 0.45],
10, ['to-number', ['get', 'iconSize10'], 0.58],
14, ['to-number', ['get', 'iconSize14'], 0.85],
18, ['to-number', ['get', 'iconSize18'], 2.5],
] as unknown as number[],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'icon-anchor': 'center',
'icon-rotate': ['to-number', ['get', 'heading'], 0],
'icon-rotation-alignment': 'map',
'icon-pitch-alignment': 'map',
},
paint: {
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'icon-opacity': 1,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship hover symbol layer add failed:', e);
}
} else {
guardedSetVisibility(map, symbolId, 'visible');
}
if (needReorder) {
reorderGlobeFeatureLayers();
}
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [
projection,
settings.showShips,
shipLayerData,
legacyHits,
shipHoverOverlaySet,
selectedMmsi,
mapSyncEpoch,
reorderGlobeFeatureLayers,
]);
// Globe ship click selection
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (projection !== 'globe' || !settings.showShips) return;
const symbolId = 'ships-globe';
const symbolLiteId = 'ships-globe-lite';
const haloId = 'ships-globe-halo';
const outlineId = 'ships-globe-outline';
const clickedRadiusDeg2 = Math.pow(0.12, 2);
const onClick = (e: maplibregl.MapMouseEvent) => {
try {
const layerIds = [symbolId, symbolLiteId, haloId, outlineId, 'ships-globe-alarm-pulse', 'ships-globe-alarm-badge'].filter((id) => map.getLayer(id));
let feats: unknown[] = [];
if (layerIds.length > 0) {
try {
const tolerance = 10;
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
[e.point.x - tolerance, e.point.y - tolerance],
[e.point.x + tolerance, e.point.y + tolerance],
];
feats = map.queryRenderedFeatures(bbox, { layers: layerIds }) as unknown[];
} catch {
feats = [];
}
}
const f = feats?.[0];
const props = ((f as { properties?: Record<string, unknown> } | undefined)?.properties || {}) as Record<
string,
unknown
>;
const mmsi = Number(props.mmsi);
if (Number.isFinite(mmsi)) {
if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) {
onToggleHighlightMmsi?.(mmsi);
return;
}
onSelectMmsi(mmsi);
return;
}
const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng };
const cosLat = Math.cos(clicked.lat * DEG2RAD);
let bestMmsi: number | null = null;
let bestD2 = Number.POSITIVE_INFINITY;
for (const t of targets) {
if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue;
const dLon = (clicked.lon - t.lon) * cosLat;
const dLat = clicked.lat - t.lat;
const d2 = dLon * dLon + dLat * dLat;
if (d2 <= clickedRadiusDeg2 && d2 < bestD2) {
bestD2 = d2;
bestMmsi = t.mmsi;
}
}
if (bestMmsi != null) {
if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) {
onToggleHighlightMmsi?.(bestMmsi);
return;
}
onSelectMmsi(bestMmsi);
return;
}
} catch {
// ignore
}
onSelectMmsi(null);
};
map.on('click', onClick);
return () => {
try {
map.off('click', onClick);
} catch {
// ignore
}
};
}, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]);
}

파일 보기

@ -0,0 +1,64 @@
import { useEffect, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import type { LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { Map3DSettings, MapProjectionId } from '../types';
import { onMapStyleReady } from '../lib/mapCore';
/**
* Mercator MapLibre .
* Deck.gl TextLayer (deckLayerFactories.ts) .
*/
export function useGlobeShipLabels(
mapRef: MutableRefObject<maplibregl.Map | null>,
_projectionBusyRef: MutableRefObject<boolean>,
opts: {
projection: MapProjectionId;
settings: Map3DSettings;
shipData: AisTarget[];
shipHighlightSet: Set<number>;
overlays: MapToggleState;
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
selectedMmsi: number | null;
mapSyncEpoch: number;
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
},
) {
const { mapSyncEpoch } = opts;
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'ship-labels-src';
const layerId = 'ship-labels';
const remove = () => {
try {
if (map.getLayer(layerId)) map.removeLayer(layerId);
} catch {
// ignore
}
try {
if (map.getSource(srcId)) map.removeSource(srcId);
} catch {
// ignore
}
};
const ensure = () => {
// Mercator ship labels are now rendered via Deck.gl TextLayer
// (see buildMercatorDeckLayers in deckLayerFactories.ts).
// Always clean up any stale MapLibre label layer.
if (!map.isStyleLoaded()) return;
remove();
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [mapSyncEpoch]);
}

파일 보기

@ -0,0 +1,693 @@
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { Map3DSettings, MapProjectionId } from '../types';
import {
ANCHORED_SHIP_ICON_ID,
GLOBE_ICON_HEADING_OFFSET_DEG,
GLOBE_OUTLINE_PERMITTED,
GLOBE_OUTLINE_OTHER,
} from '../constants';
import { isFiniteNumber } from '../lib/setUtils';
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import {
isAnchoredShip,
getDisplayHeading,
getGlobeBaseShipColor,
} from '../lib/shipUtils';
import {
buildFallbackGlobeAnchoredShipIcon,
ensureFallbackShipImage,
} from '../lib/globeShipIcon';
import { clampNumber } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
// ── Alarm pulse animation constants ──
const ALARM_PULSE_R_MIN = 8;
const ALARM_PULSE_R_MAX = 14;
const ALARM_PULSE_R_HOVER_MIN = 12;
const ALARM_PULSE_R_HOVER_MAX = 18;
const ALARM_PULSE_PERIOD_MS = 1500;
/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label, alarm pulse, alarm badge) */
export function useGlobeShipLayers(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
projection: MapProjectionId;
settings: Map3DSettings;
shipData: AisTarget[];
overlays: MapToggleState;
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
selectedMmsi: number | null;
isBaseHighlightedMmsi: (mmsi: number) => boolean;
mapSyncEpoch: number;
onGlobeShipsReady?: (ready: boolean) => void;
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
},
) {
const {
projection, settings, shipData, overlays, legacyHits,
selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady,
alarmMmsiMap,
} = opts;
const epochRef = useRef(-1);
const breatheRafRef = useRef<number>(0);
const prevGeoJsonRef = useRef<GeoJSON.FeatureCollection | null>(null);
const prevAlarmGeoJsonRef = useRef<GeoJSON.FeatureCollection | null>(null);
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
return {
type: 'FeatureCollection',
features: shipData.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const alarmKind = alarmMmsiMap?.get(t.mmsi) ?? null;
const baseName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
const labelName = alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
const heading = getDisplayHeading({
cog: t.cog,
heading: t.heading,
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
});
const isAnchored = isAnchoredShip({ sog: t.sog, cog: t.cog, heading: t.heading });
const shipHeading = isAnchored ? 0 : heading;
const hull = clampNumber(
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
50, 420,
);
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
// 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리
// hover overlay 레이어가 확대 + z-priority를 담당
const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
const iconSize14 = clampNumber(0.85 * sizeScale, 0.45, 2.6);
const iconSize18 = clampNumber(2.5 * sizeScale, 1.0, 6.0);
return {
type: 'Feature' as const,
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
name: t.name || '',
labelName,
cog: shipHeading,
heading: shipHeading,
sog: isFiniteNumber(t.sog) ? t.sog : 0,
isAnchored: isAnchored ? 1 : 0,
shipColor: getGlobeBaseShipColor({
legacy: legacy?.shipCode || null,
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3,
iconSize7,
iconSize10,
iconSize14,
iconSize18,
sizeScale,
permitted: legacy ? 1 : 0,
code: legacy?.shipCode || '',
alarmed: alarmKind ? 1 : 0,
alarmKind: alarmKind ?? '',
alarmBadgeLabel: alarmKind ? ALARM_BADGE[alarmKind].label : '',
alarmBadgeColor: alarmKind ? ALARM_BADGE[alarmKind].color : '#000',
},
};
}),
};
}, [shipData, legacyHits, alarmMmsiMap]);
// Alarm-only GeoJSON — separate source to avoid badge symbol re-placement
// when the main ship source updates (position polling)
const alarmGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
if (!alarmMmsiMap || alarmMmsiMap.size === 0) {
return { type: 'FeatureCollection', features: [] };
}
return {
type: 'FeatureCollection',
features: shipData
.filter((t) => alarmMmsiMap.has(t.mmsi))
.map((t) => {
const alarmKind = alarmMmsiMap.get(t.mmsi)!;
return {
type: 'Feature' as const,
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
alarmed: 1,
alarmBadgeLabel: ALARM_BADGE[alarmKind].label,
alarmBadgeColor: ALARM_BADGE[alarmKind].color,
},
};
}),
};
}, [shipData, alarmMmsiMap]);
// Ships in globe mode
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const imgId = 'ship-globe-icon';
const anchoredImgId = ANCHORED_SHIP_ICON_ID;
const srcId = 'ships-globe-src';
const alarmSrcId = 'ships-globe-alarm-src';
const haloId = 'ships-globe-halo';
const outlineId = 'ships-globe-outline';
const symbolLiteId = 'ships-globe-lite';
const symbolId = 'ships-globe';
const labelId = 'ships-globe-label';
const pulseId = 'ships-globe-alarm-pulse';
const badgeId = 'ships-globe-alarm-badge';
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
const hide = () => {
for (const id of [badgeId, labelId, symbolId, symbolLiteId, pulseId, outlineId, haloId]) {
guardedSetVisibility(map, id, 'none');
}
};
const ensureImage = () => {
ensureFallbackShipImage(map, imgId);
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
kickRepaint(map);
};
const ensure = () => {
if (!settings.showShips) {
hide();
onGlobeShipsReady?.(false);
return;
}
// 빠른 visibility 토글 — projectionBusy 중에도 실행
// guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출
// → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) {
const changed =
map.getLayoutProperty(symbolId, 'visibility') !== visibility ||
map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility;
if (changed) {
for (const id of [haloId, outlineId, pulseId, symbolLiteId, symbolId, badgeId]) {
guardedSetVisibility(map, id, visibility);
}
if (projection === 'globe') kickRepaint(map);
}
guardedSetVisibility(map, labelId, labelVisibility);
}
// 데이터 업데이트는 projectionBusy 중에는 차단
if (projectionBusyRef.current) {
// 레이어가 이미 존재하면 ready 상태 유지
if (map.getLayer(symbolId)) onGlobeShipsReady?.(true);
return;
}
if (!map.isStyleLoaded()) return;
if (epochRef.current !== mapSyncEpoch) {
epochRef.current = mapSyncEpoch;
}
try {
ensureImage();
} catch (e) {
console.warn('Ship icon image setup failed:', e);
}
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
// 참조 동일성 기반 setData 스킵 — 위치 변경 없는 epoch/설정 변경 시 재전송 방지
const geojson = globeShipGeoJson;
const geoJsonChanged = geojson !== prevGeoJsonRef.current;
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) {
if (geoJsonChanged) existing.setData(geojson);
} else {
map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
}
prevGeoJsonRef.current = geojson;
} catch (e) {
console.warn('Ship source setup failed:', e);
return;
}
// Alarm source — isolated from main source for stable badge rendering
try {
const existingAlarm = map.getSource(alarmSrcId) as GeoJSONSource | undefined;
const alarmChanged = alarmGeoJson !== prevAlarmGeoJsonRef.current;
if (existingAlarm) {
if (alarmChanged) existingAlarm.setData(alarmGeoJson);
} else {
map.addSource(alarmSrcId, { type: 'geojson', data: alarmGeoJson } as GeoJSONSourceSpecification);
}
prevAlarmGeoJsonRef.current = alarmGeoJson;
} catch (e) {
console.warn('Alarm source setup failed:', e);
}
const before = undefined;
let needReorder = false;
const priorityFilter = [
'any',
['==', ['to-number', ['get', 'permitted'], 0], 1],
['==', ['to-number', ['get', 'alarmed'], 0], 1],
] as unknown as unknown[];
const nonPriorityFilter = [
'all',
['==', ['to-number', ['get', 'permitted'], 0], 0],
['==', ['to-number', ['get', 'alarmed'], 0], 0],
] as unknown as unknown[];
if (!map.getLayer(haloId)) {
needReorder = true;
try {
map.addLayer(
{
id: haloId,
type: 'circle',
source: srcId,
layout: {
visibility,
'circle-sort-key': [
'case',
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112,
['==', ['get', 'permitted'], 1], 110,
['==', ['get', 'alarmed'], 1], 22,
20,
] as never,
},
paint: {
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'circle-opacity': [
'case',
['==', ['feature-state', 'selected'], 1], 0.38,
['==', ['feature-state', 'highlighted'], 1], 0.34,
0.16,
] as never,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship halo layer add failed:', e);
}
}
if (!map.getLayer(outlineId)) {
needReorder = true;
try {
map.addLayer(
{
id: outlineId,
type: 'circle',
source: srcId,
paint: {
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
'circle-color': 'rgba(0,0,0,0)',
'circle-stroke-color': [
'case',
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
GLOBE_OUTLINE_OTHER,
] as never,
'circle-stroke-width': [
'case',
['==', ['feature-state', 'selected'], 1], 3.4,
['==', ['feature-state', 'highlighted'], 1], 2.7,
['==', ['get', 'permitted'], 1], 1.8,
0.7,
] as never,
'circle-stroke-opacity': 0.85,
},
layout: {
visibility,
'circle-sort-key': [
'case',
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122,
['==', ['get', 'permitted'], 1], 120,
['==', ['get', 'alarmed'], 1], 32,
30,
] as never,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship outline layer add failed:', e);
}
}
// Alarm pulse circle (above outline, below ship icons)
// Uses separate alarm source for stable rendering
if (!map.getLayer(pulseId)) {
needReorder = true;
try {
map.addLayer(
{
id: pulseId,
type: 'circle',
source: alarmSrcId,
filter: ['==', ['get', 'alarmed'], 1] as never,
layout: { visibility },
paint: {
'circle-radius': ALARM_PULSE_R_MIN,
'circle-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
'circle-opacity': 0.35,
'circle-stroke-width': 0,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship alarm pulse layer add failed:', e);
}
}
if (!map.getLayer(symbolLiteId)) {
needReorder = true;
try {
map.addLayer(
{
id: symbolLiteId,
type: 'symbol',
source: srcId,
minzoom: 6.5,
filter: nonPriorityFilter as never,
layout: {
visibility,
'symbol-sort-key': 40 as never,
'icon-image': [
'case',
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
anchoredImgId,
imgId,
] as never,
'icon-size': [
'interpolate',
['linear'],
['zoom'],
6.5,
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45],
8,
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62],
10,
['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72],
14,
['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78],
18,
['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78],
] as unknown as number[],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'icon-anchor': 'center',
'icon-rotate': [
'case',
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
0,
['to-number', ['get', 'heading'], 0],
] as never,
'icon-rotation-alignment': 'map',
'icon-pitch-alignment': 'map',
},
paint: {
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'icon-opacity': [
'interpolate',
['linear'],
['zoom'],
6.5,
0.16,
8,
0.34,
11,
0.54,
14,
0.68,
] as never,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship lite symbol layer add failed:', e);
}
}
if (!map.getLayer(symbolId)) {
needReorder = true;
try {
map.addLayer(
{
id: symbolId,
type: 'symbol',
source: srcId,
filter: priorityFilter as never,
layout: {
visibility,
'symbol-sort-key': [
'case',
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 132,
['==', ['get', 'permitted'], 1], 130,
['==', ['get', 'alarmed'], 1], 47,
45,
] as never,
'icon-image': [
'case',
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
anchoredImgId,
imgId,
] as never,
'icon-size': [
'interpolate', ['linear'], ['zoom'],
3, ['to-number', ['get', 'iconSize3'], 0.35],
7, ['to-number', ['get', 'iconSize7'], 0.45],
10, ['to-number', ['get', 'iconSize10'], 0.58],
14, ['to-number', ['get', 'iconSize14'], 0.85],
18, ['to-number', ['get', 'iconSize18'], 2.5],
] as unknown as number[],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'icon-anchor': 'center',
'icon-rotate': [
'case',
['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0,
['to-number', ['get', 'heading'], 0],
] as never,
'icon-rotation-alignment': 'map',
'icon-pitch-alignment': 'map',
},
paint: {
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'icon-opacity': [
'case',
['==', ['feature-state', 'selected'], 1], 1,
['==', ['feature-state', 'highlighted'], 1], 0.95,
['==', ['get', 'permitted'], 1], 0.93,
0.9,
] as never,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship symbol layer add failed:', e);
}
}
const labelFilter = [
'all',
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
['==', ['get', 'permitted'], 1],
] as unknown as unknown[];
if (!map.getLayer(labelId)) {
needReorder = true;
try {
map.addLayer(
{
id: labelId,
type: 'symbol',
source: srcId,
minzoom: 7,
filter: labelFilter as never,
layout: {
visibility: labelVisibility,
'symbol-placement': 'point',
'text-field': ['get', 'labelName'] as never,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
'text-anchor': 'top',
'text-offset': [0, 1.1],
'text-padding': 2,
'text-allow-overlap': false,
'text-ignore-placement': false,
},
paint: {
'text-color': [
'case',
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
'rgba(226,232,240,0.92)',
] as never,
'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1.2,
'text-halo-blur': 0.8,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Ship label layer add failed:', e);
}
}
// Alarm badge symbol (above labels)
// Uses separate alarm source for stable rendering
if (!map.getLayer(badgeId)) {
needReorder = true;
try {
map.addLayer(
{
id: badgeId,
type: 'symbol',
source: alarmSrcId,
filter: ['==', ['get', 'alarmed'], 1] as never,
layout: {
visibility,
'text-field': ['get', 'alarmBadgeLabel'] as never,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': 11,
'text-allow-overlap': true,
'text-ignore-placement': true,
'text-anchor': 'center',
},
paint: {
'text-color': '#ffffff',
'text-halo-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
'text-halo-width': 6,
'text-translate': [12, -12],
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Ship alarm badge layer add failed:', e);
}
}
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
onGlobeShipsReady?.(true);
// needReorder: 새 레이어가 생성된 경우에만 reorder 호출
// 매 AIS poll마다 28개 moveLayer → style._changed 방지
if (projection === 'globe' && needReorder) {
reorderGlobeFeatureLayers();
}
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [
projection,
settings.showShips,
overlays.shipLabels,
globeShipGeoJson,
alarmGeoJson,
mapSyncEpoch,
reorderGlobeFeatureLayers,
onGlobeShipsReady,
]);
// Feature-state로 상호작용 상태(selected/highlighted) 즉시 반영 — setData 없이
useEffect(() => {
const map = mapRef.current;
if (!map || projection !== 'globe' || projectionBusyRef.current) return;
if (!map.isStyleLoaded() || !map.getSource('ships-globe-src')) return;
const raf = requestAnimationFrame(() => {
if (!map.isStyleLoaded()) return;
const src = 'ships-globe-src';
const alarmSrc = 'ships-globe-alarm-src';
for (const t of shipData) {
if (!isFiniteNumber(t.mmsi)) continue;
const id = Math.trunc(t.mmsi);
const s = t.mmsi === selectedMmsi ? 1 : 0;
const h = isBaseHighlightedMmsi(t.mmsi) ? 1 : 0;
try {
map.setFeatureState({ source: src, id }, { selected: s, highlighted: h });
} catch { /* ignore */ }
}
if (map.getSource(alarmSrc) && alarmMmsiMap) {
for (const t of shipData) {
if (!alarmMmsiMap.has(t.mmsi)) continue;
const id = Math.trunc(t.mmsi);
try {
map.setFeatureState(
{ source: alarmSrc, id },
{ selected: t.mmsi === selectedMmsi ? 1 : 0, highlighted: isBaseHighlightedMmsi(t.mmsi) ? 1 : 0 },
);
} catch { /* ignore */ }
}
}
kickRepaint(map);
});
return () => cancelAnimationFrame(raf);
}, [projection, selectedMmsi, isBaseHighlightedMmsi, shipData, alarmMmsiMap]);
// Alarm pulse breathing animation (rAF)
useEffect(() => {
const map = mapRef.current;
if (!map || projection !== 'globe' || !alarmMmsiMap || alarmMmsiMap.size === 0) {
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
breatheRafRef.current = 0;
return;
}
const animate = () => {
if (!map.isStyleLoaded()) {
breatheRafRef.current = requestAnimationFrame(animate);
return;
}
const t = (Math.sin(Date.now() / ALARM_PULSE_PERIOD_MS * Math.PI * 2) + 1) / 2;
const normalR = ALARM_PULSE_R_MIN + t * (ALARM_PULSE_R_MAX - ALARM_PULSE_R_MIN);
const hoverR = ALARM_PULSE_R_HOVER_MIN + t * (ALARM_PULSE_R_HOVER_MAX - ALARM_PULSE_R_HOVER_MIN);
try {
if (map.getLayer('ships-globe-alarm-pulse')) {
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [
'case',
['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]],
hoverR,
normalR,
] as never);
}
} catch {
// ignore
}
breatheRafRef.current = requestAnimationFrame(animate);
};
breatheRafRef.current = requestAnimationFrame(animate);
return () => {
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
breatheRafRef.current = 0;
};
}, [projection, alarmMmsiMap]);
}

파일 보기

@ -1,31 +1,13 @@
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
import type { MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import type { LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { Map3DSettings, MapProjectionId } from '../types';
import {
ANCHORED_SHIP_ICON_ID,
GLOBE_ICON_HEADING_OFFSET_DEG,
GLOBE_OUTLINE_PERMITTED,
GLOBE_OUTLINE_OTHER,
DEG2RAD,
} from '../constants';
import { isFiniteNumber } from '../lib/setUtils';
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import {
isAnchoredShip,
getDisplayHeading,
getGlobeBaseShipColor,
} from '../lib/shipUtils';
import {
buildFallbackGlobeAnchoredShipIcon,
ensureFallbackShipImage,
} from '../lib/globeShipIcon';
import { clampNumber } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
import { useGlobeShipLabels } from './useGlobeShipLabels';
import { useGlobeShipLayers } from './useGlobeShipLayers';
import { useGlobeShipHover } from './useGlobeShipHover';
export function useGlobeShips(
mapRef: MutableRefObject<maplibregl.Map | null>,
@ -50,841 +32,48 @@ export function useGlobeShips(
isBaseHighlightedMmsi: (mmsi: number) => boolean;
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
onGlobeShipsReady?: (ready: boolean) => void;
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
},
) {
const {
projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet,
shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets,
overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
onGlobeShipsReady,
} = opts;
// Mercator 모드 선명 라벨
useGlobeShipLabels(mapRef, projectionBusyRef, {
projection: opts.projection,
settings: opts.settings,
shipData: opts.shipData,
shipHighlightSet: opts.shipHighlightSet,
overlays: opts.overlays,
legacyHits: opts.legacyHits,
selectedMmsi: opts.selectedMmsi,
mapSyncEpoch: opts.mapSyncEpoch,
alarmMmsiMap: opts.alarmMmsiMap,
});
const globeShipsEpochRef = useRef(-1);
const globeHoverShipSignatureRef = useRef('');
// Globe 모드 선박 아이콘 레이어
useGlobeShipLayers(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
projection: opts.projection,
settings: opts.settings,
shipData: opts.shipData,
overlays: opts.overlays,
legacyHits: opts.legacyHits,
selectedMmsi: opts.selectedMmsi,
isBaseHighlightedMmsi: opts.isBaseHighlightedMmsi,
mapSyncEpoch: opts.mapSyncEpoch,
onGlobeShipsReady: opts.onGlobeShipsReady,
alarmMmsiMap: opts.alarmMmsiMap,
});
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
const globeShipGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
return {
type: 'FeatureCollection',
features: shipData.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const labelName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
const heading = getDisplayHeading({
cog: t.cog,
heading: t.heading,
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
});
const isAnchored = isAnchoredShip({ sog: t.sog, cog: t.cog, heading: t.heading });
const shipHeading = isAnchored ? 0 : heading;
const hull = clampNumber(
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
50, 420,
);
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
const selected = t.mmsi === selectedMmsi;
const highlighted = isBaseHighlightedMmsi(t.mmsi);
const selectedScale = selected ? 1.08 : 1;
const highlightScale = highlighted ? 1.06 : 1;
const iconScale = selected ? selectedScale : highlightScale;
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
return {
type: 'Feature' as const,
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
name: t.name || '',
labelName,
cog: shipHeading,
heading: shipHeading,
sog: isFiniteNumber(t.sog) ? t.sog : 0,
isAnchored: isAnchored ? 1 : 0,
shipColor: getGlobeBaseShipColor({
legacy: legacy?.shipCode || null,
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3: iconSize3 * iconScale,
iconSize7: iconSize7 * iconScale,
iconSize10: iconSize10 * iconScale,
iconSize14: iconSize14 * iconScale,
iconSize18: iconSize18 * iconScale,
sizeScale,
selected: selected ? 1 : 0,
highlighted: highlighted ? 1 : 0,
permitted: legacy ? 1 : 0,
code: legacy?.shipCode || '',
},
};
}),
};
}, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi]);
// Ship name labels in mercator
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'ship-labels-src';
const layerId = 'ship-labels';
const remove = () => {
try {
if (map.getLayer(layerId)) map.removeLayer(layerId);
} catch {
// ignore
}
try {
if (map.getSource(srcId)) map.removeSource(srcId);
} catch {
// ignore
}
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'mercator' || !settings.showShips) {
remove();
return;
}
const visibility = overlays.shipLabels ? 'visible' : 'none';
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
for (const t of shipData) {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const isTarget = !!legacy;
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
const isPinnedHighlight = shipHighlightSet.has(t.mmsi);
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '').trim();
if (!labelName) continue;
features.push({
type: 'Feature',
id: `ship-label-${t.mmsi}`,
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
labelName,
selected: isSelected ? 1 : 0,
highlighted: isPinnedHighlight ? 1 : 0,
permitted: isTarget ? 1 : 0,
},
});
}
const fc: GeoJSON.FeatureCollection<GeoJSON.Point> = { type: 'FeatureCollection', features };
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fc);
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Ship label source setup failed:', e);
return;
}
const filter = ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''] as unknown as unknown[];
if (!map.getLayer(layerId)) {
try {
map.addLayer(
{
id: layerId,
type: 'symbol',
source: srcId,
minzoom: 7,
filter: filter as never,
layout: {
visibility,
'symbol-placement': 'point',
'text-field': ['get', 'labelName'] as never,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
'text-anchor': 'top',
'text-offset': [0, 1.1],
'text-padding': 2,
'text-allow-overlap': false,
'text-ignore-placement': false,
},
paint: {
'text-color': [
'case',
['==', ['get', 'selected'], 1],
'rgba(14,234,255,0.95)',
['==', ['get', 'highlighted'], 1],
'rgba(245,158,11,0.95)',
'rgba(226,232,240,0.92)',
] as never,
'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1.2,
'text-halo-blur': 0.8,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Ship label layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(layerId, 'visibility', visibility);
} catch {
// ignore
}
}
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [
projection,
settings.showShips,
overlays.shipLabels,
shipData,
legacyHits,
selectedMmsi,
shipHighlightSet,
mapSyncEpoch,
]);
// Ships in globe mode
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const imgId = 'ship-globe-icon';
const anchoredImgId = ANCHORED_SHIP_ICON_ID;
const srcId = 'ships-globe-src';
const haloId = 'ships-globe-halo';
const outlineId = 'ships-globe-outline';
const symbolId = 'ships-globe';
const labelId = 'ships-globe-label';
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
const hide = () => {
for (const id of [labelId, symbolId, outlineId, haloId]) {
guardedSetVisibility(map, id, 'none');
}
};
const ensureImage = () => {
ensureFallbackShipImage(map, imgId);
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
kickRepaint(map);
};
const ensure = () => {
if (!settings.showShips) {
hide();
onGlobeShipsReady?.(false);
return;
}
// 빠른 visibility 토글 — projectionBusy 중에도 실행
// guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출
// → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
if (map.getLayer(symbolId)) {
const changed = map.getLayoutProperty(symbolId, 'visibility') !== visibility;
if (changed) {
for (const id of [haloId, outlineId, symbolId]) {
guardedSetVisibility(map, id, visibility);
}
if (projection === 'globe') kickRepaint(map);
}
guardedSetVisibility(map, labelId, labelVisibility);
}
// 데이터 업데이트는 projectionBusy 중에는 차단
if (projectionBusyRef.current) {
// 레이어가 이미 존재하면 ready 상태 유지
if (map.getLayer(symbolId)) onGlobeShipsReady?.(true);
return;
}
if (!map.isStyleLoaded()) return;
if (globeShipsEpochRef.current !== mapSyncEpoch) {
globeShipsEpochRef.current = mapSyncEpoch;
}
try {
ensureImage();
} catch (e) {
console.warn('Ship icon image setup failed:', e);
}
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
const geojson = globeShipGeoJson;
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(geojson);
else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Ship source setup failed:', e);
return;
}
const before = undefined;
if (!map.getLayer(haloId)) {
try {
map.addLayer(
{
id: haloId,
type: 'circle',
source: srcId,
layout: {
visibility,
'circle-sort-key': [
'case',
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115,
['==', ['get', 'permitted'], 1], 110,
['==', ['get', 'selected'], 1], 60,
['==', ['get', 'highlighted'], 1], 55,
20,
] as never,
},
paint: {
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'circle-opacity': [
'case',
['==', ['get', 'selected'], 1], 0.38,
['==', ['get', 'highlighted'], 1], 0.34,
0.16,
] as never,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship halo layer add failed:', e);
}
}
// halo: data-driven expressions are static — visibility handled by fast toggle above
if (!map.getLayer(outlineId)) {
try {
map.addLayer(
{
id: outlineId,
type: 'circle',
source: srcId,
paint: {
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
'circle-color': 'rgba(0,0,0,0)',
'circle-stroke-color': [
'case',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
GLOBE_OUTLINE_OTHER,
] as never,
'circle-stroke-width': [
'case',
['==', ['get', 'selected'], 1], 3.4,
['==', ['get', 'highlighted'], 1], 2.7,
['==', ['get', 'permitted'], 1], 1.8,
0.7,
] as never,
'circle-stroke-opacity': 0.85,
},
layout: {
visibility,
'circle-sort-key': [
'case',
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125,
['==', ['get', 'permitted'], 1], 120,
['==', ['get', 'selected'], 1], 70,
['==', ['get', 'highlighted'], 1], 65,
30,
] as never,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship outline layer add failed:', e);
}
}
// outline: data-driven expressions are static — visibility handled by fast toggle
if (!map.getLayer(symbolId)) {
try {
map.addLayer(
{
id: symbolId,
type: 'symbol',
source: srcId,
layout: {
visibility,
'symbol-sort-key': [
'case',
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135,
['==', ['get', 'permitted'], 1], 130,
['==', ['get', 'selected'], 1], 80,
['==', ['get', 'highlighted'], 1], 75,
45,
] as never,
'icon-image': [
'case',
['==', ['to-number', ['get', 'isAnchored'], 0], 1],
anchoredImgId,
imgId,
] as never,
'icon-size': [
'interpolate', ['linear'], ['zoom'],
3, ['to-number', ['get', 'iconSize3'], 0.35],
7, ['to-number', ['get', 'iconSize7'], 0.45],
10, ['to-number', ['get', 'iconSize10'], 0.58],
14, ['to-number', ['get', 'iconSize14'], 0.85],
18, ['to-number', ['get', 'iconSize18'], 2.5],
] as unknown as number[],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'icon-anchor': 'center',
'icon-rotate': [
'case',
['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0,
['to-number', ['get', 'heading'], 0],
] as never,
'icon-rotation-alignment': 'map',
'icon-pitch-alignment': 'map',
},
paint: {
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'icon-opacity': [
'case',
['==', ['get', 'permitted'], 1], 1,
['==', ['get', 'selected'], 1], 0.86,
['==', ['get', 'highlighted'], 1], 0.82,
0.66,
] as never,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship symbol layer add failed:', e);
}
}
// symbol: data-driven expressions are static — visibility handled by fast toggle
const labelFilter = [
'all',
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
[
'any',
['==', ['get', 'permitted'], 1],
['==', ['get', 'selected'], 1],
['==', ['get', 'highlighted'], 1],
],
] as unknown as unknown[];
if (!map.getLayer(labelId)) {
try {
map.addLayer(
{
id: labelId,
type: 'symbol',
source: srcId,
minzoom: 7,
filter: labelFilter as never,
layout: {
visibility: labelVisibility,
'symbol-placement': 'point',
'text-field': ['get', 'labelName'] as never,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
'text-anchor': 'top',
'text-offset': [0, 1.1],
'text-padding': 2,
'text-allow-overlap': false,
'text-ignore-placement': false,
},
paint: {
'text-color': [
'case',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
'rgba(226,232,240,0.92)',
] as never,
'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1.2,
'text-halo-blur': 0.8,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Ship label layer add failed:', e);
}
}
// label: filter/text-field are static — visibility handled by fast toggle
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
onGlobeShipsReady?.(true);
if (projection === 'globe') {
reorderGlobeFeatureLayers();
}
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [
projection,
settings.showShips,
overlays.shipLabels,
globeShipGeoJson,
selectedMmsi,
isBaseHighlightedMmsi,
mapSyncEpoch,
reorderGlobeFeatureLayers,
onGlobeShipsReady,
]);
// Globe hover overlay ships
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const imgId = 'ship-globe-icon';
const srcId = 'ships-globe-hover-src';
const haloId = 'ships-globe-hover-halo';
const outlineId = 'ships-globe-hover-outline';
const symbolId = 'ships-globe-hover';
const hideHover = () => {
for (const id of [symbolId, outlineId, haloId]) {
guardedSetVisibility(map, id, 'none');
}
};
const ensure = () => {
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
hideHover();
return;
}
if (globeShipsEpochRef.current !== mapSyncEpoch) {
globeShipsEpochRef.current = mapSyncEpoch;
}
ensureFallbackShipImage(map, imgId);
if (!map.hasImage(imgId)) {
return;
}
const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi));
if (hovered.length === 0) {
hideHover();
return;
}
const hoverSignature = hovered
.map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`)
.join('|');
const hasHoverSource = map.getSource(srcId) != null;
const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id));
if (hoverSignature === globeHoverShipSignatureRef.current && hasHoverSource && hasHoverLayers) {
return;
}
globeHoverShipSignatureRef.current = hoverSignature;
const needReorder = !hasHoverSource || !hasHoverLayers;
const hoverGeojson: GeoJSON.FeatureCollection<GeoJSON.Point> = {
type: 'FeatureCollection',
features: hovered.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null;
const heading = getDisplayHeading({
cog: t.cog,
heading: t.heading,
offset: GLOBE_ICON_HEADING_OFFSET_DEG,
});
const hull = clampNumber(
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
50,
420,
);
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
const selected = t.mmsi === selectedMmsi;
const scale = selected ? 1.16 : 1.1;
return {
type: 'Feature',
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
name: t.name || '',
cog: heading,
heading,
sog: isFiniteNumber(t.sog) ? t.sog : 0,
shipColor: getGlobeBaseShipColor({
legacy: legacy?.shipCode || null,
sog: isFiniteNumber(t.sog) ? t.sog : null,
}),
iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45),
iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7),
iconSize10: clampNumber(0.58 * sizeScale * scale, 0.35, 2.1),
iconSize14: clampNumber(0.85 * sizeScale * scale, 0.45, 3.0),
iconSize18: clampNumber(2.5 * sizeScale * scale, 1.0, 7.0),
selected: selected ? 1 : 0,
permitted: legacy ? 1 : 0,
},
};
}),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(hoverGeojson);
else map.addSource(srcId, { type: 'geojson', data: hoverGeojson } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Ship hover source setup failed:', e);
return;
}
const before = undefined;
if (!map.getLayer(haloId)) {
try {
map.addLayer(
{
id: haloId,
type: 'circle',
source: srcId,
layout: {
visibility: 'visible',
'circle-sort-key': [
'case',
['==', ['get', 'selected'], 1], 120,
['==', ['get', 'permitted'], 1], 115,
110,
] as never,
},
paint: {
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
'circle-color': [
'case',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)',
'rgba(245,158,11,1)',
] as never,
'circle-opacity': 0.42,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship hover halo layer add failed:', e);
}
} else {
map.setLayoutProperty(haloId, 'visibility', 'visible');
}
if (!map.getLayer(outlineId)) {
try {
map.addLayer(
{
id: outlineId,
type: 'circle',
source: srcId,
paint: {
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
'circle-color': 'rgba(0,0,0,0)',
'circle-stroke-color': [
'case',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
'rgba(245,158,11,0.95)',
] as never,
'circle-stroke-width': [
'case',
['==', ['get', 'selected'], 1], 3.8,
2.2,
] as never,
'circle-stroke-opacity': 0.9,
},
layout: {
visibility: 'visible',
'circle-sort-key': [
'case',
['==', ['get', 'selected'], 1], 121,
['==', ['get', 'permitted'], 1], 116,
111,
] as never,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship hover outline layer add failed:', e);
}
} else {
map.setLayoutProperty(outlineId, 'visibility', 'visible');
}
if (!map.getLayer(symbolId)) {
try {
map.addLayer(
{
id: symbolId,
type: 'symbol',
source: srcId,
layout: {
visibility: 'visible',
'symbol-sort-key': [
'case',
['==', ['get', 'selected'], 1], 122,
['==', ['get', 'permitted'], 1], 117,
112,
] as never,
'icon-image': imgId,
'icon-size': [
'interpolate', ['linear'], ['zoom'],
3, ['to-number', ['get', 'iconSize3'], 0.35],
7, ['to-number', ['get', 'iconSize7'], 0.45],
10, ['to-number', ['get', 'iconSize10'], 0.58],
14, ['to-number', ['get', 'iconSize14'], 0.85],
18, ['to-number', ['get', 'iconSize18'], 2.5],
] as unknown as number[],
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'icon-anchor': 'center',
'icon-rotate': ['to-number', ['get', 'heading'], 0],
'icon-rotation-alignment': 'map',
'icon-pitch-alignment': 'map',
},
paint: {
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'icon-opacity': 1,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship hover symbol layer add failed:', e);
}
} else {
map.setLayoutProperty(symbolId, 'visibility', 'visible');
}
if (needReorder) {
reorderGlobeFeatureLayers();
}
kickRepaint(map);
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [
projection,
settings.showShips,
shipLayerData,
legacyHits,
shipHoverOverlaySet,
selectedMmsi,
mapSyncEpoch,
reorderGlobeFeatureLayers,
]);
// Globe ship click selection
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (projection !== 'globe' || !settings.showShips) return;
const symbolId = 'ships-globe';
const haloId = 'ships-globe-halo';
const outlineId = 'ships-globe-outline';
const clickedRadiusDeg2 = Math.pow(0.08, 2);
const onClick = (e: maplibregl.MapMouseEvent) => {
try {
const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id));
let feats: unknown[] = [];
if (layerIds.length > 0) {
try {
feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[];
} catch {
feats = [];
}
}
const f = feats?.[0];
const props = ((f as { properties?: Record<string, unknown> } | undefined)?.properties || {}) as Record<
string,
unknown
>;
const mmsi = Number(props.mmsi);
if (Number.isFinite(mmsi)) {
if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) {
onToggleHighlightMmsi?.(mmsi);
return;
}
onSelectMmsi(mmsi);
return;
}
const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng };
const cosLat = Math.cos(clicked.lat * DEG2RAD);
let bestMmsi: number | null = null;
let bestD2 = Number.POSITIVE_INFINITY;
for (const t of targets) {
if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue;
const dLon = (clicked.lon - t.lon) * cosLat;
const dLat = clicked.lat - t.lat;
const d2 = dLon * dLon + dLat * dLat;
if (d2 <= clickedRadiusDeg2 && d2 < bestD2) {
bestD2 = d2;
bestMmsi = t.mmsi;
}
}
if (bestMmsi != null) {
if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) {
onToggleHighlightMmsi?.(bestMmsi);
return;
}
onSelectMmsi(bestMmsi);
return;
}
} catch {
// ignore
}
onSelectMmsi(null);
};
map.on('click', onClick);
return () => {
try {
map.off('click', onClick);
} catch {
// ignore
}
};
}, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]);
// Globe 호버 오버레이 + 클릭 선택
useGlobeShipHover(mapRef, projectionBusyRef, reorderGlobeFeatureLayers, {
projection: opts.projection,
settings: opts.settings,
shipLayerData: opts.shipLayerData,
shipHoverOverlaySet: opts.shipHoverOverlaySet,
legacyHits: opts.legacyHits,
selectedMmsi: opts.selectedMmsi,
mapSyncEpoch: opts.mapSyncEpoch,
onSelectMmsi: opts.onSelectMmsi,
onToggleHighlightMmsi: opts.onToggleHighlightMmsi,
targets: opts.targets,
hasAuxiliarySelectModifier: opts.hasAuxiliarySelectModifier,
});
}

파일 보기

@ -84,20 +84,33 @@ export function useNativeMapLayers(
useEffect(() => {
const map = mapRef.current;
if (!map) return;
let cancelled = false;
let retryRaf: number | null = null;
const ensure = () => {
if (cancelled) return;
const cfg = configRef.current;
if (projectionBusyRef.current) return;
// 1. Visibility 토글
for (const spec of cfg.layers) {
setLayerVisibility(map, spec.id, cfg.visible);
}
// projection transition 중에는 가시성 토글만 먼저 반영하고,
// source/layer 업데이트는 transition 종료 후 재시도한다.
if (projectionBusyRef.current) {
if (cfg.visible) {
retryRaf = requestAnimationFrame(ensure);
}
return;
}
// 2. 데이터가 있는 source가 하나도 없으면 종료
const hasData = cfg.sources.some((s) => s.data != null);
if (!hasData) return;
if (!map.isStyleLoaded()) return;
// isStyleLoaded() 가드 제거 — AIS poll의 setData()로 인해
// 일시적으로 false가 되어 데이터 업데이트가 스킵되는 문제 방지.
// 실패 시 try/catch가 처리하고, 다음 deps 변경 시 자연 재시도.
try {
// 3. Source 생성/업데이트
@ -150,6 +163,10 @@ export function useNativeMapLayers(
const stop = onMapStyleReady(map, ensure);
return () => {
cancelled = true;
if (retryRaf != null) {
cancelAnimationFrame(retryRaf);
}
stop();
};
// eslint-disable-next-line react-hooks/exhaustive-deps

파일 보기

@ -80,54 +80,67 @@ export function useProjectionToggle(
};
}, [clearProjectionBusyTimer, endProjectionLoading]);
const reorderRafRef = useRef(0);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const reorderGlobeFeatureLayers = useCallback(() => {
const map = mapRef.current;
if (!map || projectionRef.current !== 'globe') return;
if (!mapRef.current || projectionRef.current !== 'globe') return;
if (projectionBusyRef.current) return;
if (!map.isStyleLoaded()) return;
if (reorderRafRef.current) return; // 이미 스케줄됨 — 프레임당 1회 실행
reorderRafRef.current = requestAnimationFrame(() => {
reorderRafRef.current = 0;
const m = mapRef.current;
if (!m || !m.isStyleLoaded()) return;
const ordering = [
'subcables-hitarea',
'subcables-casing',
'subcables-line',
'subcables-glow',
'subcables-points',
'subcables-label',
'vessel-track-line',
'vessel-track-line-hitarea',
'vessel-track-arrow',
'vessel-track-pts',
'vessel-track-pts-highlight',
'zones-fill',
'zones-line',
'zones-label',
'predict-vectors-outline',
'predict-vectors',
'predict-vectors-hl-outline',
'predict-vectors-hl',
'ships-globe-halo',
'ships-globe-outline',
'ships-globe',
'ships-globe-label',
'ships-globe-hover-halo',
'ships-globe-hover-outline',
'ships-globe-hover',
'pair-lines-ml',
'fc-lines-ml',
'pair-range-ml',
'fleet-circles-ml',
];
const ordering = [
'subcables-hitarea',
'subcables-casing',
'subcables-line',
'subcables-glow',
'subcables-points',
'subcables-label',
'vessel-track-line',
'vessel-track-line-hitarea',
'vessel-track-arrow',
'vessel-track-pts',
'vessel-track-pts-highlight',
'track-replay-globe-path',
'track-replay-globe-points',
'track-replay-globe-virtual-ship',
'track-replay-globe-virtual-label',
'zones-fill',
'zones-line',
'zones-label',
'fleet-circles-ml',
'fc-lines-ml',
'pair-range-ml',
'pair-lines-ml',
'predict-vectors-outline',
'predict-vectors',
'predict-vectors-hl-outline',
'predict-vectors-hl',
'ships-globe-halo',
'ships-globe-outline',
'ships-globe-alarm-pulse',
'ships-globe-lite',
'ships-globe',
'ships-globe-label',
'ships-globe-alarm-badge',
'ships-globe-hover-halo',
'ships-globe-hover-outline',
'ships-globe-hover',
];
for (const layerId of ordering) {
try {
if (map.getLayer(layerId)) map.moveLayer(layerId);
} catch {
// ignore
for (const layerId of ordering) {
try {
if (m.getLayer(layerId)) m.moveLayer(layerId);
} catch {
// ignore
}
}
}
kickRepaint(map);
kickRepaint(m);
});
}, []);
// Projection toggle (mercator <-> globe)
@ -209,6 +222,13 @@ export function useProjectionToggle(
quietMercatorOverlays();
} else {
quietGlobeDeckLayer();
quietMercatorOverlays();
// Globe custom layer를 맵에서 분리 — setProjection() 중 render 콜백에서
// stale WebGL 자원(uniform buffer 등) 참조를 방지
const gl = globeDeckLayerRef.current;
if (gl?.id) {
try { if (map.getLayer(gl.id)) map.removeLayer(gl.id); } catch { /* ignore */ }
}
}
try {

파일 보기

@ -0,0 +1,242 @@
import { useEffect, useMemo, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import type { ActiveTrack } from '../../../entities/vesselTrack/model/types';
import { convertLegacyTrackPointsToProcessedTrack } from '../../../features/trackReplay/lib/adapters';
import { useTrackQueryStore } from '../../../features/trackReplay/stores/trackQueryStore';
import type { TrackReplayDeckRenderState } from '../../../features/trackReplay/hooks/useTrackReplayDeckLayers';
import { useNativeMapLayers, type NativeLayerSpec, type NativeSourceConfig } from './useNativeMapLayers';
import type { MapProjectionId } from '../types';
import { kickRepaint } from '../lib/mapCore';
const GLOBE_LINE_SRC = 'track-replay-globe-line-src';
const GLOBE_POINT_SRC = 'track-replay-globe-point-src';
const GLOBE_VIRTUAL_SRC = 'track-replay-globe-virtual-src';
const GLOBE_TRACK_LAYER_IDS = {
PATH: 'track-replay-globe-path',
POINTS: 'track-replay-globe-points',
VIRTUAL_SHIP: 'track-replay-globe-virtual-ship',
VIRTUAL_LABEL: 'track-replay-globe-virtual-label',
} as const;
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function normalizeCoordinate(value: unknown): [number, number] | null {
if (!Array.isArray(value) || value.length !== 2) return null;
const lon = toFiniteNumber(value[0]);
const lat = toFiniteNumber(value[1]);
if (lon == null || lat == null) return null;
return [lon, lat];
}
export function useTrackReplayLayer(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
projection: MapProjectionId;
mapSyncEpoch: number;
activeTrack?: ActiveTrack | null;
renderState: TrackReplayDeckRenderState;
},
) {
const { projection, mapSyncEpoch, activeTrack = null, renderState } = opts;
const { enabledTracks, currentPositions, showPoints, showVirtualShip, showLabels, renderEpoch } = renderState;
const setTracks = useTrackQueryStore((state) => state.setTracks);
// Backward compatibility path: if legacy activeTrack is provided, load it into the new store.
useEffect(() => {
if (!activeTrack) return;
if (!activeTrack.points || activeTrack.points.length === 0) return;
const converted = convertLegacyTrackPointsToProcessedTrack(activeTrack.mmsi, activeTrack.points);
if (!converted) return;
setTracks([converted]);
}, [activeTrack, setTracks]);
const lineGeoJson = useMemo<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => {
const features: GeoJSON.Feature<GeoJSON.LineString>[] = [];
for (const track of enabledTracks) {
const coordinates: [number, number][] = [];
for (const coord of track.geometry) {
const normalized = normalizeCoordinate(coord);
if (normalized) coordinates.push(normalized);
}
if (coordinates.length < 2) continue;
features.push({
type: 'Feature',
properties: {
vesselId: track.vesselId,
shipName: track.shipName,
},
geometry: {
type: 'LineString',
coordinates,
},
});
}
return {
type: 'FeatureCollection',
features,
};
}, [enabledTracks]);
const pointGeoJson = useMemo<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
for (const track of enabledTracks) {
track.geometry.forEach((coord, index) => {
const normalized = normalizeCoordinate(coord);
if (!normalized) return;
features.push({
type: 'Feature',
properties: {
vesselId: track.vesselId,
shipName: track.shipName,
index,
},
geometry: {
type: 'Point',
coordinates: normalized,
},
});
});
}
return {
type: 'FeatureCollection',
features,
};
}, [enabledTracks]);
const virtualGeoJson = useMemo<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
for (const position of currentPositions) {
const normalized = normalizeCoordinate(position.position);
if (!normalized) continue;
features.push({
type: 'Feature',
properties: {
vesselId: position.vesselId,
shipName: position.shipName,
},
geometry: {
type: 'Point',
coordinates: normalized,
},
});
}
return {
type: 'FeatureCollection',
features,
};
}, [currentPositions]);
const globeSources = useMemo<NativeSourceConfig[]>(
() => [
{ id: GLOBE_LINE_SRC, data: lineGeoJson },
{ id: GLOBE_POINT_SRC, data: pointGeoJson },
{ id: GLOBE_VIRTUAL_SRC, data: virtualGeoJson },
],
[lineGeoJson, pointGeoJson, virtualGeoJson],
);
const globeLayers = useMemo<NativeLayerSpec[]>(
() => [
{
id: GLOBE_TRACK_LAYER_IDS.PATH,
type: 'line',
sourceId: GLOBE_LINE_SRC,
paint: {
'line-color': '#00d1ff',
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 8, 3, 12, 4],
'line-opacity': 0.8,
},
layout: {
'line-cap': 'round',
'line-join': 'round',
},
},
{
id: GLOBE_TRACK_LAYER_IDS.POINTS,
type: 'circle',
sourceId: GLOBE_POINT_SRC,
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 8, 3, 12, 4],
'circle-color': '#00d1ff',
'circle-opacity': showPoints ? 0.8 : 0,
'circle-stroke-width': 1,
'circle-stroke-color': 'rgba(2,6,23,0.8)',
},
},
{
id: GLOBE_TRACK_LAYER_IDS.VIRTUAL_SHIP,
type: 'circle',
sourceId: GLOBE_VIRTUAL_SRC,
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 2.5, 8, 4, 12, 6],
'circle-color': '#f59e0b',
'circle-opacity': showVirtualShip ? 0.9 : 0,
'circle-stroke-width': 1,
'circle-stroke-color': 'rgba(255,255,255,0.8)',
},
},
{
id: GLOBE_TRACK_LAYER_IDS.VIRTUAL_LABEL,
type: 'symbol',
sourceId: GLOBE_VIRTUAL_SRC,
paint: {
'text-color': 'rgba(226,232,240,0.95)',
'text-opacity': showLabels ? 1 : 0,
'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1,
},
layout: {
'text-field': ['get', 'shipName'],
'text-size': 11,
'text-anchor': 'left',
'text-offset': [0.8, 0],
},
},
],
[showLabels, showPoints, showVirtualShip],
);
const isGlobeVisible = projection === 'globe' && enabledTracks.length > 0;
useNativeMapLayers(
mapRef,
projectionBusyRef,
reorderGlobeFeatureLayers,
{
sources: globeSources,
layers: globeLayers,
visible: isGlobeVisible,
beforeLayer: ['zones-fill', 'zones-line'],
},
[projection, mapSyncEpoch, renderEpoch, lineGeoJson, pointGeoJson, virtualGeoJson, showPoints, showVirtualShip, showLabels],
);
useEffect(() => {
if (projection !== 'globe') return;
if (!isGlobeVisible) return;
const map = mapRef.current;
if (!map || projectionBusyRef.current) return;
kickRepaint(map);
const id = requestAnimationFrame(() => {
kickRepaint(map);
});
return () => cancelAnimationFrame(id);
}, [projection, isGlobeVisible, renderEpoch, mapRef, projectionBusyRef]);
}

파일 보기

@ -107,6 +107,7 @@ const GLOBE_LAYERS: NativeLayerSpec[] = [
const ANIM_CYCLE_SEC = 20;
/* ── Hook ──────────────────────────────────────────────────────────── */
/** @deprecated trackReplay store 엔진으로 이관 완료. 유지보수 호환 용도로만 남겨둔다. */
export function useVesselTrackLayer(
mapRef: MutableRefObject<maplibregl.Map | null>,
overlayRef: MutableRefObject<MapboxOverlay | null>,

파일 보기

@ -13,10 +13,11 @@ import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { guardedSetVisibility } from '../lib/layerHelpers';
/** Globe tessellation vertex 65535 .
* ( 2100+ vertex) globe에서 70,000+
* ring당 maxPts개로 . centroid에는 . */
* ( 2100+ vertex) globe에서 ~33x로
* ring당 maxPts개로 . centroid에는 .
* 4 × 300pts × 33x 39,600 vertices (< 65535 limit). */
function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson {
const MAX_PTS = 60;
const MAX_PTS = 300;
const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => {
if (ring.length <= MAX_PTS) return ring;
const step = Math.ceil(ring.length / MAX_PTS);

파일 보기

@ -0,0 +1,588 @@
import { HexagonLayer } from '@deck.gl/aggregation-layers';
import { IconLayer, LineLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { DashSeg, PairRangeCircle } from '../types';
import {
SHIP_ICON_MAPPING,
FLAT_SHIP_ICON_SIZE,
FLAT_SHIP_ICON_SIZE_SELECTED,
FLAT_SHIP_ICON_SIZE_HIGHLIGHTED,
FLAT_LEGACY_HALO_RADIUS,
FLAT_LEGACY_HALO_RADIUS_SELECTED,
FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED,
EMPTY_MMSI_SET,
DEPTH_DISABLED_PARAMS,
GLOBE_OVERLAY_PARAMS,
HALO_OUTLINE_COLOR,
HALO_OUTLINE_COLOR_SELECTED,
HALO_OUTLINE_COLOR_HIGHLIGHTED,
PAIR_RANGE_NORMAL_DECK,
PAIR_RANGE_WARN_DECK,
PAIR_LINE_NORMAL_DECK,
PAIR_LINE_WARN_DECK,
FC_LINE_NORMAL_DECK,
FC_LINE_SUSPICIOUS_DECK,
FLEET_RANGE_LINE_DECK,
FLEET_RANGE_FILL_DECK,
PAIR_RANGE_NORMAL_DECK_HL,
PAIR_RANGE_WARN_DECK_HL,
PAIR_LINE_NORMAL_DECK_HL,
PAIR_LINE_WARN_DECK_HL,
FC_LINE_NORMAL_DECK_HL,
FC_LINE_SUSPICIOUS_DECK_HL,
FLEET_RANGE_LINE_DECK_HL,
FLEET_RANGE_FILL_DECK_HL,
} from '../constants';
import { getDisplayHeading, getShipColor } from './shipUtils';
import { getCachedShipIcon } from './shipIconCache';
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
interface DeckHoverCallbacks {
touchDeckHoverState: (isHover: boolean) => void;
setDeckHoverPairs: (next: number[]) => void;
setDeckHoverMmsi: (next: number[]) => void;
clearDeckHoverPairs: () => void;
clearMapFleetHoverState: () => void;
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
toFleetMmsiList: (value: unknown) => number[];
}
interface DeckSelectCallbacks {
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
onSelectMmsi: (mmsi: number | null) => void;
onToggleHighlightMmsi?: (mmsi: number) => void;
onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void;
}
/* ── Mercator Deck 레이어 ─────────────────────────────── */
export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks {
shipLayerData: AisTarget[];
shipOverlayLayerData: AisTarget[];
legacyTargetsOrdered: AisTarget[];
legacyOverlayTargets: AisTarget[];
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
pairLinks: PairLink[] | undefined;
fcDashed: DashSeg[];
fleetCircles: FleetCircle[] | undefined;
pairRanges: PairRangeCircle[];
pairLinksInteractive: PairLink[];
pairRangesInteractive: PairRangeCircle[];
fcLinesInteractive: DashSeg[];
fleetCirclesInteractive: FleetCircle[];
overlays: MapToggleState;
showDensity: boolean;
showShips: boolean;
selectedMmsi: number | null;
shipHighlightSet: Set<number>;
alarmTargets?: AisTarget[];
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
alarmPulseRadius?: number;
alarmPulseHoverRadius?: number;
}
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
const layers: unknown[] = [];
const overlayParams = DEPTH_DISABLED_PARAMS;
const clearDeckHover = () => { ctx.touchDeckHoverState(false); };
const isTargetShip = (mmsi: number) => (ctx.legacyHits ? ctx.legacyHits.has(mmsi) : false);
const shipOtherData: AisTarget[] = [];
const shipTargetData: AisTarget[] = [];
for (const t of ctx.shipLayerData) {
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
else shipOtherData.push(t);
}
const shipOverlayOtherData: AisTarget[] = [];
for (const t of ctx.shipOverlayLayerData) {
if (!isTargetShip(t.mmsi)) shipOverlayOtherData.push(t);
}
/* ─ density ─ */
if (ctx.showDensity) {
layers.push(
new HexagonLayer<AisTarget>({
id: 'density',
data: ctx.shipLayerData,
pickable: true,
extruded: true,
radius: 2500,
elevationScale: 35,
coverage: 0.92,
opacity: 0.35,
getPosition: (d) => [d.lon, d.lat],
}),
);
}
/* ─ pair range ─ */
if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) {
const validRanges = ctx.pairRanges.filter((d) => d.center && isFinite(d.center[0]) && isFinite(d.center[1]) && isFinite(d.radiusNm));
if (validRanges.length > 0) layers.push(
new ScatterplotLayer<PairRangeCircle>({
id: 'pair-range',
data: validRanges,
pickable: true,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
radiusMinPixels: 10,
lineWidthUnits: 'pixels',
getLineWidth: () => 1.8,
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
getPosition: (d) => d.center,
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
ctx.touchDeckHoverState(true);
const p = info.object as PairRangeCircle;
ctx.setDeckHoverPairs([p.aMmsi, p.bMmsi]);
ctx.setDeckHoverMmsi([p.aMmsi, p.bMmsi]);
ctx.clearMapFleetHoverState();
},
onClick: (info) => {
if (!info.object) { ctx.onSelectMmsi(null); return; }
const obj = info.object as PairRangeCircle;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
ctx.onToggleHighlightMmsi?.(obj.aMmsi);
ctx.onToggleHighlightMmsi?.(obj.bMmsi);
return;
}
ctx.onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
},
}),
);
}
/* ─ pair lines ─ */
if (ctx.overlays.pairLines && (ctx.pairLinks?.length ?? 0) > 0) {
layers.push(
new LineLayer<PairLink>({
id: 'pair-lines',
data: ctx.pairLinks,
pickable: true,
parameters: overlayParams,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK),
getWidth: (d) => (d.warn ? 3.5 : 2.5),
widthUnits: 'pixels',
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
ctx.touchDeckHoverState(true);
const obj = info.object as PairLink;
ctx.setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
ctx.setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
ctx.clearMapFleetHoverState();
},
onClick: (info) => {
if (!info.object) return;
const obj = info.object as PairLink;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
ctx.onToggleHighlightMmsi?.(obj.aMmsi);
ctx.onToggleHighlightMmsi?.(obj.bMmsi);
return;
}
ctx.onDeckSelectOrHighlight({ mmsi: obj.aMmsi });
},
}),
);
}
/* ─ fc lines ─ */
if (ctx.overlays.fcLines && ctx.fcDashed.length > 0) {
layers.push(
new LineLayer<DashSeg>({
id: 'fc-lines',
data: ctx.fcDashed,
pickable: true,
parameters: overlayParams,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
getWidth: () => 2.2,
widthUnits: 'pixels',
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
ctx.touchDeckHoverState(true);
const obj = info.object as DashSeg;
if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; }
ctx.setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]);
ctx.setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]);
ctx.clearMapFleetHoverState();
},
onClick: (info) => {
if (!info.object) return;
const obj = info.object as DashSeg;
if (obj.fromMmsi == null || obj.toMmsi == null) return;
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
ctx.onToggleHighlightMmsi?.(obj.fromMmsi);
ctx.onToggleHighlightMmsi?.(obj.toMmsi);
return;
}
ctx.onDeckSelectOrHighlight({ mmsi: obj.fromMmsi });
},
}),
);
}
/* ─ fleet circles ─ */
if (ctx.overlays.fleetCircles && (ctx.fleetCircles?.length ?? 0) > 0) {
layers.push(
new ScatterplotLayer<FleetCircle>({
id: 'fleet-circles',
data: ctx.fleetCircles,
pickable: true,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
lineWidthUnits: 'pixels',
getLineWidth: () => 2.0,
getLineColor: () => FLEET_RANGE_LINE_DECK,
getPosition: (d) => d.center,
onHover: (info) => {
if (!info.object) { clearDeckHover(); return; }
ctx.touchDeckHoverState(true);
const obj = info.object as FleetCircle;
const list = ctx.toFleetMmsiList(obj.vesselMmsis);
ctx.setMapFleetHoverState(obj.ownerKey || null, list);
ctx.setDeckHoverMmsi(list);
ctx.clearDeckHoverPairs();
},
onClick: (info) => {
if (!info.object) return;
const obj = info.object as FleetCircle;
const list = ctx.toFleetMmsiList(obj.vesselMmsis);
const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent;
if (sourceEvent && ctx.hasAuxiliarySelectModifier(sourceEvent)) {
for (const mmsi of list) ctx.onToggleHighlightMmsi?.(mmsi);
return;
}
const first = list[0];
if (first != null) ctx.onDeckSelectOrHighlight({ mmsi: first });
},
}),
);
layers.push(
new ScatterplotLayer<FleetCircle>({
id: 'fleet-circles-fill',
data: ctx.fleetCircles,
pickable: false,
billboard: false,
parameters: overlayParams,
filled: true,
stroked: false,
radiusUnits: 'meters',
getRadius: (d) => d.radiusNm * 1852,
getFillColor: () => FLEET_RANGE_FILL_DECK,
getPosition: (d) => d.center,
}),
);
}
/* ─ ships ─ */
if (ctx.showShips) {
const shipOnHover = (info: PickingInfo) => {
if (!info.object) { clearDeckHover(); return; }
ctx.touchDeckHoverState(true);
const obj = info.object as AisTarget;
ctx.setDeckHoverMmsi([obj.mmsi]);
ctx.clearDeckHoverPairs();
ctx.clearMapFleetHoverState();
};
const shipOnClick = (info: PickingInfo) => {
if (!info.object) { ctx.onSelectMmsi(null); return; }
ctx.onDeckSelectOrHighlight(
{
mmsi: (info.object as AisTarget).mmsi,
srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent,
},
true,
);
};
if (shipOtherData.length > 0) {
layers.push(
new IconLayer<AisTarget>({
id: 'ships-other',
data: shipOtherData,
pickable: true,
billboard: false,
parameters: overlayParams,
iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number],
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
sizeUnits: 'pixels',
getSize: () => FLAT_SHIP_ICON_SIZE,
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
onHover: shipOnHover,
onClick: shipOnClick,
alphaCutoff: 0.05,
}),
);
}
if (shipOverlayOtherData.length > 0) {
layers.push(
new IconLayer<AisTarget>({
id: 'ships-overlay-other',
data: shipOverlayOtherData,
pickable: false,
billboard: false,
parameters: overlayParams,
iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number],
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
sizeUnits: 'pixels',
getSize: (d) => {
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED;
if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED;
return 0;
},
getColor: (d) => getShipColor(d, ctx.selectedMmsi, null, ctx.shipHighlightSet),
alphaCutoff: 0.05,
}),
);
}
if (ctx.legacyTargetsOrdered.length > 0) {
layers.push(
new ScatterplotLayer<AisTarget>({
id: 'legacy-halo',
data: ctx.legacyTargetsOrdered,
pickable: false,
billboard: false,
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: 'pixels',
getRadius: () => FLAT_LEGACY_HALO_RADIUS,
lineWidthUnits: 'pixels',
getLineWidth: () => 2,
getLineColor: () => HALO_OUTLINE_COLOR,
getPosition: (d) => [d.lon, d.lat] as [number, number],
}),
);
}
if (shipTargetData.length > 0) {
layers.push(
new IconLayer<AisTarget>({
id: 'ships-target',
data: shipTargetData,
pickable: true,
billboard: false,
parameters: overlayParams,
iconAtlas: getCachedShipIcon(),
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => 'ship',
getPosition: (d) => [d.lon, d.lat] as [number, number],
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
sizeUnits: 'pixels',
getSize: () => FLAT_SHIP_ICON_SIZE,
getColor: (d) => getShipColor(d, null, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
onHover: shipOnHover,
onClick: shipOnClick,
alphaCutoff: 0.05,
}),
);
}
}
/* ─ interactive overlays ─ */
if (ctx.pairRangesInteractive.length > 0) {
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: ctx.pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.8, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
}
if (ctx.pairLinksInteractive.length > 0) {
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: ctx.pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 4.5, widthUnits: 'pixels' }));
}
if (ctx.fcLinesInteractive.length > 0) {
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: ctx.fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 3.2, widthUnits: 'pixels' }));
}
if (ctx.fleetCirclesInteractive.length > 0) {
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 3.0, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
}
/* ─ legacy overlay (highlight/selected) ─ */
if (ctx.showShips && ctx.legacyOverlayTargets.length > 0) {
layers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-overlay', data: ctx.legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
}
if (ctx.showShips && ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)).length > 0) {
const shipOverlayTargetData2 = ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi));
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!ctx.shipHighlightSet.has(d.mmsi) && !(ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, ctx.selectedMmsi, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, ctx.shipHighlightSet); } }));
}
/* ─ ship name labels (Mercator) ─ */
if (ctx.showShips && ctx.overlays.shipLabels) {
const labelData: AisTarget[] = [];
for (const t of ctx.shipLayerData) {
const isTarget = ctx.legacyHits?.has(t.mmsi) ?? false;
const isSelected = ctx.selectedMmsi != null && t.mmsi === ctx.selectedMmsi;
const isHighlighted = ctx.shipHighlightSet.has(t.mmsi);
if (isTarget || isSelected || isHighlighted) labelData.push(t);
}
if (labelData.length > 0) {
layers.push(
new TextLayer<AisTarget>({
id: 'ship-labels',
data: labelData,
pickable: false,
billboard: true,
getText: (d) => {
const legacy = ctx.legacyHits?.get(d.mmsi);
const baseName = (legacy?.shipNameCn || legacy?.shipNameRoman || d.name || '').trim();
if (!baseName) return '';
const alarmKind = ctx.alarmMmsiMap?.get(d.mmsi) ?? null;
return alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
},
getPosition: (d) => [d.lon, d.lat] as [number, number],
getColor: (d) => {
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 242];
if (ctx.shipHighlightSet.has(d.mmsi)) return [245, 158, 11, 242];
return [226, 232, 240, 234];
},
getSize: 11,
sizeUnits: 'pixels',
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, Noto Sans KR, sans-serif',
characterSet: 'auto',
getPixelOffset: [0, 16],
getTextAnchor: 'middle',
outlineWidth: 2,
outlineColor: [2, 6, 23, 217],
}),
);
}
}
/* ─ alarm pulse + badge ─ */
const alarmTargets = ctx.alarmTargets ?? [];
const alarmMap = ctx.alarmMmsiMap;
if (ctx.showShips && alarmMap && alarmMap.size > 0 && alarmTargets.length > 0) {
const pulseR = ctx.alarmPulseRadius ?? 8;
const pulseHR = ctx.alarmPulseHoverRadius ?? 12;
layers.push(
new ScatterplotLayer<AisTarget>({
id: 'alarm-pulse',
data: alarmTargets,
pickable: false,
billboard: false,
parameters: overlayParams,
filled: true,
stroked: false,
radiusUnits: 'pixels',
getRadius: (d) => {
const isHover = (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) || ctx.shipHighlightSet.has(d.mmsi);
return isHover ? pulseHR : pulseR;
},
getFillColor: (d) => {
const kind = alarmMap.get(d.mmsi);
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
},
getPosition: (d) => [d.lon, d.lat] as [number, number],
updateTriggers: { getRadius: [pulseR, pulseHR, ctx.selectedMmsi, ctx.shipHighlightSet] },
}),
);
layers.push(
new TextLayer<AisTarget>({
id: 'alarm-badge',
data: alarmTargets,
pickable: false,
parameters: overlayParams,
getText: (d) => {
const kind = alarmMap.get(d.mmsi);
return kind ? ALARM_BADGE[kind].label : '';
},
getPosition: (d) => [d.lon, d.lat] as [number, number],
getColor: [255, 255, 255, 255],
getBackgroundColor: (d) => {
const kind = alarmMap.get(d.mmsi);
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number];
},
background: true,
backgroundPadding: [3, 1],
getPixelOffset: [14, -14],
sizeUnits: 'pixels',
getSize: 10,
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, Noto Sans KR, sans-serif',
characterSet: 'auto',
}),
);
}
return layers;
}
/* ── Globe Deck 오버레이 레이어 ────────────────────────── */
export interface GlobeDeckLayerContext {
pairRanges: PairRangeCircle[];
pairLinks: PairLink[] | undefined;
fcDashed: DashSeg[];
fleetCircles: FleetCircle[] | undefined;
legacyTargetsOrdered: AisTarget[];
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
overlays: MapToggleState;
showShips: boolean;
selectedMmsi: number | null;
isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean;
isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean;
isHighlightedMmsi: (mmsi: number) => boolean;
touchDeckHoverState: (isHover: boolean) => void;
setDeckHoverPairs: (next: number[]) => void;
setDeckHoverMmsi: (next: number[]) => void;
clearDeckHoverPairs: () => void;
clearDeckHoverMmsi: () => void;
clearMapFleetHoverState: () => void;
setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void;
toFleetMmsiList: (value: unknown) => number[];
}
export function buildGlobeDeckLayers(ctx: GlobeDeckLayerContext): unknown[] {
const overlayParams = GLOBE_OVERLAY_PARAMS;
const globeLayers: unknown[] = [];
if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) {
globeLayers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-globe', data: ctx.pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = ctx.isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const p = info.object as PairRangeCircle; ctx.setDeckHoverPairs([p.aMmsi, p.bMmsi]); ctx.setDeckHoverMmsi([p.aMmsi, p.bMmsi]); ctx.clearMapFleetHoverState(); } }));
}
if (ctx.overlays.pairLines && (ctx.pairLinks?.length ?? 0) > 0) {
const links = ctx.pairLinks || [];
globeLayers.push(new LineLayer<PairLink>({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = ctx.isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (ctx.isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const obj = info.object as PairLink; ctx.setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); ctx.setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); ctx.clearMapFleetHoverState(); } }));
}
if (ctx.overlays.fcLines && ctx.fcDashed.length > 0) {
globeLayers.push(new LineLayer<DashSeg>({ id: 'fc-lines-globe', data: ctx.fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => ctx.isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => ctx.isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); return; } ctx.setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); ctx.setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); ctx.clearMapFleetHoverState(); } }));
}
if (ctx.overlays.fleetCircles && (ctx.fleetCircles?.length ?? 0) > 0) {
const circles = ctx.fleetCircles || [];
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { ctx.clearDeckHoverPairs(); ctx.clearDeckHoverMmsi(); ctx.clearMapFleetHoverState(); return; } ctx.touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = ctx.toFleetMmsiList(obj.vesselMmsis); ctx.setMapFleetHoverState(obj.ownerKey || null, list); ctx.setDeckHoverMmsi(list); ctx.clearDeckHoverPairs(); } }));
globeLayers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (ctx.isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center }));
}
if (ctx.showShips && ctx.legacyTargetsOrdered.length > 0) {
globeLayers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-globe', data: ctx.legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
}
return globeLayers;
}

파일 보기

@ -49,7 +49,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
try {
map.off('style.load', runOnce);
map.off('styledata', runOnce);
map.off('idle', runOnce);
} catch {
// ignore
}
@ -57,7 +56,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
map.on('style.load', runOnce);
map.on('styledata', runOnce);
map.on('idle', runOnce);
return () => {
if (fired) return;
@ -66,7 +64,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
if (!map) return;
map.off('style.load', runOnce);
map.off('styledata', runOnce);
map.off('idle', runOnce);
} catch {
// ignore
}
@ -83,12 +80,8 @@ export function extractProjectionType(map: maplibregl.Map): MapProjectionId | un
return undefined;
}
export function getMapTilerKey(): string | null {
const k = import.meta.env.VITE_MAPTILER_KEY;
if (typeof k !== 'string') return null;
const v = k.trim();
return v ? v : null;
}
// Canonical source: shared/lib/map/mapTilerKey.ts (re-exported for local usage)
export { getMapTilerKey } from '../../../shared/lib/map/mapTilerKey';
export function getLayerId(value: unknown): string | null {
if (!value || typeof value !== 'object') return null;
@ -103,6 +96,19 @@ export function sanitizeDeckLayerList(value: unknown): unknown[] {
let dropped = 0;
for (const layer of value) {
// Deck layer instances expose `id`, `props`, and `clone`.
// Filter out MapLibre native layer specs that only share an `id`.
const isDeckLayerLike =
!!layer &&
typeof layer === 'object' &&
typeof (layer as { id?: unknown }).id === 'string' &&
typeof (layer as { clone?: unknown }).clone === 'function' &&
typeof (layer as { props?: unknown }).props === 'object';
if (!isDeckLayerLike) {
dropped += 1;
continue;
}
const layerId = getLayerId(layer);
if (!layerId) {
dropped += 1;

파일 보기

@ -41,28 +41,46 @@ export function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) {
return ['any', ...clauses] as unknown[];
}
export function makeGlobeCircleRadiusExpr() {
const base3 = 4;
const base7 = 6;
const base10 = 8;
const base14 = 12;
const base18 = 32;
// ── Globe circle radius zoom stops ──
// MapLibre 제약: expression 당 zoom-based interpolate는 1개만 허용
// → 하나의 interpolate 안에서 각 stop 값을 case로 분기
const ZOOM_LEVELS = [3, 7, 10, 14, 18] as const;
const BASE_VALUES = [4, 6, 8, 12, 32] as const;
const SELECTED_VALUES = [4.6, 6.8, 9.0, 13.5, 36] as const;
const HIGHLIGHTED_VALUES = [4.2, 6.2, 8.2, 12.6, 34] as const;
return [
'interpolate',
['linear'],
['zoom'],
3,
['case', ['==', ['get', 'selected'], 1], 4.6, ['==', ['get', 'highlighted'], 1], 4.2, base3],
7,
['case', ['==', ['get', 'selected'], 1], 6.8, ['==', ['get', 'highlighted'], 1], 6.2, base7],
10,
['case', ['==', ['get', 'selected'], 1], 9.0, ['==', ['get', 'highlighted'], 1], 8.2, base10],
14,
['case', ['==', ['get', 'selected'], 1], 13.5, ['==', ['get', 'highlighted'], 1], 12.6, base14],
18,
['case', ['==', ['get', 'selected'], 1], 36, ['==', ['get', 'highlighted'], 1], 34, base18],
];
function buildStopsWithCase(getter: (key: string) => unknown[]) {
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
for (let i = 0; i < ZOOM_LEVELS.length; i++) {
stops.push(ZOOM_LEVELS[i]);
stops.push([
'case',
['==', getter('selected'), 1], SELECTED_VALUES[i],
['==', getter('highlighted'), 1], HIGHLIGHTED_VALUES[i],
BASE_VALUES[i],
]);
}
return stops;
}
/** feature-state 기반 — 메인 선박 레이어 (halo, outline) */
export function makeGlobeCircleRadiusExpr() {
return buildStopsWithCase((key) => ['feature-state', key]);
}
/** GeoJSON property 기반 — hover overlay 레이어 */
export function makeGlobeCircleRadiusPropExpr() {
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
for (let i = 0; i < ZOOM_LEVELS.length; i++) {
stops.push(ZOOM_LEVELS[i]);
stops.push([
'case',
['==', ['get', 'selected'], 1], SELECTED_VALUES[i],
HIGHLIGHTED_VALUES[i],
]);
}
return stops;
}
export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never;
export const GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR = makeGlobeCircleRadiusPropExpr() as never;

파일 보기

@ -63,10 +63,11 @@ export function getGlobeBaseShipColor({
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
}
if (!isFiniteNumber(sog)) return 'rgba(100,116,139,0.55)';
if (sog >= 10) return 'rgba(148,163,184,0.78)';
if (sog >= 1) return 'rgba(100,116,139,0.74)';
return 'rgba(71,85,105,0.68)';
// Keep alpha control in icon-opacity only to avoid double-multiplying transparency.
if (!isFiniteNumber(sog)) return '#64748b';
if (sog >= 10) return '#94a3b8';
if (sog >= 1) return '#64748b';
return '#475569';
}
export function getShipColor(

파일 보기

@ -4,7 +4,7 @@ import type { SubcableGeoJson } from '../../entities/subcable/model/types';
import type { ActiveTrack } from '../../entities/vesselTrack/model/types';
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
import type { FcLink, FleetCircle, LegacyAlarmKind, PairLink } from '../../features/legacyDashboard/model/types';
import type { MapStyleSettings } from '../../features/mapSettings/types';
export type Map3DSettings = {
@ -60,6 +60,7 @@ export interface Map3DProps {
onHoverCable?: (cableId: string | null) => void;
onClickCable?: (cableId: string | null) => void;
mapStyleSettings?: MapStyleSettings;
onMapReady?: (map: import('maplibre-gl').Map) => void;
initialView?: MapViewState | null;
onViewStateChange?: (view: MapViewState) => void;
onGlobeShipsReady?: (ready: boolean) => void;
@ -68,6 +69,8 @@ export interface Map3DProps {
onRequestTrack?: (mmsi: number, minutes: number) => void;
onCloseTrackMenu?: () => void;
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
/** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
}
export type DashSeg = {

파일 보기

@ -1,64 +1,133 @@
type Props = {
import { useState } from "react";
interface Props {
total: number;
fishing: number;
transit: number;
pairLinks: number;
alarms: number;
pollingStatus: "idle" | "loading" | "ready" | "error";
lastFetchMinutes: number | null;
clock: string;
adminMode?: boolean;
onLogoClick?: () => void;
userName?: string;
onLogout?: () => void;
};
theme?: "dark" | "light";
onToggleTheme?: () => void;
isSidebarOpen?: boolean;
onMenuToggle?: () => void;
}
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick, userName, onLogout }: Props) {
const statusColor =
pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) {
return (
<div className="topbar">
<div className="logo" onClick={onLogoClick} style={{ cursor: onLogoClick ? "pointer" : undefined }} title={adminMode ? "ADMIN" : undefined}>
🛰 <span>WING</span> · {adminMode ? <span style={{ fontSize: 10, color: "#F59E0B" }}>(ADMIN)</span> : null}
<>
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
<b className="text-xs text-wing-text">{total}</b>
</div>
<div className="stats">
<div className="stat">
DATA <b style={{ color: "#22C55E" }}>API</b>
</div>
<div className="stat">
POLL{" "}
<b style={{ color: statusColor }}>
{pollingStatus.toUpperCase()}
{lastFetchMinutes ? `(${lastFetchMinutes}m)` : ""}
</b>
</div>
<div className="stat">
<b>{total}</b>
</div>
<div className="stat">
<b style={{ color: "#22C55E" }}>{fishing}</b>
</div>
<div className="stat">
<b style={{ color: "#3B82F6" }}>{transit}</b>
</div>
<div className="stat">
<b style={{ color: "#F59E0B" }}>{pairLinks}</b>
</div>
<div className="stat">
<b style={{ color: "#EF4444" }}>{alarms}</b>
</div>
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
<b className="text-wing-success">{fishing}</b>
</div>
<div className="time">{clock}</div>
{userName && (
<div className="topbar-user">
<span className="topbar-user__name">{userName}</span>
{onLogout && (
<button className="topbar-user__logout" onClick={onLogout}>
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
<b className="text-wing-accent">{transit}</b>
</div>
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
<b className="text-wing-warning">{pairLinks}</b>
</div>
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
<b className="text-wing-danger">{alarms}</b>
</div>
</>
);
}
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) {
const [isStatsOpen, setIsStatsOpen] = useState(false);
return (
<div className="col-span-full relative z-[1000]">
<div className="flex h-[44px] items-center gap-2.5 border-b border-wing-border bg-wing-surface px-3.5">
{/* 햄버거 메뉴 (모바일) */}
{onMenuToggle && (
<button
className="flex cursor-pointer items-center justify-center rounded border border-wing-border bg-transparent p-1 text-wing-muted transition-colors hover:border-wing-accent hover:text-wing-text md:hidden"
onClick={onMenuToggle}
aria-label={isSidebarOpen ? "메뉴 닫기" : "메뉴 열기"}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{isSidebarOpen ? (
<>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</>
) : (
<>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</>
)}
</svg>
</button>
)}
{/* 로고 */}
<div
className={`flex items-center gap-1.5 whitespace-nowrap text-sm font-extrabold${onLogoClick ? " cursor-pointer" : ""}`}
onClick={onLogoClick}
title={adminMode ? "ADMIN" : undefined}
>
<span className="text-wing-accent">WING</span>
<span className="hidden sm:inline">·</span>
{adminMode ? <span className="text-[10px] text-wing-warning">(ADMIN)</span> : null}
</div>
{/* 데스크톱: 인라인 통계 */}
<div className="ml-auto hidden items-center gap-3.5 md:flex">
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
</div>
{/* 항상 표시: 시계 + 테마 + 사용자 */}
<div className="ml-auto flex shrink-0 items-center gap-2.5 md:ml-2.5">
<span className="whitespace-nowrap text-[10px] font-semibold text-wing-accent">{clock}</span>
{onToggleTheme && (
<button
className="cursor-pointer rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
onClick={onToggleTheme}
title={theme === "dark" ? "라이트 모드로 전환" : "다크 모드로 전환"}
>
{theme === "dark" ? "Light" : "Dark"}
</button>
)}
{userName && (
<div className="flex shrink-0 items-center gap-2">
<span className="hidden whitespace-nowrap text-[10px] font-medium text-wing-text sm:inline">{userName}</span>
{onLogout && (
<button
className="cursor-pointer whitespace-nowrap rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
onClick={onLogout}
>
</button>
)}
</div>
)}
</div>
</div>
{/* 모바일 통계 바 (펼침 시) — 레이아웃 흐름에 포함, 지도 영역 밀어내기 */}
{isStatsOpen && (
<div className="flex items-center justify-center gap-4 border-b border-wing-border bg-wing-surface px-3.5 py-2 md:hidden">
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
</div>
)}
{/* 모바일 통계 토글 탭 — topbar 하단 우측에 걸침 */}
<button
className="absolute bottom-0 right-3.5 z-10 translate-y-full cursor-pointer rounded-b-md border border-t-0 border-wing-border bg-wing-surface px-2 py-0.5 text-[8px] text-wing-muted transition-colors hover:text-wing-text md:hidden"
onClick={() => setIsStatsOpen((v) => !v)}
aria-label={isStatsOpen ? "통계 닫기" : "통계 열기"}
>
{isStatsOpen ? "▴" : "▾ 통계"}
</button>
</div>
);
}

파일 보기

@ -0,0 +1,307 @@
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import { useTrackPlaybackStore, TRACK_PLAYBACK_SPEED_OPTIONS } from '../../features/trackReplay/stores/trackPlaybackStore';
import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore';
function formatDateTime(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '--';
const date = new Date(ms);
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
date.getMinutes(),
)}:${pad(date.getSeconds())}`;
}
export function GlobalTrackReplayPanel() {
const PANEL_WIDTH = 420;
const PANEL_MARGIN = 12;
const PANEL_DEFAULT_TOP = 16;
const PANEL_RIGHT_RESERVED = 520;
const panelRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(
null,
);
const [isDragging, setIsDragging] = useState(false);
const clampPosition = useCallback(
(x: number, y: number) => {
if (typeof window === 'undefined') return { x, y };
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const panelHeight = panelRef.current?.offsetHeight ?? 360;
return {
x: Math.min(Math.max(PANEL_MARGIN, x), Math.max(PANEL_MARGIN, viewportWidth - PANEL_WIDTH - PANEL_MARGIN)),
y: Math.min(Math.max(PANEL_MARGIN, y), Math.max(PANEL_MARGIN, viewportHeight - panelHeight - PANEL_MARGIN)),
};
},
[PANEL_MARGIN, PANEL_WIDTH],
);
const [position, setPosition] = useState(() => {
if (typeof window === 'undefined') {
return { x: PANEL_MARGIN, y: PANEL_DEFAULT_TOP };
}
return {
x: Math.max(PANEL_MARGIN, window.innerWidth - PANEL_WIDTH - PANEL_RIGHT_RESERVED),
y: PANEL_DEFAULT_TOP,
};
});
const tracks = useTrackQueryStore((state) => state.tracks);
const isLoading = useTrackQueryStore((state) => state.isLoading);
const error = useTrackQueryStore((state) => state.error);
const showPoints = useTrackQueryStore((state) => state.showPoints);
const showVirtualShip = useTrackQueryStore((state) => state.showVirtualShip);
const showLabels = useTrackQueryStore((state) => state.showLabels);
const showTrail = useTrackQueryStore((state) => state.showTrail);
const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips);
const setShowPoints = useTrackQueryStore((state) => state.setShowPoints);
const setShowVirtualShip = useTrackQueryStore((state) => state.setShowVirtualShip);
const setShowLabels = useTrackQueryStore((state) => state.setShowLabels);
const setShowTrail = useTrackQueryStore((state) => state.setShowTrail);
const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips);
const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery);
const isPlaying = useTrackPlaybackStore((state) => state.isPlaying);
const currentTime = useTrackPlaybackStore((state) => state.currentTime);
const startTime = useTrackPlaybackStore((state) => state.startTime);
const endTime = useTrackPlaybackStore((state) => state.endTime);
const playbackSpeed = useTrackPlaybackStore((state) => state.playbackSpeed);
const loop = useTrackPlaybackStore((state) => state.loop);
const play = useTrackPlaybackStore((state) => state.play);
const pause = useTrackPlaybackStore((state) => state.pause);
const stop = useTrackPlaybackStore((state) => state.stop);
const setCurrentTime = useTrackPlaybackStore((state) => state.setCurrentTime);
const setPlaybackSpeed = useTrackPlaybackStore((state) => state.setPlaybackSpeed);
const toggleLoop = useTrackPlaybackStore((state) => state.toggleLoop);
const progress = useMemo(() => {
if (endTime <= startTime) return 0;
return ((currentTime - startTime) / (endTime - startTime)) * 100;
}, [startTime, endTime, currentTime]);
const isVisible = isLoading || tracks.length > 0 || !!error;
useEffect(() => {
if (!isVisible) return;
if (typeof window === 'undefined') return;
const onResize = () => {
setPosition((prev) => clampPosition(prev.x, prev.y));
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [clampPosition, isVisible]);
useEffect(() => {
if (!isVisible) return;
const onPointerMove = (event: PointerEvent) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
setPosition(() => {
const nextX = drag.originX + (event.clientX - drag.startX);
const nextY = drag.originY + (event.clientY - drag.startY);
return clampPosition(nextX, nextY);
});
};
const stopDrag = (event: PointerEvent) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
dragRef.current = null;
setIsDragging(false);
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', stopDrag);
window.addEventListener('pointercancel', stopDrag);
return () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', stopDrag);
window.removeEventListener('pointercancel', stopDrag);
};
}, [clampPosition, isVisible]);
const handleHeaderPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: position.x,
originY: position.y,
};
setIsDragging(true);
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore
}
},
[position.x, position.y],
);
if (!isVisible) return null;
return (
<div
ref={panelRef}
style={{
position: 'absolute',
left: position.x,
top: position.y,
width: PANEL_WIDTH,
background: 'rgba(15,23,42,0.94)',
border: '1px solid rgba(148,163,184,0.35)',
borderRadius: 12,
padding: 12,
color: '#e2e8f0',
zIndex: 40,
backdropFilter: 'blur(8px)',
boxShadow: '0 8px 24px rgba(2,6,23,0.45)',
}}
>
<div
onPointerDown={handleHeaderPointerDown}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
cursor: isDragging ? 'grabbing' : 'grab',
userSelect: 'none',
touchAction: 'none',
}}
>
<strong style={{ fontSize: 13 }}>Track Replay</strong>
<button
type="button"
onClick={() => closeTrackQuery()}
onPointerDown={(event) => event.stopPropagation()}
style={{
fontSize: 11,
padding: '4px 8px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.5)',
background: 'rgba(30,41,59,0.7)',
color: '#e2e8f0',
cursor: 'pointer',
}}
>
</button>
</div>
{error ? (
<div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div>
) : null}
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}> ...</div> : null}
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 8 }}>
{tracks.length} · {formatDateTime(startTime)} ~ {formatDateTime(endTime)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<button
type="button"
onClick={() => (isPlaying ? pause() : play())}
disabled={tracks.length === 0}
style={{
padding: '6px 10px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.45)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
cursor: 'pointer',
}}
>
{isPlaying ? '일시정지' : '재생'}
</button>
<button
type="button"
onClick={() => stop()}
disabled={tracks.length === 0}
style={{
padding: '6px 10px',
borderRadius: 6,
border: '1px solid rgba(148,163,184,0.45)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
cursor: 'pointer',
}}
>
</button>
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
<select
value={playbackSpeed}
onChange={(event) => setPlaybackSpeed(Number(event.target.value))}
style={{
background: 'rgba(30,41,59,0.85)',
border: '1px solid rgba(148,163,184,0.45)',
borderRadius: 6,
color: '#e2e8f0',
fontSize: 12,
padding: '4px 6px',
}}
>
{TRACK_PLAYBACK_SPEED_OPTIONS.map((speed) => (
<option key={speed} value={speed}>
{speed}x
</option>
))}
</select>
</label>
</div>
<div style={{ marginBottom: 10 }}>
<input
type="range"
min={startTime}
max={endTime || startTime + 1}
value={currentTime}
onChange={(event) => setCurrentTime(Number(event.target.value))}
style={{ width: '100%' }}
disabled={tracks.length === 0 || endTime <= startTime}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#94a3b8' }}>
<span>{formatDateTime(currentTime)}</span>
<span>{Math.max(0, Math.min(100, progress)).toFixed(1)}%</span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
<label>
<input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} />
</label>
<label>
<input
type="checkbox"
checked={showVirtualShip}
onChange={(event) => setShowVirtualShip(event.target.checked)}
/>{' '}
</label>
<label>
<input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} />
</label>
<label>
<input type="checkbox" checked={showTrail} onChange={(event) => setShowTrail(event.target.checked)} />
</label>
<label>
<input
type="checkbox"
checked={hideLiveShips}
onChange={(event) => setHideLiveShips(event.target.checked)}
/>{' '}
</label>
<label>
<input type="checkbox" checked={loop} onChange={() => toggleLoop()} />
</label>
</div>
</div>
);
}

파일 보기

@ -1,3 +1,5 @@
import { useMemo, useState } from "react";
import { TextInput } from "@wing/ui";
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
import type { MouseEvent } from "react";
@ -12,6 +14,26 @@ type Props = {
onClearHover?: () => void;
};
function isFiniteNumber(x: unknown): x is number {
return typeof x === "number" && Number.isFinite(x);
}
const STRIP_RE = /[\s\-.,_]/g;
function normalize(s: string): string {
return s.replace(STRIP_RE, "").toLowerCase();
}
function matchesQuery(v: DerivedLegacyVessel, nq: string): boolean {
if (normalize(v.permitNo).includes(nq)) return true;
if (normalize(v.name).includes(nq)) return true;
if (v.legacy.shipNameRoman && normalize(v.legacy.shipNameRoman).includes(nq)) return true;
if (v.legacy.shipNameCn && normalize(v.legacy.shipNameCn).includes(nq)) return true;
if (v.legacy.callSign && normalize(v.legacy.callSign).includes(nq)) return true;
if (normalize(String(v.mmsi)).includes(nq)) return true;
return false;
}
export function VesselList({
vessels,
selectedMmsi,
@ -21,6 +43,8 @@ export function VesselList({
onHoverMmsi,
onClearHover,
}: Props) {
const [searchQuery, setSearchQuery] = useState("");
const handlePrimaryAction = (e: MouseEvent, mmsi: number) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
onToggleHighlightMmsi(mmsi);
@ -29,17 +53,23 @@ export function VesselList({
onSelectMmsi(mmsi);
};
function isFiniteNumber(x: unknown): x is number {
return typeof x === "number" && Number.isFinite(x);
}
const sorted = vessels
.slice()
.sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1))
.slice(0, 80);
const sorted = useMemo(() => {
const nq = searchQuery.length >= 2 ? normalize(searchQuery) : "";
const filtered = nq ? vessels.filter((v) => matchesQuery(v, nq)) : vessels;
return filtered.slice().sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1));
}, [vessels, searchQuery]);
return (
<div className="vlist">
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-1.5 shrink-0 px-1">
<TextInput
placeholder="검색: 등록번호 / 선박명 / MMSI"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-5 w-full py-0 text-[9px]"
/>
</div>
<div className="vlist">
{sorted.map((v) => {
const meta = VESSEL_TYPES[v.shipCode];
const primarySegs = meta.speedProfile.filter((s) => s.primary);
@ -76,6 +106,7 @@ export function VesselList({
);
})}
{sorted.length === 0 ? <div style={{ fontSize: 11, color: "var(--muted)" }}>( )</div> : null}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,316 @@
import { useMemo, useRef, useState } from 'react';
import {
WEATHER_LAYERS,
LEGEND_META,
SPEED_OPTIONS,
type WeatherLayerId,
type WeatherOverlayState,
type WeatherOverlayActions,
} from '../../features/weatherOverlay/useWeatherOverlay';
type Props = WeatherOverlayState & WeatherOverlayActions;
/** 절대 시간 표기 (MM. DD. HH:mm) */
function fmtBase(d: Date | null): string {
if (!d) return '-';
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}. ${dd}. ${hh}:${mi}`;
}
/** epoch 초 → Date */
function secToDate(sec: number): Date {
return new Date(sec * 1000);
}
/** 기준시간 대비 오프셋 표기 (+HH:MM) */
function fmtOffset(base: Date | null, target: Date | null): string {
if (!base || !target) return '-';
const diffMs = target.getTime() - base.getTime();
const totalMin = Math.round(diffMs / 60_000);
const h = Math.floor(Math.abs(totalMin) / 60);
const m = Math.abs(totalMin) % 60;
const sign = totalMin >= 0 ? '+' : '-';
return `${sign}${h}:${String(m).padStart(2, '0')}`;
}
/** ColorRamp → CSS linear-gradient */
function rampToGradient(layerId: WeatherLayerId): string {
const { colorRamp } = LEGEND_META[layerId];
const stops = colorRamp.getRawColorStops();
if (stops.length === 0) return 'transparent';
const { min, max } = colorRamp.getBounds();
const range = max - min || 1;
const css = stops.map((s) => {
const [r, g, b, a] = s.color;
const pct = ((s.value - min) / range) * 100;
return `rgba(${r},${g},${b},${(a ?? 255) / 255}) ${pct.toFixed(1)}%`;
});
return `linear-gradient(to right, ${css.join(', ')})`;
}
/** 범례 눈금 생성 */
function getLegendTicks(layerId: WeatherLayerId): string[] {
const { colorRamp, unit } = LEGEND_META[layerId];
const { min, max } = colorRamp.getBounds();
const count = 5;
const step = (max - min) / count;
const ticks: string[] = [];
for (let i = 0; i <= count; i++) {
const v = min + step * i;
ticks.push(Number.isInteger(v) ? String(v) : v.toFixed(1));
}
ticks[ticks.length - 1] += unit;
return ticks;
}
/** steps 배열에서 value 에 가장 가까운 step epoch 초 반환 */
function snapToStep(steps: number[], value: number): number {
if (steps.length === 0) return value;
let best = steps[0];
let bestDist = Math.abs(value - best);
for (let i = 1; i < steps.length; i++) {
const dist = Math.abs(value - steps[i]);
if (dist < bestDist) {
best = steps[i];
bestDist = dist;
}
}
return best;
}
export function WeatherOverlayPanel(props: Props) {
const {
enabled,
activeLayerId,
opacity,
isPlaying,
animationSpeed,
currentTime,
startTime,
endTime,
steps,
isReady,
toggleLayer,
setOpacity,
play,
pause,
setSpeed,
seekTo,
} = props;
const [open, setOpen] = useState(false);
const activeCount = Object.values(enabled).filter(Boolean).length;
/** 드래그 중 현재 step index */
const [draggingIdx, setDraggingIdx] = useState<number | null>(null);
/** 마지막으로 seek 요청한 step index (중복 방지) */
const lastSeekedRef = useRef<number | null>(null);
const legendGradient = useMemo(
() => (activeLayerId ? rampToGradient(activeLayerId) : null),
[activeLayerId],
);
const legendTicks = useMemo(
() => (activeLayerId ? getLegendTicks(activeLayerId) : []),
[activeLayerId],
);
// 현재 시간을 step index로 변환 (재생 중 폴링 값 반영)
const currentStepIdx =
draggingIdx != null
? draggingIdx
: steps.length > 0 && currentTime
? (() => {
const curSec = currentTime.getTime() / 1000;
const snapped = snapToStep(steps, curSec);
return steps.indexOf(snapped);
})()
: 0;
/** 드래그 중: step 경계를 넘을 때마다 실시간 seek */
const handleSliderInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const idx = Number(e.target.value);
setDraggingIdx(idx);
// step이 바뀌었을 때만 seek (동일 step 반복 방지)
if (lastSeekedRef.current !== idx && steps[idx] != null) {
lastSeekedRef.current = idx;
seekTo(steps[idx]);
}
};
/** 드래그 끝: 정리 */
const handleSliderCommit = () => {
lastSeekedRef.current = null;
setDraggingIdx(null);
};
const legendInfo = activeLayerId ? LEGEND_META[activeLayerId] : null;
const showPanel = open;
const showLegend = !!activeLayerId && !!legendInfo && !!legendGradient;
return (
<>
<button
className={`wo-gear${open ? ' open' : ''}${activeCount > 0 ? ' active' : ''}`}
onClick={() => {
setOpen((prev) => {
if (prev && activeCount > 0) {
toggleLayer(activeLayerId!);
pause();
}
return !prev;
});
}}
title="기상 타일 오버레이"
type="button"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
<circle cx="12" cy="12" r="3" />
</svg>
{activeCount > 0 && <span className="wo-gear-badge">{activeCount}</span>}
</button>
{(showPanel || showLegend) && (
<div className="wo-stack">
{showPanel && (
<div className="wo-panel">
<div className="wo-header">
<span className="wo-title"> </span>
{!isReady && activeCount > 0 && <span className="wo-loading">...</span>}
</div>
<div className="wo-layers">
{WEATHER_LAYERS.map((meta) => (
<button
key={meta.id}
className={`wo-layer-btn${enabled[meta.id] ? ' on' : ''}`}
onClick={() => toggleLayer(meta.id as WeatherLayerId)}
title={meta.label}
type="button"
>
<span className="wo-layer-icon">{meta.icon}</span>
<span className="wo-layer-name">{meta.label}</span>
</button>
))}
</div>
<div className="wo-section">
<div className="wo-label">
<span className="wo-val">{Math.round(opacity * 100)}%</span>
</div>
<input
type="range"
className="wo-slider"
min={0}
max={100}
value={Math.round(opacity * 100)}
onChange={(e) => setOpacity(Number(e.target.value) / 100)}
/>
</div>
{activeCount > 0 && steps.length > 0 && (
<div className="wo-section wo-timeline">
<div className="wo-label">
<span className="wo-val">{fmtBase(startTime)}</span>
{' · '}
<span className="wo-val wo-offset">{fmtOffset(startTime, currentTime)}</span>
</div>
{/* step 기반 슬라이더 */}
<div className="wo-step-slider-wrap">
<input
type="range"
className="wo-slider wo-time-slider"
min={0}
max={steps.length - 1}
step={1}
value={currentStepIdx}
onChange={handleSliderInput}
onMouseUp={handleSliderCommit}
onTouchEnd={handleSliderCommit}
disabled={!isReady}
/>
{/* 눈금 표시 */}
<div className="wo-step-ticks">
{steps.map((sec, i) => {
const pct = steps.length > 1 ? (i / (steps.length - 1)) * 100 : 0;
const d = secToDate(sec);
const isDay = d.getHours() === 0 && d.getMinutes() === 0;
return (
<span
key={sec}
className={`wo-step-tick${isDay ? ' day' : ''}`}
style={{ left: `${pct}%` }}
/>
);
})}
</div>
</div>
<div className="wo-time-range">
<span>{fmtBase(startTime)}</span>
<span>
{draggingIdx != null && steps[draggingIdx]
? fmtBase(secToDate(steps[draggingIdx]))
: fmtBase(currentTime)}
</span>
<span>{fmtBase(endTime)}</span>
</div>
<div className="wo-playback">
<button
className="wo-play-btn"
type="button"
onClick={isPlaying ? pause : play}
disabled={!isReady}
title={isPlaying ? '일시정지' : '재생'}
>
{isPlaying ? '⏸' : '▶'}
</button>
<div className="wo-speed-btns">
{SPEED_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`wo-speed-btn${animationSpeed === opt.value ? ' on' : ''}`}
onClick={() => setSpeed(opt.value)}
type="button"
title={`${opt.label} 속도`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
)}
<div className="wo-hint">
MapTiler Weather SDK
</div>
</div>
)}
{showLegend && (
<div className="wo-legend">
<div className="wo-legend-header">
{legendInfo!.label} ({legendInfo!.unit})
</div>
<div
className="wo-legend-bar"
style={{ background: legendGradient! }}
/>
<div className="wo-legend-ticks">
{legendTicks.map((t, i) => (
<span key={i}>{t}</span>
))}
</div>
</div>
)}
</div>
)}
</>
);
}

파일 보기

@ -0,0 +1,118 @@
import { useState } from 'react';
import type { WeatherSnapshot } from '../../entities/weather/model/types';
import {
getWindDirectionLabel,
getWindArrow,
getWaveSeverity,
getWeatherLabel,
} from '../../entities/weather/lib/weatherUtils';
interface WeatherPanelProps {
snapshot: WeatherSnapshot | null;
isLoading: boolean;
error: string | null;
onRefresh: () => void;
}
function fmtTime(ts: number): string {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function fmtVal(v: number | null, unit: string, decimals = 1): string {
if (v == null) return '-';
return `${v.toFixed(decimals)}${unit}`;
}
export function WeatherPanel({ snapshot, isLoading, error, onRefresh }: WeatherPanelProps) {
const [open, setOpen] = useState(false);
return (
<>
<button
className={`weather-gear${open ? ' open' : ''}`}
onClick={() => setOpen((p) => !p)}
title="해양 기상 현황"
type="button"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12h2M6 8c0-2.2 1.8-4 4-4 1.5 0 2.8.8 3.5 2h.5c1.7 0 3 1.3 3 3s-1.3 3-3 3H6c-2.2 0-4-1.8-4-4z" />
<path d="M2 20h2M8 16a3 3 0 0 1 3-3h2a3 3 0 0 1 3 3 3 3 0 0 1-3 3H8a3 3 0 0 1 0-6z" />
</svg>
</button>
{open && (
<div className="weather-panel">
<div className="wp-header">
<span className="wp-title"> </span>
{isLoading && <span className="wp-loading">...</span>}
</div>
{error && <div className="wp-error">{error}</div>}
{snapshot && snapshot.points.map((pt) => {
const severity = getWaveSeverity(pt.waveHeight);
const isWarn = severity === 'rough' || severity === 'severe'
|| (pt.windSpeed != null && pt.windSpeed >= 10);
return (
<div
key={pt.label}
className={`wz-card${isWarn ? ' wz-warn' : ''}`}
style={{ borderLeftColor: pt.color }}
>
<div className="wz-name">{pt.label}</div>
<div className="wz-row">
<span className="wz-item">
<span className="wz-icon">~</span>
<span className="wz-value">{fmtVal(pt.waveHeight, 'm')}</span>
</span>
<span className="wz-item">
<span className="wz-icon">{getWindArrow(pt.windDirection)}</span>
<span className="wz-value">
{fmtVal(pt.windSpeed, 'm/s')} {getWindDirectionLabel(pt.windDirection)}
</span>
</span>
</div>
<div className="wz-row">
<span className="wz-item">
<span className="wz-label"></span>
<span className="wz-value">{fmtVal(pt.seaSurfaceTemp, '°C')}</span>
</span>
<span className="wz-item">
<span className="wz-label"></span>
<span className="wz-value">{fmtVal(pt.swellHeight, 'm')}</span>
</span>
{pt.weatherCode != null && (
<span className="wz-item">
<span className="wz-value wz-weather">{getWeatherLabel(pt.weatherCode)}</span>
</span>
)}
</div>
</div>
);
})}
{!snapshot && !isLoading && !error && (
<div className="wp-empty"> </div>
)}
<div className="wp-footer">
{snapshot && (
<span className="wp-time"> {fmtTime(snapshot.fetchedAt)}</span>
)}
<button
className="wp-refresh"
type="button"
onClick={onRefresh}
disabled={isLoading}
title="새로고침"
>
</button>
</div>
</div>
)}
</>
);
}

파일 보기

@ -1,3 +1,4 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
import { defineConfig, loadEnv } from "vite";
@ -15,7 +16,7 @@ export default defineConfig(({ mode }) => {
const snpApiTarget = env.VITE_SNP_API_TARGET || process.env.VITE_SNP_API_TARGET || "http://211.208.115.83:8041";
return {
plugins: [react()],
plugins: [tailwindcss(), react()],
resolve: {
alias: {
// deck.gl (via loaders.gl) contains a few Node-only helper modules.

1031
package-lock.json generated

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

파일 보기

@ -1,7 +1,7 @@
{
"name": "wing-fleet-dashboard",
"private": true,
"workspaces": ["apps/*"],
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"dev": "npm run dev:web & npm run dev:api",
"dev:web": "npm -w @wing/web run dev",

18
packages/ui/package.json Normal file
파일 보기

@ -0,0 +1,18 @@
{
"name": "@wing/ui",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./theme/tokens.css": "./src/theme/tokens.css"
},
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^3.3.0"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}

파일 보기

@ -0,0 +1,27 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '../utils/cn.ts';
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: 'default' | 'accent' | 'danger' | 'warning' | 'success' | 'muted';
children: ReactNode;
}
export function Badge({ variant = 'default', className, children, ...props }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center rounded-[5px] border px-1.5 py-px text-[8px] font-extrabold leading-none tracking-wide',
variant === 'default' && 'border-white/8 bg-wing-muted/22 text-white',
variant === 'accent' && 'border-wing-accent/70 bg-wing-accent/22 text-white',
variant === 'danger' && 'border-wing-danger/70 bg-wing-danger/22 text-white',
variant === 'warning' && 'border-wing-warning/70 bg-wing-warning/22 text-white',
variant === 'success' && 'border-wing-success/70 bg-wing-success/22 text-white',
variant === 'muted' && 'border-wing-border/90 bg-wing-card/55 text-wing-muted font-bold',
className,
)}
{...props}
>
{children}
</span>
);
}

파일 보기

@ -0,0 +1,30 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '../utils/cn.ts';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'ghost' | 'primary' | 'danger';
size?: 'sm' | 'md';
children: ReactNode;
}
export function Button({ variant = 'ghost', size = 'sm', className, children, ...props }: ButtonProps) {
return (
<button
className={cn(
'cursor-pointer rounded border transition-all duration-150 select-none whitespace-nowrap',
size === 'sm' && 'px-1.5 py-0.5 text-[9px]',
size === 'md' && 'px-2.5 py-1.5 text-xs rounded-lg',
variant === 'ghost' &&
'border-wing-border bg-transparent text-wing-muted hover:text-wing-text hover:border-wing-accent',
variant === 'primary' &&
'border-wing-accent bg-wing-accent text-white hover:brightness-110',
variant === 'danger' &&
'border-wing-danger bg-transparent text-wing-danger hover:bg-wing-danger/10',
className,
)}
{...props}
>
{children}
</button>
);
}

파일 보기

@ -0,0 +1,27 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '../utils/cn.ts';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
active?: boolean;
size?: 'sm' | 'md';
children: ReactNode;
}
export function IconButton({ active, size = 'md', className, children, ...props }: IconButtonProps) {
return (
<button
className={cn(
'flex shrink-0 cursor-pointer items-center justify-center rounded border p-0 transition-all duration-150 select-none',
'border-wing-border bg-wing-glass text-wing-muted backdrop-blur-lg',
'hover:text-wing-text hover:border-wing-accent',
size === 'sm' && 'h-[22px] w-[22px] rounded text-sm',
size === 'md' && 'h-[29px] w-[29px] rounded text-base',
active && 'text-wing-accent border-wing-accent',
className,
)}
{...props}
>
{children}
</button>
);
}

파일 보기

@ -0,0 +1,26 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '../utils/cn.ts';
interface ListItemProps extends HTMLAttributes<HTMLDivElement> {
selected?: boolean;
highlighted?: boolean;
children: ReactNode;
}
export function ListItem({ selected, highlighted, className, children, ...props }: ListItemProps) {
return (
<div
className={cn(
'flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-1 text-[10px] transition-colors duration-100 select-none',
'hover:bg-wing-card',
selected && 'bg-[var(--wing-select-bg)] border-[var(--wing-select-border)]',
highlighted && !selected && 'bg-[var(--wing-highlight-bg)] border border-[var(--wing-highlight-border)]',
selected && highlighted && 'bg-gradient-to-r from-[var(--wing-select-bg)] to-[var(--wing-highlight-bg)] border-[var(--wing-select-border)]',
className,
)}
{...props}
>
{children}
</div>
);
}

파일 보기

@ -0,0 +1,24 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '../utils/cn.ts';
interface PanelProps extends HTMLAttributes<HTMLDivElement> {
glass?: boolean;
children: ReactNode;
}
export function Panel({ glass = true, className, children, ...props }: PanelProps) {
return (
<div
className={cn(
'rounded-lg border border-wing-border p-3',
glass
? 'bg-wing-glass backdrop-blur-lg'
: 'bg-wing-surface',
className,
)}
{...props}
>
{children}
</div>
);
}

파일 보기

@ -0,0 +1,38 @@
import { useState } from 'react';
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '../utils/cn.ts';
interface SectionProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
title: ReactNode;
actions?: ReactNode;
defaultOpen?: boolean;
contentClassName?: string;
children: ReactNode;
}
export function Section({ title, actions, defaultOpen = true, className, contentClassName, children, ...props }: SectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className={cn('border-b border-wing-border', className)} {...props}>
<div
className="shrink-0 flex cursor-pointer items-center justify-between gap-2 px-3 py-2.5 select-none"
onClick={() => setIsOpen((v) => !v)}
>
<span className="text-[9px] font-bold uppercase tracking-[1.5px] text-wing-muted">
{title}
</span>
{actions && (
<span onClick={(e) => e.stopPropagation()} className="flex items-center gap-1.5">
{actions}
</span>
)}
</div>
{isOpen && (
<div className={cn('px-3 pb-2.5', contentClassName)}>
{children}
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,18 @@
import type { InputHTMLAttributes } from 'react';
import { cn } from '../utils/cn.ts';
type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
export function TextInput({ className, ...props }: TextInputProps) {
return (
<input
className={cn(
'flex-1 rounded-md border border-wing-border bg-wing-card/75 px-2 py-1.5 text-[10px] text-wing-text outline-none',
'placeholder:text-wing-muted/90',
'focus:border-wing-accent',
className,
)}
{...props}
/>
);
}

파일 보기

@ -0,0 +1,24 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '../utils/cn.ts';
interface ToggleButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
on?: boolean;
children: ReactNode;
}
export function ToggleButton({ on, className, children, ...props }: ToggleButtonProps) {
return (
<button
className={cn(
'cursor-pointer rounded border px-1.5 py-0.5 text-[8px] transition-all duration-150 select-none',
on
? 'border-wing-accent bg-wing-accent text-white'
: 'border-wing-border bg-wing-card text-wing-muted',
className,
)}
{...props}
>
{children}
</button>
);
}

파일 보기

@ -0,0 +1,8 @@
export { Button } from './Button.tsx';
export { IconButton } from './IconButton.tsx';
export { ToggleButton } from './ToggleButton.tsx';
export { Badge } from './Badge.tsx';
export { Panel } from './Panel.tsx';
export { Section } from './Section.tsx';
export { ListItem } from './ListItem.tsx';
export { TextInput } from './TextInput.tsx';

2
packages/ui/src/index.ts Normal file
파일 보기

@ -0,0 +1,2 @@
export { cn } from './utils/cn.ts';
export * from './components/index.ts';

파일 보기

@ -0,0 +1,87 @@
/* ── Wing Design Tokens ──────────────────────────────────────────── */
/* Dark theme (default) */
:root,
[data-theme='dark'] {
--wing-bg: #020617;
--wing-surface: #0f172a;
--wing-card: #1e293b;
--wing-border: #1e3a5f;
--wing-text: #e2e8f0;
--wing-muted: #64748b;
--wing-accent: #3b82f6;
--wing-danger: #ef4444;
--wing-warning: #f59e0b;
--wing-success: #22c55e;
--wing-glass: rgba(15, 23, 42, 0.92);
--wing-glass-dense: rgba(15, 23, 42, 0.95);
--wing-overlay: rgba(2, 6, 23, 0.42);
--wing-card-alpha: rgba(30, 41, 59, 0.55);
--wing-subtle: rgba(255, 255, 255, 0.03);
--wing-select-bg: rgba(14, 234, 255, 0.16);
--wing-select-border: rgba(14, 234, 255, 0.55);
--wing-highlight-bg: rgba(245, 158, 11, 0.16);
--wing-highlight-border: rgba(245, 158, 11, 0.4);
/* Legacy aliases (backward compatibility) */
--bg: var(--wing-bg);
--panel: var(--wing-surface);
--card: var(--wing-card);
--border: var(--wing-border);
--text: var(--wing-text);
--muted: var(--wing-muted);
--accent: var(--wing-accent);
--crit: var(--wing-danger);
--high: var(--wing-warning);
}
/* Light theme */
[data-theme='light'] {
--wing-bg: #e2e8f0;
--wing-surface: #ffffff;
--wing-card: #f1f5f9;
--wing-border: #94a3b8;
--wing-text: #0f172a;
--wing-muted: #64748b;
--wing-accent: #2563eb;
--wing-danger: #dc2626;
--wing-warning: #d97706;
--wing-success: #16a34a;
--wing-glass: rgba(255, 255, 255, 0.92);
--wing-glass-dense: rgba(255, 255, 255, 0.95);
--wing-overlay: rgba(0, 0, 0, 0.25);
--wing-card-alpha: rgba(226, 232, 240, 0.6);
--wing-subtle: rgba(0, 0, 0, 0.03);
--wing-select-bg: rgba(14, 182, 210, 0.14);
--wing-select-border: rgba(14, 182, 210, 0.5);
--wing-highlight-bg: rgba(217, 119, 6, 0.14);
--wing-highlight-border: rgba(217, 119, 6, 0.4);
--bg: var(--wing-bg);
--panel: var(--wing-surface);
--card: var(--wing-card);
--border: var(--wing-border);
--text: var(--wing-text);
--muted: var(--wing-muted);
--accent: var(--wing-accent);
--crit: var(--wing-danger);
--high: var(--wing-warning);
}
/* ── Tailwind Theme Mapping ──────────────────────────────────────── */
@theme {
--color-wing-bg: var(--wing-bg);
--color-wing-surface: var(--wing-surface);
--color-wing-card: var(--wing-card);
--color-wing-border: var(--wing-border);
--color-wing-text: var(--wing-text);
--color-wing-muted: var(--wing-muted);
--color-wing-accent: var(--wing-accent);
--color-wing-danger: var(--wing-danger);
--color-wing-warning: var(--wing-warning);
--color-wing-success: var(--wing-success);
--color-wing-glass: var(--wing-glass);
--font-sans: 'Noto Sans KR', sans-serif;
}

파일 보기

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

19
packages/ui/tsconfig.json Normal file
파일 보기

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true
},
"include": ["src"]
}