Compare commits

...

21 커밋

작성자 SHA1 메시지 날짜
9881b99ee7 feat(scat): Pre-SCAT 해안조사 UI 개선 + WeatherRightPanel 정리
SCAT 좌측패널 리팩토링, 해안조사 뷰 기능 보강, 기상 우측패널 중복 코드 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:22:20 +09:00
5865734b15 Merge remote-tracking branch 'origin/feature/cctv-hns-enhancements' into feature/pre-scat-develop 2026-03-19 13:53:55 +09:00
Nan Kyung Lee
0cf3ff1ea0 style(weather): 기상 레이어 체크박스 및 패널 사이즈 축소
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:53:09 +09:00
Nan Kyung Lee
7949b96866 feat(incidents): UI 개선 + 오염물 배출규정 기능 추가
- prediction: 커스텀 다크 캘린더/시간 드롭다운, DMS 좌표 입력, 모델 버튼 3열 배치
- incidents: 밝은 지도 테마, 해양환경관리법 제22조 기반 오염물 배출규정 기능
  - 지도 클릭시 영해기선 거리별 배출 가능 여부 표시 (OSM 실측 좌표 기반)
  - 3해리/12해리/25해리 경계선 표시
- weather: 기상 범례 사이즈 축소 + 폰트 축소
- map: 풍속/파고/수온/해류 패널 축소·투명화, 확대/축소 버튼 축소, 좌표 중앙 배치
- map: 범례 기본 접힌 상태

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:43:21 +09:00
Nan Kyung Lee
6bea387ee2 Merge remote-tracking branch 'origin/develop' into feature/cctv-hns-enhancements 2026-03-19 08:42:49 +09:00
Nan Kyung Lee
7110d76276 feat(aerial): WingAI (AI 탐지/분석) 서브탭 추가
- MMSI 선종 불일치 탐지: AIS 등록 선종 vs AI 영상 분석 선종 비교, 지도 위 위치 표시
- 변화 감지: AS-IS/현재 시점 복합 정보원(위성/CCTV/드론/AIS) 오버레이 비교
- 연안자동감지: 지도 폴리곤 드로잉으로 감시 구역 등록, 주기/모니터링 방법 설정
- 위성요청 라벨 '위성영상'으로 변경, 서브탭 순서 재배치
- aerial:spectral 권한 트리 마이그레이션 추가 (022)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:07:47 +09:00
Nan Kyung Lee
7fb98ebb08 feat(aerial): 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시
- Mapbox placeholder → VWorld 위성 타일(WMTS) 실제 영상으로 교체
- 완료 항목 클릭 시 해당 지역에 위성 영상 레이어 오버레이
- 선택 지점에 📷 마커 표시
- VWorld API 키 환경변수(VITE_VWORLD_API_KEY) 연동

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:50:48 +09:00
Nan Kyung Lee
8c0ada08fd feat(aerial): 위성 요청 목록 더보기 → 페이징 처리로 변경
- 더보기/접기 토글 제거
- 페이지당 5건 표시 + ◀ 1 2 ▶ 페이지 네비게이션
- "총 N건 중 1–5" 현재 범위 표시
- 필터 변경 시 전체 목록 대상 페이징 적용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:45:55 +09:00
Nan Kyung Lee
39277c1c02 style(aerial): 위성 요청 헤더/탭/새요청 높이 통일 + 상단 마진 축소
- 전체 요소 높이 h-7(28px)로 통일
- 상단 패딩 py-2→pt-1, 아이콘 w-8→w-7, 텍스트 13px→12px
- 탭 버튼 py-1.5→h-full, 새요청 py-2→h-7
- 헤더 하단 마진 mb-2 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:44:05 +09:00
Nan Kyung Lee
0549fb879f style(aerial): 위성 촬영 요청 상단 간격 축소
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:42:41 +09:00
Nan Kyung Lee
f0fee9d92b style(aerial): 위성 요청 헤더+탭 한줄 배치 + 지도 높이 확대
- 헤더(🛰 위성 촬영 요청) + 탭(요청목록/히스토리지도) + 새요청 버튼을 한 줄로 통합
- 지도 뷰 높이 calc(100vh - 160px)로 확대하여 영상 중첩 표시 공간 확보
- 헤더/탭 사이즈 축소로 컴팩트 레이아웃

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:41:37 +09:00
Nan Kyung Lee
5191e606a1 feat(aerial): 위성 히스토리 지도에 캘린더 + 날짜별 촬영 리스트 + 영상 오버레이
- 좌상단: 캘린더(date picker) + 촬영 이력 있는 날짜 바로가기 버튼
- 날짜 선택 시 해당일 촬영 내역만 필터링하여 리스트 표시
- 완료 항목 클릭 시 지도에 위성 영상 오버레이 표시 (이미지 레이어)
- 선택된 구역 폴리곤 하이라이트 (두꺼운 테두리 + 진한 채움)
- 하단 상세 정보 바: 구역명, 위성, 해상도, 좌표, 영상 표출 상태
- 요청일자를 2026-03 기준으로 업데이트 + dateKey 필드 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:30:00 +09:00
Nan Kyung Lee
19fdc489f3 fix(aerial): 촬영 히스토리 지도 리스트 위치 좌하단으로 이동
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:25:39 +09:00
Nan Kyung Lee
7564f42918 feat(aerial): 위성 요청 목록/히스토리 지도 탭 분리
- 📋 요청 목록 / 🗺 촬영 히스토리 지도 탭 토글
- 지도 뷰: MapLibre에 촬영 구역 사각형 폴리곤 표시
  상태별 색상 (촬영중=노랑, 대기=파랑, 완료=초록, 취소=빨강)
- 좌측 오버레이: 요청 리스트 (ID, 구역, 위성, 해상도, 상태)
- 우측 오버레이: 상태별 범례 + 총 건수
- parseCoord 헬퍼: "33.24°N 126.50°E" → {lat, lon} 파싱

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:20:10 +09:00
Nan Kyung Lee
00e7a3e70a feat(aerial): 위성 요청 취소 기능 추가
- SatRequest status에 '취소' 상태 추가
- 필터 탭에 '취소' 추가
- 대기/촬영중 상태 모두 취소 가능 (confirm 팝업)
- 취소된 요청은 빨간 ✕ 배지 + 투명도 60%
- satRequests를 상태(state)로 관리하여 실시간 상태 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:16:31 +09:00
Nan Kyung Lee
0c4bfb2f24 fix(aerial): UP42 모달 지도 크기 탭별 동일하게 고정
- 모달 높이 85vh 고정 (max-h → height)
- 지도 영역 minHeight 350px 보장
- Optical/SAR/Elevation 탭 전환 시 지도 크기 일정 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:13:10 +09:00
Nan Kyung Lee
044994bd57 feat(aerial): UP42 위성 패스 조회 + 궤도 지도 표시
- 백엔드: GET /api/aerial/satellite/passes — 한국 주변 위성 패스 시뮬레이션
  UP42 API 연동 준비 (Workspace ID: b9bc92ae, TODO 주석)
  6개 위성 궤도 데이터 (KOMPSAT-3A, Pléiades Neo, Sentinel-1/2, WV-3, SkySat)
- 프론트 API: fetchSatellitePasses() + SatellitePass 인터페이스
- UP42 모달: MapLibre 지도에 위성 궤도 라인 실시간 표시
  한국 영역 AOI 점선 박스 + 궤도별 색상 구분
  위성 클릭 시 해당 궤도 하이라이트 (나머지 투명)
- 패스 타임라인: 통과 시각, 해상도, 앙각, 상승/하강 방향, 긴급도 표시
- 궤도 범례 오버레이 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:11:52 +09:00
Nan Kyung Lee
326237b91f style(weather): 섹션 내부 컨텐츠 값 사이즈 키움
- 바람현황 값: 13px, 컴파스 유지
- 파도 카드 값: 14px, 라벨: 10px
- 수온·공기 카드 값: 14px
- 시간별 예보 온도: 13px, 아이콘: lg
- 천문·조석 시각: 13px, 아이콘: base

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:46:14 +09:00
Nan Kyung Lee
bbdb654857 style(weather): 컨텐츠 글자 사이즈 추가 키움 (핵심 지표 제외)
- 라벨/본문: 10px→11px, 섹션제목/헤더: 12px→13px
- 핵심 지표(풍속/파고/수온) 숫자는 20px 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:44:51 +09:00
Nan Kyung Lee
f5bcbde40e style(weather): 기상정보 패널 글자 크기 전체 1단계 키움
- 라벨 7px→10px, 본문 9px→10px, 섹션제목 9px→10px
- 핵심 지표 숫자 18px→22px
- 헤더 11px→12px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:42:36 +09:00
Nan Kyung Lee
6b5d5f89dd feat(weather): 지역별 기상정보 패널 글자 사이즈 조정 + 시각화 개선
- 패널 폭 380px → 320px 축소
- 전체 폰트 사이즈 컴팩트화 (큰 숫자 4xl→18px, 본문 xs→9px)
- 핵심 지표 3칸 카드 (풍속/파고/수온) 상단 배치, 등급별 색상
- 풍향 컴파스 SVG (N/E/S/W + 화살표, 풍속 색상 연동)
- 풍속/파고 게이지 바 (진행률 + 등급 색상)
- 파도 4칸 그리드 (유의파고/최고파고/주기/파향)
- 수온·공기·염분 3칸 그리드
- 천문·조석 4칸 그리드 (일출/일몰/월출/월몰)
- 날씨 특보 배지 스타일 개선
- 전체 패딩 축소로 더 많은 정보 한눈에 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:40:57 +09:00
25개의 변경된 파일3116개의 추가작업 그리고 596개의 파일을 삭제

파일 보기

@ -435,6 +435,103 @@ router.post('/satellite/:sn/status', requireAuth, requirePermission('aerial', 'C
}
});
// ============================================================
// UP42 위성 패스 조회 (실시간 위성 목록 + 궤도)
// ============================================================
/** 한국 주변 위성 패스 시뮬레이션 데이터 (UP42 API 연동 시 교체) */
function generateKoreaSatellitePasses() {
const now = new Date();
const passes = [
{
id: 'pass-kmp3a-1', satellite: 'KOMPSAT-3A', provider: 'KARI', type: 'optical',
resolution: '0.5m', color: '#a855f7',
startTime: new Date(now.getTime() + 2 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 2 * 3600000 + 14 * 60000).toISOString(),
maxElevation: 72, direction: 'descending',
orbit: [
{ lat: 42.0, lon: 126.5 }, { lat: 40.5, lon: 127.0 }, { lat: 39.0, lon: 127.4 },
{ lat: 37.5, lon: 127.8 }, { lat: 36.0, lon: 128.1 }, { lat: 34.5, lon: 128.4 },
{ lat: 33.0, lon: 128.6 }, { lat: 31.5, lon: 128.8 },
],
},
{
id: 'pass-pneo-1', satellite: 'Pléiades Neo', provider: 'Airbus', type: 'optical',
resolution: '0.3m', color: '#06b6d4',
startTime: new Date(now.getTime() + 3.5 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 3.5 * 3600000 + 12 * 60000).toISOString(),
maxElevation: 65, direction: 'ascending',
orbit: [
{ lat: 30.0, lon: 130.0 }, { lat: 31.5, lon: 129.2 }, { lat: 33.0, lon: 128.5 },
{ lat: 34.5, lon: 127.8 }, { lat: 36.0, lon: 127.1 }, { lat: 37.5, lon: 126.4 },
{ lat: 39.0, lon: 125.8 }, { lat: 40.5, lon: 125.2 },
],
},
{
id: 'pass-s1-1', satellite: 'Sentinel-1 SAR', provider: 'ESA', type: 'sar',
resolution: '20m', color: '#f59e0b',
startTime: new Date(now.getTime() + 5 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 5 * 3600000 + 18 * 60000).toISOString(),
maxElevation: 58, direction: 'descending',
orbit: [
{ lat: 43.0, lon: 124.0 }, { lat: 41.0, lon: 125.0 }, { lat: 39.0, lon: 126.0 },
{ lat: 37.0, lon: 126.8 }, { lat: 35.0, lon: 127.5 }, { lat: 33.0, lon: 128.0 },
{ lat: 31.0, lon: 128.5 },
],
},
{
id: 'pass-wv3-1', satellite: 'Maxar WorldView-3', provider: 'Maxar', type: 'optical',
resolution: '0.31m', color: '#3b82f6',
startTime: new Date(now.getTime() + 8 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 8 * 3600000 + 10 * 60000).toISOString(),
maxElevation: 80, direction: 'descending',
orbit: [
{ lat: 41.0, lon: 129.5 }, { lat: 39.5, lon: 129.0 }, { lat: 38.0, lon: 128.5 },
{ lat: 36.5, lon: 128.0 }, { lat: 35.0, lon: 127.5 }, { lat: 33.5, lon: 127.0 },
{ lat: 32.0, lon: 126.5 },
],
},
{
id: 'pass-skysat-1', satellite: 'SkySat', provider: 'Planet', type: 'optical',
resolution: '0.5m', color: '#22c55e',
startTime: new Date(now.getTime() + 12 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 12 * 3600000 + 8 * 60000).toISOString(),
maxElevation: 55, direction: 'ascending',
orbit: [
{ lat: 31.0, lon: 127.0 }, { lat: 32.5, lon: 126.5 }, { lat: 34.0, lon: 126.0 },
{ lat: 35.5, lon: 125.5 }, { lat: 37.0, lon: 125.0 }, { lat: 38.5, lon: 124.5 },
{ lat: 40.0, lon: 124.0 },
],
},
{
id: 'pass-s2-1', satellite: 'Sentinel-2', provider: 'ESA', type: 'optical',
resolution: '10m', color: '#ec4899',
startTime: new Date(now.getTime() + 18 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 18 * 3600000 + 20 * 60000).toISOString(),
maxElevation: 62, direction: 'descending',
orbit: [
{ lat: 42.0, lon: 128.0 }, { lat: 40.0, lon: 128.0 }, { lat: 38.0, lon: 128.0 },
{ lat: 36.0, lon: 128.0 }, { lat: 34.0, lon: 128.0 }, { lat: 32.0, lon: 128.0 },
],
},
];
return passes;
}
// GET /api/aerial/satellite/passes — 한국 주변 실시간 위성 패스 목록 (UP42 API 연동 준비)
router.get('/satellite/passes', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => {
try {
// TODO: UP42 API 연동 시 아래 코드를 실제 API 호출로 교체
// const token = await getUp42Token()
// const passes = await fetchUp42Catalog(token, { bbox: [124, 33, 132, 39] })
const passes = generateKoreaSatellitePasses();
res.json({ passes, source: 'simulation', note: 'UP42 API 연동 시 실제 데이터로 교체 예정' });
} catch (err) {
console.error('[aerial] 위성 패스 조회 오류:', err);
res.status(500).json({ error: '위성 패스 조회 실패' });
}
});
// ============================================================
// OIL INFERENCE 라우트
// ============================================================

파일 보기

@ -1,15 +1,29 @@
import { Router } from 'express';
import { requireAuth } from '../auth/authMiddleware.js';
import { listJurisdictions, listZones, listSections, getSection } from './scatService.js';
import { listOffices, listJurisdictions, listZones, listSections, getSection } from './scatService.js';
const router = Router();
// ============================================================
// GET /api/scat/offices — 관할청 목록
// ============================================================
router.get('/offices', requireAuth, async (_req, res) => {
try {
const offices = await listOffices();
res.json(offices);
} catch (err) {
console.error('[scat] 관할청 목록 조회 오류:', err);
res.status(500).json({ error: '관할청 목록 조회 중 오류가 발생했습니다.' });
}
});
// ============================================================
// GET /api/scat/jurisdictions — 관할서 목록
// ============================================================
router.get('/jurisdictions', requireAuth, async (_req, res) => {
router.get('/jurisdictions', requireAuth, async (req, res) => {
try {
const jurisdictions = await listJurisdictions();
const { officeCd } = req.query as { officeCd?: string };
const jurisdictions = await listJurisdictions(officeCd);
res.json(jurisdictions);
} catch (err) {
console.error('[scat] 관할서 목록 조회 오류:', err);
@ -22,8 +36,8 @@ router.get('/jurisdictions', requireAuth, async (_req, res) => {
// ============================================================
router.get('/zones', requireAuth, async (req, res) => {
try {
const { jurisdiction } = req.query as { jurisdiction?: string };
const zones = await listZones({ jurisdiction });
const { jurisdiction, officeCd } = req.query as { jurisdiction?: string; officeCd?: string };
const zones = await listZones({ jurisdiction, officeCd });
res.json(zones);
} catch (err) {
console.error('[scat] 조사구역 목록 조회 오류:', err);
@ -36,14 +50,15 @@ router.get('/zones', requireAuth, async (req, res) => {
// ============================================================
router.get('/sections', requireAuth, async (req, res) => {
try {
const { zone, status, sensitivity, jurisdiction, search } = req.query as {
const { zone, status, sensitivity, jurisdiction, search, officeCd } = req.query as {
zone?: string;
status?: string;
sensitivity?: string;
jurisdiction?: string;
search?: string;
officeCd?: string;
};
const sections = await listSections({ zone, status, sensitivity, jurisdiction, search });
const sections = await listSections({ zone, status, sensitivity, jurisdiction, search, officeCd });
res.json(sections);
} catch (err) {
console.error('[scat] 해안구간 목록 조회 오류:', err);

파일 보기

@ -60,18 +60,40 @@ interface SectionDetail {
notes: string[];
}
// ============================================================
// 관할청 목록 조회
// ============================================================
export async function listOffices(): Promise<string[]> {
const sql = `
SELECT DISTINCT OFFICE_CD
FROM wing.CST_SRVY_ZONE
WHERE USE_YN = 'Y' AND OFFICE_CD IS NOT NULL
ORDER BY OFFICE_CD
`;
const { rows } = await wingPool.query(sql);
return rows.map((r: Record<string, unknown>) => r.office_cd as string);
}
// ============================================================
// 관할서 목록 조회
// ============================================================
export async function listJurisdictions(): Promise<string[]> {
export async function listJurisdictions(officeCd?: string): Promise<string[]> {
const conditions: string[] = ["USE_YN = 'Y'", 'JRSD_NM IS NOT NULL'];
const params: unknown[] = [];
let idx = 1;
if (officeCd) {
conditions.push(`OFFICE_CD = $${idx++}`);
params.push(officeCd);
}
const sql = `
SELECT DISTINCT JRSD_NM
FROM wing.CST_SRVY_ZONE
WHERE USE_YN = 'Y' AND JRSD_NM IS NOT NULL
WHERE ${conditions.join(' AND ')}
ORDER BY JRSD_NM
`;
const { rows } = await wingPool.query(sql);
const { rows } = await wingPool.query(sql, params);
return rows.map((r: Record<string, unknown>) => r.jrsd_nm as string);
}
@ -81,6 +103,7 @@ export async function listJurisdictions(): Promise<string[]> {
export async function listZones(filters?: {
jurisdiction?: string;
officeCd?: string;
}): Promise<ZoneItem[]> {
const conditions: string[] = ["USE_YN = 'Y'"];
const params: unknown[] = [];
@ -90,6 +113,10 @@ export async function listZones(filters?: {
conditions.push(`JRSD_NM ILIKE '%' || $${idx++} || '%'`);
params.push(filters.jurisdiction);
}
if (filters?.officeCd) {
conditions.push(`OFFICE_CD = $${idx++}`);
params.push(filters.officeCd);
}
const where = 'WHERE ' + conditions.join(' AND ');
@ -126,6 +153,7 @@ export async function listSections(filters: {
sensitivity?: string;
jurisdiction?: string;
search?: string;
officeCd?: string;
}): Promise<SectionListItem[]> {
const conditions: string[] = [];
const params: unknown[] = [];
@ -151,6 +179,10 @@ export async function listSections(filters: {
conditions.push(`s.SECT_NM ILIKE '%' || $${idx++} || '%'`);
params.push(filters.search);
}
if (filters.officeCd) {
conditions.push(`z.OFFICE_CD = $${idx++}`);
params.push(filters.officeCd);
}
conditions.push("s.USE_YN = 'Y'");
conditions.push("z.USE_YN = 'Y'");

파일 보기

@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS CST_SRVY_ZONE (
ZONE_CD VARCHAR(10) NOT NULL UNIQUE,
ZONE_NM VARCHAR(100) NOT NULL,
JRSD_NM VARCHAR(20),
OFFICE_CD VARCHAR(20),
SECT_CNT INTEGER DEFAULT 0,
LAT_CENTER NUMERIC(9,6),
LNG_CENTER NUMERIC(9,6),
@ -29,9 +30,9 @@ CREATE TABLE IF NOT EXISTS CST_SRVY_ZONE (
CREATE TABLE IF NOT EXISTS CST_SECT (
CST_SECT_SN SERIAL PRIMARY KEY,
CST_SRVY_ZONE_SN INTEGER REFERENCES CST_SRVY_ZONE(CST_SRVY_ZONE_SN),
SECT_CD VARCHAR(20) NOT NULL UNIQUE,
SECT_CD VARCHAR(30) NOT NULL UNIQUE,
SECT_NM VARCHAR(200),
CST_TP_CD VARCHAR(30),
CST_TP_CD VARCHAR(100),
ESI_CD VARCHAR(5),
ESI_NUM SMALLINT,
LEN_M NUMERIC(8,1),

파일 보기

@ -0,0 +1,19 @@
-- aerial:spectral (AI 탐지/분석) 서브탭 권한 추가
-- 기존 aerial 서브탭(satellite) 뒤, cctv 앞에 배치 (SORT_ORD = 6)
-- 기존 cctv, theory 순서 밀기
UPDATE AUTH_PERM_TREE SET SORT_ORD = 7 WHERE RSRC_CD = 'aerial:cctv';
UPDATE AUTH_PERM_TREE SET SORT_ORD = 8 WHERE RSRC_CD = 'aerial:theory';
-- spectral 리소스 추가
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD)
VALUES ('aerial:spectral', 'aerial', 'AI 탐지/분석', 1, 6)
ON CONFLICT (RSRC_CD) DO NOTHING;
-- 기존 역할에 spectral READ 권한 부여 (aerial READ 권한이 있는 역할)
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, USE_YN)
SELECT ap.ROLE_SN, 'aerial:spectral', ap.OPER_CD, ap.USE_YN
FROM AUTH_PERM ap
WHERE ap.RSRC_CD = 'aerial'
AND ap.USE_YN = 'Y'
ON CONFLICT (ROLE_SN, RSRC_CD, OPER_CD) DO NOTHING;

파일 보기

@ -32,6 +32,7 @@
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-window": "^2.2.7",
"socket.io-client": "^4.8.3",
"xlsx": "^0.18.5",
"zustand": "^5.0.11"
@ -41,6 +42,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.1",
@ -2499,6 +2501,16 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
@ -5439,6 +5451,16 @@
"node": ">=0.10.0"
}
},
"node_modules/react-window": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
"integrity": "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

파일 보기

@ -34,6 +34,7 @@
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-window": "^2.2.7",
"socket.io-client": "^4.8.3",
"xlsx": "^0.18.5",
"zustand": "^5.0.11"
@ -43,6 +44,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.1",

파일 보기

@ -1400,23 +1400,23 @@ function MapControls({ center, zoom }: { center: [number, number]; zoom: number
const { current: map } = useMap()
return (
<div className="absolute top-4 left-4 z-10">
<div className="flex flex-col gap-2">
<div className="absolute top-[80px] left-[10px] z-10">
<div className="flex flex-col gap-1">
<button
onClick={() => map?.zoomIn()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-xs"
>
+
</button>
<button
onClick={() => map?.zoomOut()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-xs"
>
</button>
<button
onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-[10px]"
>
&#x1F3AF;
</button>
@ -1435,7 +1435,7 @@ interface MapLegendProps {
}
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
const [minimized, setMinimized] = useState(false)
const [minimized, setMinimized] = useState(true)
if (dispersionResult && incidentCoord) {
return (

파일 보기

@ -40,9 +40,10 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
{ id: 'media', label: '영상사진관리', icon: '📷' },
{ id: 'analysis', label: '영상사진합성', icon: '🧩' },
{ id: 'realtime', label: '실시간드론', icon: '🛸' },
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
{ id: 'satellite', label: '위성요청', icon: '🛰' },
{ id: 'satellite', label: '위성영상', icon: '🛰' },
{ id: 'cctv', label: 'CCTV 조회', icon: '📹' },
{ id: 'spectral', label: 'AI 탐지/분석', icon: '🤖' },
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
{ id: 'theory', label: '항공탐색 이론', icon: '📐' }
],
assets: null,

파일 보기

@ -69,6 +69,76 @@
color: var(--t3);
}
/* Date/Time picker custom styling */
.prd-date-input::-webkit-calendar-picker-indicator,
.prd-time-input::-webkit-calendar-picker-indicator {
opacity: 0;
position: absolute;
right: 0;
width: 28px;
height: 100%;
cursor: pointer;
}
.prd-date-input,
.prd-time-input {
font-size: 10px;
color-scheme: dark;
}
.prd-date-input::-webkit-datetime-edit,
.prd-time-input::-webkit-datetime-edit {
color: var(--t2);
font-family: var(--fM);
font-size: 10px;
letter-spacing: 0.3px;
}
.prd-date-input::-webkit-datetime-edit-fields-wrapper,
.prd-time-input::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
.prd-date-input::-webkit-datetime-edit-year-field,
.prd-date-input::-webkit-datetime-edit-month-field,
.prd-date-input::-webkit-datetime-edit-day-field,
.prd-time-input::-webkit-datetime-edit-hour-field,
.prd-time-input::-webkit-datetime-edit-minute-field,
.prd-time-input::-webkit-datetime-edit-ampm-field {
color: var(--t2);
background: transparent;
padding: 1px 2px;
border-radius: 2px;
}
.prd-date-input::-webkit-datetime-edit-year-field:focus,
.prd-date-input::-webkit-datetime-edit-month-field:focus,
.prd-date-input::-webkit-datetime-edit-day-field:focus,
.prd-time-input::-webkit-datetime-edit-hour-field:focus,
.prd-time-input::-webkit-datetime-edit-minute-field:focus,
.prd-time-input::-webkit-datetime-edit-ampm-field:focus {
background: rgba(6, 182, 212, 0.12);
color: var(--cyan);
}
.prd-date-input::-webkit-datetime-edit-text,
.prd-time-input::-webkit-datetime-edit-text {
color: var(--t3);
padding: 0 1px;
}
/* Time hour/minute select (dark dropdown) */
select.prd-i.prd-time-select {
color-scheme: dark;
-webkit-appearance: menulist !important;
appearance: menulist !important;
background: var(--bg3) !important;
background-image: none !important;
padding-right: 4px;
color: var(--t1);
border-color: var(--bd);
}
/* Select Dropdown */
select.prd-i {
cursor: pointer;
@ -210,10 +280,11 @@
.prd-mc {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 13px;
justify-content: center;
gap: 4px;
padding: 5px 4px;
border-radius: 5px;
font-size: 12px;
font-size: 9px;
font-weight: 600;
font-family: 'Noto Sans KR', sans-serif;
cursor: pointer;
@ -294,18 +365,20 @@
.cod {
position: absolute;
bottom: 80px;
left: 16px;
background: rgba(18, 25, 41, 0.85);
backdrop-filter: blur(12px);
border: 1px solid var(--bd);
left: 50%;
transform: translateX(-50%);
background: rgba(18, 25, 41, 0.5);
backdrop-filter: blur(8px);
border: 1px solid rgba(30, 42, 66, 0.4);
border-radius: 6px;
padding: 8px 12px;
padding: 5px 14px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--t2);
font-size: 10px;
color: #1a1a2e;
font-weight: 600;
z-index: 20;
display: flex;
gap: 16px;
gap: 14px;
}
.cov {
@ -316,40 +389,41 @@
/* ═══ Weather Info Panel ═══ */
.wip {
position: absolute;
top: 16px;
left: 16px;
background: rgba(18, 25, 41, 0.9);
backdrop-filter: blur(12px);
border: 1px solid var(--bd);
border-radius: 8px;
padding: 12px 14px;
top: 10px;
left: 10px;
background: rgba(18, 25, 41, 0.65);
backdrop-filter: blur(10px);
border: 1px solid rgba(30, 42, 66, 0.5);
border-radius: 6px;
padding: 6px 10px;
z-index: 20;
display: flex;
gap: 20px;
gap: 12px;
}
.wii {
display: flex;
flex-direction: column;
gap: 2px;
gap: 1px;
align-items: center;
}
.wii-icon {
font-size: 18px;
opacity: 0.6;
font-size: 12px;
opacity: 0.5;
}
.wii-value {
font-size: 15px;
font-size: 11px;
font-weight: 700;
color: var(--t1);
font-family: 'JetBrains Mono', monospace;
}
.wii-label {
font-size: 9px;
color: var(--t3);
font-size: 7px;
color: #1a1a2e;
font-weight: 700;
font-family: 'Noto Sans KR', sans-serif;
}

파일 보기

@ -5,6 +5,7 @@ import { OilAreaAnalysis } from './OilAreaAnalysis'
import { RealtimeDrone } from './RealtimeDrone'
import { SensorAnalysis } from './SensorAnalysis'
import { SatelliteRequest } from './SatelliteRequest'
import { WingAI } from './WingAI'
import { CctvView } from './CctvView'
export function AerialView() {
@ -16,6 +17,8 @@ export function AerialView() {
return <AerialTheoryView />
case 'satellite':
return <SatelliteRequest />
case 'spectral':
return <WingAI />
case 'cctv':
return <CctvView />
case 'analysis':

파일 보기

@ -1,4 +1,12 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { Map, Source, Layer } from '@vis.gl/react-maplibre'
import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { Marker } from '@vis.gl/react-maplibre'
import { fetchSatellitePasses } from '../services/aerialApi'
const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || ''
import type { SatellitePass } from '../services/aerialApi'
interface SatRequest {
id: string
@ -9,20 +17,22 @@ interface SatRequest {
requestDate: string
expectedReceive: string
resolution: string
status: '촬영중' | '대기' | '완료'
status: '촬영중' | '대기' | '완료' | '취소'
provider?: string
purpose?: string
requester?: string
/** ISO 날짜 (필터용) */
dateKey?: string
}
const satRequests: SatRequest[] = [
{ id: 'SAT-004', zone: '제주 서귀포 해상 (유출 해역 중심)', zoneCoord: '33.24°N 126.50°E', zoneArea: '15km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 08:14', expectedReceive: '02-20 14:30', resolution: '0.5m', status: '촬영중', provider: 'KARI', purpose: '유출유 확산 모니터링', requester: '방제과 김해양' },
{ id: 'SAT-005', zone: '가파도 북쪽 해안선', zoneCoord: '33.17°N 126.27°E', zoneArea: '8km²', satellite: 'KOMPSAT-3', requestDate: '02-20 09:02', expectedReceive: '02-21 09:00', resolution: '1.0m', status: '대기', provider: 'KARI', purpose: '해안선 오염 확인', requester: '방제과 이민수' },
{ id: 'SAT-006', zone: '마라도 주변 해역', zoneCoord: '33.11°N 126.27°E', zoneArea: '12km²', satellite: 'Sentinel-2', requestDate: '02-20 09:30', expectedReceive: '02-21 11:00', resolution: '10m', status: '대기', provider: 'ESA Copernicus', purpose: '수질 분석용 다분광 촬영', requester: '환경분석팀 박수진' },
{ id: 'SAT-007', zone: '대정읍 해안 오염 확산 구역', zoneCoord: '33.21°N 126.10°E', zoneArea: '20km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 10:05', expectedReceive: '02-22 08:00', resolution: '0.5m', status: '대기', provider: 'KARI', purpose: '확산 예측 모델 검증', requester: '방제과 김해양' },
{ id: 'SAT-003', zone: '제주 남방 100해리 해상', zoneCoord: '33.00°N 126.50°E', zoneArea: '25km²', satellite: 'Sentinel-1', requestDate: '02-19 14:00', expectedReceive: '02-19 23:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: 'SAR 유막 탐지', requester: '환경분석팀 박수진' },
{ id: 'SAT-002', zone: '여수 오동도 인근 해역', zoneCoord: '34.73°N 127.68°E', zoneArea: '18km²', satellite: 'KOMPSAT-3A', requestDate: '02-18 11:30', expectedReceive: '02-18 17:45', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '유출 초기 범위 확인', requester: '방제과 김해양' },
{ id: 'SAT-001', zone: '통영 해역 남측', zoneCoord: '34.85°N 128.43°E', zoneArea: '30km²', satellite: 'Sentinel-1', requestDate: '02-17 09:00', expectedReceive: '02-17 21:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: '야간 SAR 유막 모니터링', requester: '환경분석팀 박수진' },
{ id: 'SAT-004', zone: '제주 서귀포 해상 (유출 해역 중심)', zoneCoord: '33.24°N 126.50°E', zoneArea: '15km²', satellite: 'KOMPSAT-3A', requestDate: '03-17 08:14', expectedReceive: '03-17 14:30', resolution: '0.5m', status: '촬영중', provider: 'KARI', purpose: '유출유 확산 모니터링', requester: '방제과 김해양', dateKey: '2026-03-17' },
{ id: 'SAT-005', zone: '가파도 북쪽 해안선', zoneCoord: '33.17°N 126.27°E', zoneArea: '8km²', satellite: 'KOMPSAT-3', requestDate: '03-17 09:02', expectedReceive: '03-18 09:00', resolution: '1.0m', status: '대기', provider: 'KARI', purpose: '해안선 오염 확인', requester: '방제과 이민수', dateKey: '2026-03-17' },
{ id: 'SAT-006', zone: '마라도 주변 해역', zoneCoord: '33.11°N 126.27°E', zoneArea: '12km²', satellite: 'Sentinel-2', requestDate: '03-16 09:30', expectedReceive: '03-16 23:00', resolution: '10m', status: '완료', provider: 'ESA Copernicus', purpose: '수질 분석용 다분광 촬영', requester: '환경분석팀 박수진', dateKey: '2026-03-16' },
{ id: 'SAT-007', zone: '대정읍 해안 오염 확산 구역', zoneCoord: '33.21°N 126.10°E', zoneArea: '20km²', satellite: 'KOMPSAT-3A', requestDate: '03-16 10:05', expectedReceive: '03-17 08:00', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '확산 예측 모델 검증', requester: '방제과 김해양', dateKey: '2026-03-16' },
{ id: 'SAT-003', zone: '제주 남방 100해리 해상', zoneCoord: '33.00°N 126.50°E', zoneArea: '25km²', satellite: 'Sentinel-1', requestDate: '03-15 14:00', expectedReceive: '03-15 23:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: 'SAR 유막 탐지', requester: '환경분석팀 박수진', dateKey: '2026-03-15' },
{ id: 'SAT-002', zone: '여수 오동도 인근 해역', zoneCoord: '34.73°N 127.68°E', zoneArea: '18km²', satellite: 'KOMPSAT-3A', requestDate: '03-14 11:30', expectedReceive: '03-14 17:45', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '유출 초기 범위 확인', requester: '방제과 김해양', dateKey: '2026-03-14' },
{ id: 'SAT-001', zone: '통영 해역 남측', zoneCoord: '34.85°N 128.43°E', zoneArea: '30km²', satellite: 'Sentinel-1', requestDate: '03-13 09:00', expectedReceive: '03-13 21:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: '야간 SAR 유막 모니터링', requester: '환경분석팀 박수진', dateKey: '2026-03-13' },
]
const satellites = [
@ -59,28 +69,70 @@ const up42Satellites = [
{ id: 'cop-dem', name: 'Copernicus DEM', res: '10m', type: 'elevation' as const, color: '#64748b', cloud: 0 },
]
const up42Passes = [
{ sat: 'KOMPSAT-3A', time: '오늘 14:1014:24', res: '0.5m', cloud: '≤10%', note: '최우선 추천', color: '#a855f7' },
{ sat: 'Pléiades Neo', time: '오늘 14:3814:52', res: '0.3m', cloud: '≤15%', note: '초고해상도', color: '#06b6d4' },
{ sat: 'Sentinel-1 SAR', time: '오늘 16:5517:08', res: '20m', cloud: '야간/우천 가능', note: 'SAR', color: '#f59e0b' },
{ sat: 'KOMPSAT-3', time: '내일 09:12', res: '1.0m', cloud: '≤15%', note: '', color: '#a855f7' },
{ sat: 'Maxar WV-3', time: '내일 13:20', res: '0.5m', cloud: '≤20%', note: '', color: '#3b82f6' },
]
// up42Passes — 실시간 패스로 대체됨 (satPasses from API)
const SAT_MAP_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }],
}
/** 좌표 문자열 파싱 ("33.24°N 126.50°E" → {lat, lon}) */
function parseCoord(coordStr: string): { lat: number; lon: number } | null {
const m = coordStr.match(/([\d.]+)°N\s+([\d.]+)°E/)
if (!m) return null
return { lat: parseFloat(m[1]), lon: parseFloat(m[2]) }
}
type SatModalPhase = 'none' | 'provider' | 'blacksky' | 'up42'
export function SatelliteRequest() {
const [requests, setRequests] = useState(satRequests)
const [mainTab, setMainTab] = useState<'list' | 'map'>('list')
const [statusFilter, setStatusFilter] = useState('전체')
const [modalPhase, setModalPhase] = useState<SatModalPhase>('none')
const [selectedRequest, setSelectedRequest] = useState<SatRequest | null>(null)
const [showMoreCompleted, setShowMoreCompleted] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const PAGE_SIZE = 5
// UP42 sub-tab
const [up42SubTab, setUp42SubTab] = useState<'optical' | 'sar' | 'elevation'>('optical')
const [up42SelSat, setUp42SelSat] = useState<string | null>(null)
const [up42SelPass, setUp42SelPass] = useState<number | null>(null)
const [up42SelPass, setUp42SelPass] = useState<string | null>(null)
const [satPasses, setSatPasses] = useState<SatellitePass[]>([])
const [satPassesLoading, setSatPassesLoading] = useState(false)
// 히스토리 지도 — 캘린더 + 선택 항목
const [mapSelectedDate, setMapSelectedDate] = useState(() => {
const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
})
const [mapSelectedItem, setMapSelectedItem] = useState<SatRequest | null>(null)
const satImgOpacity = 90
const satImgBrightness = 100
const satShowOverlay = true
const modalRef = useRef<HTMLDivElement>(null)
const loadSatPasses = useCallback(async () => {
setSatPassesLoading(true)
try {
const passes = await fetchSatellitePasses()
setSatPasses(passes)
} catch {
setSatPasses([])
} finally {
setSatPassesLoading(false)
}
}, [])
useEffect(() => {
const handler = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
@ -91,15 +143,21 @@ export function SatelliteRequest() {
return () => document.removeEventListener('mousedown', handler)
}, [modalPhase])
const allRequests = showMoreCompleted ? satRequests : satRequests.filter(r => r.status !== '완료' || r.id === 'SAT-003')
// UP42 모달 열릴 때 위성 패스 로드
useEffect(() => {
if (modalPhase === 'up42') loadSatPasses()
}, [modalPhase, loadSatPasses])
const filtered = allRequests.filter(r => {
const filtered = requests.filter(r => {
if (statusFilter === '전체') return true
if (statusFilter === '대기') return r.status === '대기'
if (statusFilter === '진행') return r.status === '촬영중'
if (statusFilter === '완료') return r.status === '완료'
if (statusFilter === '취소') return r.status === '취소'
return true
})
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const pagedItems = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
const statusBadge = (s: SatRequest['status']) => {
if (s === '촬영중') return (
@ -110,6 +168,9 @@ export function SatelliteRequest() {
if (s === '대기') return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(59,130,246,.15)', border: '1px solid rgba(59,130,246,.3)', color: 'var(--blue)' }}> </span>
)
if (s === '취소') return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(239,68,68,.1)', border: '1px solid rgba(239,68,68,.2)', color: 'var(--red)' }}> </span>
)
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: 'var(--green)' }}> </span>
)
@ -122,7 +183,7 @@ export function SatelliteRequest() {
{ value: '0.5m', label: '최고 해상도', color: 'var(--cyan)' },
]
const filters = ['전체', '대기', '진행', '완료']
const filters = ['전체', '대기', '진행', '완료', '취소']
const up42Filtered = up42Satellites.filter(s => s.type === up42SubTab)
@ -138,19 +199,35 @@ export function SatelliteRequest() {
const bsInputStyle = { border: '1px solid #21262d', background: '#161b22', color: '#e2e8f0' }
return (
<div className="overflow-y-auto py-5 px-6">
{/* 헤더 */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[10px] flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg,rgba(59,130,246,.2),rgba(168,85,247,.2))', border: '1px solid rgba(59,130,246,.3)' }}>🛰</div>
<div>
<div className="text-base font-bold font-korean text-text-1"> </div>
<div className="text-[11px] text-text-3 font-korean mt-0.5"> </div>
</div>
<div className="overflow-y-auto px-6 pt-1 pb-2" style={{ height: mainTab === 'map' ? '100%' : undefined }}>
{/* 헤더 + 탭 + 새요청 한 줄 (높이 통일) */}
<div className="flex items-center gap-3 mb-2 h-9">
<div className="flex items-center gap-2 shrink-0">
<div className="w-7 h-7 rounded-md flex items-center justify-center text-sm" style={{ background: 'linear-gradient(135deg,rgba(59,130,246,.2),rgba(168,85,247,.2))', border: '1px solid rgba(59,130,246,.3)' }}>🛰</div>
<div className="text-[12px] font-bold font-korean text-text-1"> </div>
</div>
<button onClick={() => setModalPhase('provider')} className="px-4 py-2.5 text-white border-none rounded-sm text-[13px] font-semibold cursor-pointer font-korean flex items-center gap-1.5" style={{ background: 'linear-gradient(135deg,var(--blue),var(--purple))' }}>🛰 </button>
<div className="flex gap-1 h-7">
<button
onClick={() => setMainTab('list')}
className="px-2.5 h-full rounded text-[10px] font-bold font-korean cursor-pointer border transition-colors"
style={mainTab === 'list'
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
}
>📋 </button>
<button
onClick={() => setMainTab('map')}
className="px-2.5 h-full rounded text-[10px] font-bold font-korean cursor-pointer border transition-colors"
style={mainTab === 'map'
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
}
>🗺 </button>
</div>
<button onClick={() => setModalPhase('provider')} className="ml-auto px-3 h-7 text-white border-none rounded-sm text-[10px] font-semibold cursor-pointer font-korean flex items-center gap-1 shrink-0" style={{ background: 'linear-gradient(135deg,var(--blue),var(--purple))' }}>🛰 </button>
</div>
{mainTab === 'list' && (<>
{/* 요약 통계 */}
<div className="grid grid-cols-4 gap-3 mb-5">
{stats.map((s, i) => (
@ -187,8 +264,8 @@ export function SatelliteRequest() {
))}
</div>
{/* 데이터 행 */}
{filtered.map(r => (
{/* 데이터 행 (페이징) */}
{pagedItems.map(r => (
<div key={r.id}>
<div
onClick={() => setSelectedRequest(selectedRequest?.id === r.id ? null : r)}
@ -197,7 +274,7 @@ export function SatelliteRequest() {
gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px',
borderColor: 'rgba(255,255,255,.04)',
background: selectedRequest?.id === r.id ? 'rgba(99,102,241,.06)' : r.status === '촬영중' ? 'rgba(234,179,8,.03)' : 'transparent',
opacity: r.status === '완료' ? 0.7 : 1,
opacity: (r.status === '완료' || r.status === '취소') ? 0.6 : 1,
}}
>
<div className="text-[11px] font-mono text-text-2">{r.id}</div>
@ -232,8 +309,18 @@ export function SatelliteRequest() {
{r.status === '완료' && (
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(34,197,94,.08)', borderColor: 'rgba(34,197,94,.2)', color: 'var(--green)' }}>📥 </button>
)}
{r.status === '대기' && (
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(239,68,68,.08)', borderColor: 'rgba(239,68,68,.2)', color: 'var(--red)' }}> </button>
{(r.status === '대기' || r.status === '촬영중') && (
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`${r.id} (${r.zone}) 위성 촬영 요청을 취소하시겠습니까?`)) {
setRequests(prev => prev.map(req => req.id === r.id ? { ...req, status: '취소' as const } : req))
setSelectedRequest(null)
}
}}
className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors"
style={{ background: 'rgba(239,68,68,.08)', borderColor: 'rgba(239,68,68,.2)', color: 'var(--red)' }}
> </button>
)}
</div>
</div>
@ -241,11 +328,36 @@ export function SatelliteRequest() {
</div>
))}
<div
onClick={() => setShowMoreCompleted(!showMoreCompleted)}
className="text-center py-2.5 text-[10px] text-text-3 font-korean cursor-pointer hover:text-text-2 transition-colors"
>
{showMoreCompleted ? '▲ 완료 목록 접기' : '▼ 이전 완료 목록 더보기 (6건)'}
{/* 페이징 */}
<div className="flex items-center justify-between px-4 py-2">
<div className="text-[9px] text-text-3 font-korean">
{filtered.length} {(currentPage - 1) * PAGE_SIZE + 1}{Math.min(currentPage * PAGE_SIZE, filtered.length)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage <= 1}
className="px-2 py-1 rounded text-[10px] font-mono cursor-pointer border transition-colors"
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: currentPage <= 1 ? 'var(--t3)' : 'var(--t1)', opacity: currentPage <= 1 ? 0.5 : 1 }}
></button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
<button
key={p}
onClick={() => setCurrentPage(p)}
className="w-7 h-7 rounded text-[10px] font-bold font-mono cursor-pointer border transition-colors"
style={currentPage === p
? { background: 'rgba(59,130,246,.15)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
}
>{p}</button>
))}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
className="px-2 py-1 rounded text-[10px] font-mono cursor-pointer border transition-colors"
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: currentPage >= totalPages ? 'var(--t3)' : 'var(--t1)', opacity: currentPage >= totalPages ? 0.5 : 1 }}
></button>
</div>
</div>
</div>
@ -288,6 +400,179 @@ export function SatelliteRequest() {
</div>
</div>
</div>
</>)}
{/* ═══ 촬영 히스토리 지도 뷰 ═══ */}
{mainTab === 'map' && (() => {
const dateFiltered = requests.filter(r => r.dateKey === mapSelectedDate)
const dateHasDots = [...new Set(requests.map(r => r.dateKey).filter(Boolean))]
return (
<div className="bg-bg-2 border border-border rounded-md overflow-hidden relative" style={{ height: 'calc(100vh - 160px)' }}>
<Map
initialViewState={{ longitude: 127.5, latitude: 34.5, zoom: 7 }}
mapStyle={SAT_MAP_STYLE}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
{/* 선택된 날짜의 촬영 구역 폴리곤 */}
{dateFiltered.map(r => {
const coord = parseCoord(r.zoneCoord)
if (!coord) return null
const areaKm = parseFloat(r.zoneArea) || 10
const delta = Math.sqrt(areaKm) * 0.005
const isSelected = mapSelectedItem?.id === r.id
const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6'
return (
<Source key={r.id} id={`zone-${r.id}`} type="geojson" data={{
type: 'Feature', properties: {},
geometry: { type: 'Polygon', coordinates: [[
[coord.lon - delta, coord.lat - delta],
[coord.lon + delta, coord.lat - delta],
[coord.lon + delta, coord.lat + delta],
[coord.lon - delta, coord.lat + delta],
[coord.lon - delta, coord.lat - delta],
]] },
}}>
<Layer id={`zone-fill-${r.id}`} type="fill" paint={{ 'fill-color': statusColor, 'fill-opacity': isSelected ? 0.35 : 0.12 }} />
<Layer id={`zone-line-${r.id}`} type="line" paint={{ 'line-color': statusColor, 'line-width': isSelected ? 3 : 1.5 }} />
</Source>
)
})}
{/* 선택된 완료 항목: VWorld 위성 영상 오버레이 (BlackSky 스타일) */}
{mapSelectedItem && mapSelectedItem.status === '완료' && satShowOverlay && VWORLD_API_KEY && (
<Source
id="sat-vworld-overlay"
type="raster"
tiles={[`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg`]}
tileSize={256}
>
<Layer id="sat-vworld-layer" type="raster" paint={{
'raster-opacity': satImgOpacity / 100,
'raster-brightness-max': Math.min(satImgBrightness / 100 * 1.2, 1),
'raster-brightness-min': Math.max((satImgBrightness - 100) / 200, 0),
}} />
</Source>
)}
{/* 선택된 항목 마커 */}
{mapSelectedItem && (() => {
const coord = parseCoord(mapSelectedItem.zoneCoord)
if (!coord) return null
return (
<Marker longitude={coord.lon} latitude={coord.lat} anchor="center">
<div className="relative">
<div className="w-4 h-4 rounded-full" style={{ background: 'rgba(6,182,212,.6)', border: '2px solid #fff', boxShadow: '0 0 12px rgba(6,182,212,.5)' }} />
<div className="absolute inset-0 w-4 h-4 rounded-full animate-ping" style={{ background: 'rgba(6,182,212,.3)' }} />
</div>
</Marker>
)
})()}
</Map>
{/* 좌상단: 캘린더 + 날짜별 리스트 */}
<div className="absolute top-3 left-3 w-[260px] rounded-lg border border-border z-10 overflow-hidden" style={{ background: 'rgba(18,25,41,.92)', backdropFilter: 'blur(8px)' }}>
{/* 캘린더 헤더 */}
<div className="px-3 py-2 border-b border-border">
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">📅 </div>
<input
type="date"
value={mapSelectedDate}
onChange={e => { setMapSelectedDate(e.target.value); setMapSelectedItem(null) }}
className="w-full px-2.5 py-1.5 bg-bg-3 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-[var(--cyan)] transition-colors"
/>
{/* 촬영 이력 있는 날짜 점 표시 */}
<div className="flex flex-wrap gap-1 mt-2">
{dateHasDots.map(d => (
<button
key={d}
onClick={() => { setMapSelectedDate(d!); setMapSelectedItem(null) }}
className="px-1.5 py-0.5 rounded text-[8px] font-mono cursor-pointer border transition-colors"
style={mapSelectedDate === d
? { background: 'rgba(6,182,212,.2)', borderColor: 'var(--cyan)', color: 'var(--cyan)' }
: { background: 'var(--bg0)', borderColor: 'var(--bd)', color: 'var(--t3)' }
}
>{d?.slice(5)}</button>
))}
</div>
</div>
{/* 날짜별 촬영 리스트 */}
<div className="max-h-[35vh] overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
<div className="px-3 py-1.5 border-b border-border text-[9px] text-text-3 font-korean sticky top-0" style={{ background: 'rgba(18,25,41,.95)' }}>
{mapSelectedDate} · {dateFiltered.length}
</div>
{dateFiltered.length === 0 ? (
<div className="px-3 py-4 text-[10px] text-text-3 font-korean text-center"> </div>
) : dateFiltered.map(r => {
const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6'
const isSelected = mapSelectedItem?.id === r.id
return (
<div
key={r.id}
onClick={() => setMapSelectedItem(isSelected ? null : r)}
className="px-3 py-2 border-b cursor-pointer transition-colors"
style={{
borderColor: 'rgba(255,255,255,.04)',
background: isSelected ? 'rgba(6,182,212,.1)' : 'transparent',
}}
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-[10px] font-mono text-text-2">{r.id}</span>
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: `${statusColor}20`, color: statusColor }}>{r.status}</span>
</div>
<div className="text-[9px] text-text-1 font-korean truncate">{r.zone}</div>
<div className="text-[8px] text-text-3 font-mono mt-0.5">{r.satellite} · {r.resolution}</div>
{r.status === '완료' && (
<div className="mt-1 text-[8px] font-korean" style={{ color: 'var(--cyan)' }}>📷 </div>
)}
</div>
)
})}
</div>
</div>
{/* 우상단: 범례 */}
<div className="absolute top-3 right-3 px-3 py-2.5 rounded-lg border border-border z-10" style={{ background: 'rgba(18,25,41,.9)', backdropFilter: 'blur(8px)' }}>
<div className="text-[10px] font-bold text-text-2 font-korean mb-2"> </div>
{[
{ label: '촬영중', color: '#eab308' },
{ label: '대기', color: '#3b82f6' },
{ label: '완료', color: '#22c55e' },
{ label: '취소', color: '#ef4444' },
].map(item => (
<div key={item.label} className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 rounded-sm" style={{ background: item.color, opacity: 0.4, border: `1px solid ${item.color}` }} />
<span className="text-[9px] text-text-3 font-korean">{item.label}</span>
</div>
))}
<div className="text-[8px] text-text-3 font-korean mt-1.5 pt-1.5 border-t border-border"> {requests.length}</div>
</div>
{/* 선택된 항목 상세 (하단) */}
{mapSelectedItem && (
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 px-4 py-3 rounded-lg border border-border z-10 max-w-[500px]" style={{ background: 'rgba(18,25,41,.95)', backdropFilter: 'blur(8px)' }}>
<div className="flex items-center gap-3">
<div className="flex-1">
<div className="text-[11px] font-bold text-text-1 font-korean mb-0.5">{mapSelectedItem.zone}</div>
<div className="text-[9px] text-text-3 font-mono">{mapSelectedItem.satellite} · {mapSelectedItem.resolution} · {mapSelectedItem.zoneCoord}</div>
</div>
<div className="text-center shrink-0">
<div className="text-[8px] text-text-3 font-korean"></div>
<div className="text-[10px] font-mono text-text-1">{mapSelectedItem.requestDate}</div>
</div>
{mapSelectedItem.status === '완료' && (
<div className="px-2 py-1 rounded text-[9px] font-bold font-korean shrink-0" style={{ background: 'rgba(34,197,94,.15)', color: '#22c55e' }}>
📷
</div>
)}
<button onClick={() => setMapSelectedItem(null)} className="text-text-3 bg-transparent border-none cursor-pointer text-sm"></button>
</div>
</div>
)}
</div>
)
})()}
{/* ═══ 모달: 제공자 선택 ═══ */}
{modalPhase !== 'none' && (
@ -596,7 +881,7 @@ export function SatelliteRequest() {
{/* ── UP42 카탈로그 주문 ── */}
{modalPhase === 'up42' && (
<div className="border rounded-[14px] w-[920px] max-h-[90vh] flex flex-col overflow-hidden border-[rgba(59,130,246,.3)]" style={{ background: '#0d1117', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }}>
<div className="border rounded-[14px] w-[920px] flex flex-col overflow-hidden border-[rgba(59,130,246,.3)]" style={{ background: '#0d1117', boxShadow: '0 24px 80px rgba(0,0,0,.7)', height: '85vh' }}>
{/* 헤더 */}
<div className="px-6 py-4 border-b border-[#21262d] flex items-center justify-between shrink-0 relative">
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#3b82f6,#06b6d4,#22c55e)' }} />
@ -672,83 +957,121 @@ export function SatelliteRequest() {
</div>
</div>
{/* 오른쪽: 지도 + AOI + 패스 */}
{/* 오른쪽: 궤도 지도 + 패스 목록 */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* 지도 영역 (placeholder) */}
<div className="flex-1 relative" style={{ background: '#0a0e18' }}>
{/* 검색바 */}
<div className="absolute top-3 left-3 right-3 flex items-center gap-2 px-3 py-2 rounded-lg z-10 border border-[#21262d]" style={{ background: 'rgba(13,17,23,.9)', backdropFilter: 'blur(8px)' }}>
<span className="text-[#8690a6] text-[13px]">🔍</span>
<input type="text" placeholder="위치 또는 좌표 입력..." className="flex-1 bg-transparent border-none outline-none text-[11px] font-korean text-[#e2e8f0]" />
</div>
{/* 지도 영역 — 위성 궤도 표시 (최소 높이 보장) */}
<div className="flex-1 relative" style={{ minHeight: 350 }}>
<Map
initialViewState={{ longitude: 128, latitude: 36, zoom: 5.5 }}
mapStyle={SAT_MAP_STYLE}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
{/* 한국 영역 AOI 박스 */}
<Source id="korea-aoi" type="geojson" data={{
type: 'Feature',
properties: {},
geometry: { type: 'Polygon', coordinates: [[[124, 33], [132, 33], [132, 39], [124, 39], [124, 33]]] },
}}>
<Layer id="korea-aoi-fill" type="fill" paint={{ 'fill-color': '#3b82f6', 'fill-opacity': 0.05 }} />
<Layer id="korea-aoi-line" type="line" paint={{ 'line-color': '#3b82f6', 'line-width': 1.5, 'line-dasharray': [4, 3] }} />
</Source>
{/* 지도 placeholder */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-3xl mb-2 opacity-20">🗺</div>
<div className="text-[11px] font-korean opacity-40 text-[#64748b]"> AOI를 </div>
{/* 위성 궤도 라인 */}
{satPasses.map(pass => (
<Source key={pass.id} id={`orbit-${pass.id}`} type="geojson" data={{
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: pass.orbit.map(p => [p.lon, p.lat]) },
}}>
<Layer
id={`orbit-line-${pass.id}`}
type="line"
paint={{
'line-color': pass.color,
'line-width': up42SelPass === pass.id ? 3 : 1.5,
'line-opacity': up42SelPass === pass.id ? 1 : up42SelPass ? 0.2 : 0.6,
'line-dasharray': pass.type === 'sar' ? [6, 4] : [1],
}}
/>
{/* 궤도 위 위성 위치 (중간점) */}
{(up42SelPass === pass.id || !up42SelPass) && (
<Layer
id={`orbit-point-${pass.id}`}
type="circle"
filter={['==', '$type', 'LineString']}
paint={{}}
/>
)}
</Source>
))}
</Map>
{/* 범례 오버레이 */}
<div className="absolute top-3 left-3 px-3 py-2 rounded-lg z-10 border border-[#21262d]" style={{ background: 'rgba(13,17,23,.9)', backdropFilter: 'blur(8px)' }}>
<div className="text-[9px] font-bold text-[#64748b] mb-1.5">🛰 </div>
{satPasses.slice(0, 4).map(p => (
<div key={p.id} className="flex items-center gap-1.5 mb-1">
<div className="w-3 h-[2px] rounded-sm" style={{ background: p.color }} />
<span className="text-[8px] text-[#94a3b8]">{p.satellite}</span>
</div>
))}
<div className="flex items-center gap-1.5 mt-1.5 pt-1.5 border-t border-[#21262d]">
<div className="w-3 h-3 rounded border border-[#3b82f6]" style={{ background: 'rgba(59,130,246,.1)' }} />
<span className="text-[8px] text-[#64748b]"> AOI</span>
</div>
</div>
{/* AOI 도구 버튼 (오른쪽 사이드) */}
<div className="absolute top-14 right-3 flex flex-col gap-1 p-1.5 rounded-lg z-10 border border-[#21262d]" style={{ background: 'rgba(13,17,23,.9)' }}>
<div className="text-[7px] font-bold text-center mb-0.5 text-[#64748b]">ADD</div>
{[
{ icon: '⬜', title: '사각형 AOI' },
{ icon: '🔷', title: '다각형 AOI' },
{ icon: '⭕', title: '원형 AOI' },
{ icon: '📁', title: '파일 업로드' },
].map((t, i) => (
<button key={i} className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none bg-[#161b22] text-[#8690a6]" title={t.title}>{t.icon}</button>
))}
<div className="h-px my-0.5 bg-[#21262d]" />
<div className="text-[7px] font-bold text-center mb-0.5 text-[#64748b]">AOI</div>
<button className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none bg-[#161b22] text-[#8690a6]" title="저장된 AOI">💾</button>
<button className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none bg-[#161b22] text-[#ef4444]" title="AOI 삭제">🗑</button>
</div>
{/* 줌 컨트롤 */}
<div className="absolute bottom-3 right-3 flex flex-col rounded-md overflow-hidden z-10 border border-[#21262d]">
<button className="w-7 h-7 flex items-center justify-center text-sm cursor-pointer border-none bg-[#161b22] text-[#8690a6]">+</button>
<button className="w-7 h-7 flex items-center justify-center text-sm cursor-pointer border-none border-t border-t-[#21262d] bg-[#161b22] text-[#8690a6]"></button>
</div>
{/* 이 지역 검색 버튼 */}
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10">
<button className="px-4 py-2 rounded-full text-[10px] font-semibold cursor-pointer font-korean text-white border-none" style={{ background: 'rgba(59,130,246,.9)', boxShadow: '0 2px 12px rgba(59,130,246,.3)' }}>🔍 </button>
</div>
{/* 로딩 */}
{satPassesLoading && (
<div className="absolute inset-0 flex items-center justify-center z-10" style={{ background: 'rgba(0,0,0,.5)' }}>
<div className="text-[11px] text-[#60a5fa] font-korean animate-pulse">🛰 ...</div>
</div>
)}
</div>
{/* 위성 패스 타임라인 */}
<div className="border-t border-[#21262d] px-4 py-3 shrink-0" style={{ background: 'rgba(13,17,23,.95)' }}>
<div className="text-[10px] font-bold font-korean mb-2 text-[#e2e8f0]">🛰 AOI </div>
<div className="border-t border-[#21262d] px-4 py-3 shrink-0 max-h-[200px] overflow-y-auto" style={{ background: 'rgba(13,17,23,.95)', scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
<div className="text-[10px] font-bold font-korean mb-2 text-[#e2e8f0]">
🛰 ({satPasses.length})
<span className="text-[8px] text-[#64748b] font-normal ml-2"> </span>
</div>
<div className="flex flex-col gap-1.5">
{up42Passes.map((p, i) => (
<div
key={i}
onClick={() => setUp42SelPass(up42SelPass === i ? null : i)}
className="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors"
style={{
background: up42SelPass === i ? 'rgba(59,130,246,.1)' : '#161b22',
border: up42SelPass === i ? '1px solid rgba(59,130,246,.3)' : '1px solid #21262d',
}}
>
<div className="w-1.5 h-5 rounded-full shrink-0" style={{ background: p.color }} />
<div className="flex-1 flex items-center gap-3 min-w-0">
<span className="text-[10px] font-bold font-korean min-w-[100px] text-[#e2e8f0]">{p.sat}</span>
<span className="text-[9px] font-bold font-mono min-w-[110px] text-[#60a5fa]">{p.time}</span>
<span className="text-[9px] font-mono text-cyan-500">{p.res}</span>
<span className="text-[8px] font-mono text-[#64748b]">{p.cloud}</span>
</div>
{p.note && (
{satPasses.map(pass => {
const start = new Date(pass.startTime)
const timeStr = `${start.getHours().toString().padStart(2, '0')}:${start.getMinutes().toString().padStart(2, '0')}`
const diffH = Math.max(0, (start.getTime() - Date.now()) / 3600000)
const urgency = diffH < 3 ? '긴급' : diffH < 8 ? '예정' : '내일'
return (
<div
key={pass.id}
onClick={() => setUp42SelPass(up42SelPass === pass.id ? null : pass.id)}
className="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors"
style={{
background: up42SelPass === pass.id ? 'rgba(59,130,246,.1)' : '#161b22',
border: up42SelPass === pass.id ? `1px solid ${pass.color}40` : '1px solid #21262d',
}}
>
<div className="w-1.5 h-6 rounded-full shrink-0" style={{ background: pass.color }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold font-korean text-[#e2e8f0]">{pass.satellite}</span>
<span className="text-[8px] text-[#64748b]">{pass.provider}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[9px] font-bold font-mono text-[#60a5fa]">{timeStr}</span>
<span className="text-[9px] font-mono" style={{ color: pass.color }}>{pass.resolution}</span>
<span className="text-[8px] font-mono text-[#64748b]">EL {pass.maxElevation}° · {pass.direction === 'ascending' ? '↗ 상승' : '↘ 하강'}</span>
</div>
</div>
<span className="px-1.5 py-px rounded text-[8px] font-bold shrink-0" style={{
background: p.note === '최우선 추천' ? 'rgba(34,197,94,.1)' : p.note === '초고해상도' ? 'rgba(6,182,212,.1)' : p.note === 'SAR' ? 'rgba(245,158,11,.1)' : 'rgba(99,102,241,.1)',
color: p.note === '최우선 추천' ? '#22c55e' : p.note === '초고해상도' ? '#06b6d4' : p.note === 'SAR' ? '#f59e0b' : '#818cf8',
}}>{p.note}</span>
)}
{up42SelPass === i && <span className="text-xs text-blue-500"></span>}
</div>
))}
background: urgency === '긴급' ? 'rgba(239,68,68,.1)' : urgency === '예정' ? 'rgba(6,182,212,.1)' : 'rgba(100,116,139,.1)',
color: urgency === '긴급' ? '#ef4444' : urgency === '예정' ? '#06b6d4' : '#64748b',
}}>{urgency}</span>
{up42SelPass === pass.id && <span className="text-xs text-blue-500"></span>}
</div>
)
})}
</div>
</div>
</div>
@ -759,7 +1082,7 @@ export function SatelliteRequest() {
<div className="text-[9px] font-korean text-[#64748b]"> ? <span className="text-[#60a5fa] cursor-pointer"> </span> <span className="text-[#60a5fa] cursor-pointer"> </span></div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-korean mr-1.5 text-[#8690a6]">
: {up42SelSat ? up42Satellites.find(s => s.id === up42SelSat)?.name : '없음'}
: {up42SelPass ? satPasses.find(p => p.id === up42SelPass)?.satellite : up42SelSat ? up42Satellites.find(s => s.id === up42SelSat)?.name : '없음'}
</span>
<button onClick={() => setModalPhase('provider')} className="px-4 py-2 rounded-lg border border-[#21262d] text-[11px] font-semibold cursor-pointer font-korean text-[#94a3b8] bg-[#161b22]"> </button>
<button

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

파일 보기

@ -159,3 +159,26 @@ export async function stopDroneStreamApi(id: string): Promise<{ success: boolean
const response = await api.post<{ success: boolean }>(`/aerial/drone/streams/${id}/stop`);
return response.data;
}
// ============================================================
// UP42 위성 패스 조회
// ============================================================
export interface SatellitePass {
id: string;
satellite: string;
provider: string;
type: 'optical' | 'sar' | 'elevation';
resolution: string;
color: string;
startTime: string;
endTime: string;
maxElevation: number;
direction: 'ascending' | 'descending';
orbit: Array<{ lat: number; lon: number }>;
}
export async function fetchSatellitePasses(): Promise<SatellitePass[]> {
const response = await api.get<{ passes: SatellitePass[] }>('/aerial/satellite/passes');
return response.data.passes;
}

파일 보기

@ -0,0 +1,202 @@
import { useState } from 'react'
/**
* 22
*
*
* :
* https://lbox.kr/v2/statute/%ED%95%B4%EC%96%91%ED%99%98%EA%B2%BD%EA%B4%80%EB%A6%AC%EB%B2%95/%EB%B3%B8%EB%AC%B8%20%3E%20%EC%A0%9C3%EC%9E%A5%20%3E%20%EC%A0%9C1%EC%A0%88%20%3E%20%EC%A0%9C22%EC%A1%B0
* 8[ 2] 14
*/
type Status = 'forbidden' | 'allowed' | 'conditional'
interface DischargeRule {
category: string
item: string
zones: [Status, Status, Status, Status] // [~3NM, 3~12NM, 12~25NM, 25NM+]
condition?: string
}
const RULES: DischargeRule[] = [
// 폐기물
{ category: '폐기물', item: '플라스틱 제품', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
{ category: '폐기물', item: '포장유해물질·용기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
{ category: '폐기물', item: '중금속 포함 쓰레기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
// 화물잔류물
{ category: '화물잔류물', item: '부유성 화물잔류물', zones: ['forbidden', 'forbidden', 'forbidden', 'allowed'] },
{ category: '화물잔류물', item: '침강성 화물잔류물', zones: ['forbidden', 'forbidden', 'allowed', 'allowed'] },
{ category: '화물잔류물', item: '화물창 세정수', zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], condition: '해양환경에 해롭지 않은 일반세제 사용시' },
// 음식물 찌꺼기
{ category: '음식물찌꺼기', item: '미분쇄', zones: ['forbidden', 'forbidden', 'allowed', 'allowed'] },
{ category: '음식물찌꺼기', item: '분쇄·연마', zones: ['forbidden', 'conditional', 'allowed', 'allowed'], condition: '크기 25mm 이하시' },
// 분뇨
{ category: '분뇨', item: '분뇨저장장치', zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], condition: '항속 4노트 이상시 서서히 배출' },
{ category: '분뇨', item: '분뇨마쇄소독장치', zones: ['forbidden', 'conditional', 'conditional', 'conditional'], condition: '항속 4노트 이상시 / 400톤 미만 국내항해 선박은 3해리 이내 가능' },
{ category: '분뇨', item: '분뇨처리장치', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면 및 육성수면은 불가' },
// 중수
{ category: '중수', item: '거주구역 중수', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면, 수산자원관리수면, 지정해역 등은 불가' },
// 수산동식물
{ category: '수산동식물', item: '자연기원물질', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '면허 또는 허가를 득한 자에 한하여 어업활동 수면' },
]
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25해리+']
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e']
function getZoneIndex(distanceNm: number): number {
if (distanceNm < 3) return 0
if (distanceNm < 12) return 1
if (distanceNm < 25) return 2
return 3
}
function StatusBadge({ status }: { status: Status }) {
if (status === 'forbidden') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(239,68,68,0.15)', color: '#ef4444' }}></span>
if (status === 'allowed') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(34,197,94,0.15)', color: '#22c55e' }}></span>
return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(234,179,8,0.15)', color: '#eab308' }}></span>
}
interface DischargeZonePanelProps {
lat: number
lon: number
distanceNm: number
onClose: () => void
}
export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZonePanelProps) {
const zoneIdx = getZoneIndex(distanceNm)
const [expandedCat, setExpandedCat] = useState<string | null>(null)
const categories = [...new Set(RULES.map(r => r.category))]
return (
<div
className="absolute top-4 right-4 z-[1000] rounded-lg overflow-hidden flex flex-col"
style={{
width: 320,
maxHeight: 'calc(100% - 32px)',
background: 'rgba(13,17,23,0.95)',
border: '1px solid #30363d',
boxShadow: '0 16px 48px rgba(0,0,0,0.5)',
backdropFilter: 'blur(12px)',
}}
>
{/* Header */}
<div
className="shrink-0 flex items-center justify-between"
style={{
padding: '10px 14px',
borderBottom: '1px solid #30363d',
background: 'linear-gradient(135deg, #1c2333, #161b22)',
}}
>
<div>
<div className="text-[11px] font-bold text-[#f0f6fc] font-korean">🚢 </div>
<div className="text-[8px] text-[#8b949e] font-korean"> 22</div>
</div>
<span onClick={onClose} className="text-[14px] cursor-pointer text-[#8b949e] hover:text-[#f0f6fc]"></span>
</div>
{/* Location Info */}
<div className="shrink-0" style={{ padding: '8px 14px', borderBottom: '1px solid #21262d' }}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[9px] text-[#8b949e] font-korean"> </span>
<span className="text-[9px] text-[#c9d1d9] font-mono">{lat.toFixed(4)}°N, {lon.toFixed(4)}°E</span>
</div>
<div className="flex items-center justify-between mb-2">
<span className="text-[9px] text-[#8b949e] font-korean"> ()</span>
<span className="text-[11px] font-bold font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
{distanceNm.toFixed(1)} NM
</span>
</div>
{/* Zone indicator */}
<div className="flex gap-1">
{ZONE_LABELS.map((label, i) => (
<div
key={label}
className="flex-1 text-center rounded-sm"
style={{
padding: '3px 0',
fontSize: 8,
fontWeight: i === zoneIdx ? 700 : 400,
color: i === zoneIdx ? '#fff' : '#8b949e',
background: i === zoneIdx ? ZONE_COLORS[i] : 'rgba(255,255,255,0.04)',
border: i === zoneIdx ? 'none' : '1px solid #21262d',
}}
>
{label}
</div>
))}
</div>
</div>
{/* Rules */}
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: '#30363d transparent' }}>
{categories.map(cat => {
const catRules = RULES.filter(r => r.category === cat)
const isExpanded = expandedCat === cat
const allForbidden = catRules.every(r => r.zones[zoneIdx] === 'forbidden')
const allAllowed = catRules.every(r => r.zones[zoneIdx] === 'allowed')
const summaryColor = allForbidden ? '#ef4444' : allAllowed ? '#22c55e' : '#eab308'
return (
<div key={cat} style={{ borderBottom: '1px solid #21262d' }}>
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => setExpandedCat(isExpanded ? null : cat)}
style={{ padding: '8px 14px' }}
>
<div className="flex items-center gap-2">
<div style={{ width: 6, height: 6, borderRadius: '50%', background: summaryColor }} />
<span className="text-[10px] font-bold text-[#c9d1d9] font-korean">{cat}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[8px] font-semibold" style={{ color: summaryColor }}>
{allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'}
</span>
<span className="text-[9px] text-[#8b949e]">{isExpanded ? '▾' : '▸'}</span>
</div>
</div>
{isExpanded && (
<div style={{ padding: '0 14px 10px' }}>
{catRules.map((rule, i) => (
<div
key={i}
className="flex items-center justify-between"
style={{
padding: '5px 8px',
marginBottom: 2,
background: 'rgba(255,255,255,0.02)',
borderRadius: 4,
}}
>
<span className="text-[9px] text-[#c9d1d9] font-korean">{rule.item}</span>
<StatusBadge status={rule.zones[zoneIdx]} />
</div>
))}
{catRules.some(r => r.condition && r.zones[zoneIdx] !== 'forbidden') && (
<div className="mt-1" style={{ padding: '4px 8px' }}>
{catRules.filter(r => r.condition && r.zones[zoneIdx] !== 'forbidden').map((r, i) => (
<div key={i} className="text-[7px] text-[#8b949e] font-korean leading-relaxed">
💡 {r.item}: {r.condition}
</div>
))}
</div>
)}
</div>
)}
</div>
)
})}
</div>
{/* Footer */}
<div className="shrink-0" style={{ padding: '6px 14px', borderTop: '1px solid #21262d' }}>
<div className="text-[7px] text-[#8b949e] font-korean leading-relaxed">
. .
</div>
</div>
</div>
)
}

파일 보기

@ -1,7 +1,8 @@
import { useState, useEffect, useMemo } from 'react'
import { Map, Popup, useControl } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox'
import { ScatterplotLayer, IconLayer } from '@deck.gl/layers'
import { ScatterplotLayer, IconLayer, PathLayer } from '@deck.gl/layers'
import { PathStyleExtension } from '@deck.gl/extensions'
import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'
@ -9,23 +10,25 @@ import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './Inci
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'
import { fetchIncidents } from '../services/incidentsApi'
import type { IncidentCompat } from '../services/incidentsApi'
import { DischargeZonePanel } from './DischargeZonePanel'
import { estimateDistanceFromCoast, getDischargeZoneLines } from '../utils/dischargeZoneData'
// ── CartoDB Dark Matter 베이스맵 ────────────────────────
// ── CartoDB Positron 베이스맵 (밝은 테마) ────────────────
const BASE_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
'carto-light': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
layers: [{ id: 'carto-light-layer', type: 'raster', source: 'carto-light' }],
}
// ── DeckGLOverlay ──────────────────────────────────────
@ -90,6 +93,10 @@ export function IncidentsView() {
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null)
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null)
// Discharge zone mode
const [dischargeMode, setDischargeMode] = useState(false)
const [dischargeInfo, setDischargeInfo] = useState<{ lat: number; lon: number; distanceNm: number } | null>(null)
// Analysis view mode
const [viewMode, setViewMode] = useState<ViewMode>('overlay')
const [analysisActive, setAnalysisActive] = useState(false)
@ -223,10 +230,30 @@ export function IncidentsView() {
})
}, [])
// ── 배출 구역 경계선 레이어 ──
const dischargeZoneLayers = useMemo(() => {
if (!dischargeMode) return []
const zoneLines = getDischargeZoneLines()
return zoneLines.map((line, i) =>
new PathLayer({
id: `discharge-zone-${i}`,
data: [line],
getPath: (d: typeof line) => d.path,
getColor: (d: typeof line) => d.color,
getWidth: 2,
widthUnits: 'pixels',
getDashArray: [6, 3],
dashJustified: true,
extensions: [new PathStyleExtension({ dash: true })],
pickable: false,
})
)
}, [dischargeMode])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers: any[] = useMemo(
() => [incidentLayer, vesselIconLayer],
[incidentLayer, vesselIconLayer],
() => [incidentLayer, vesselIconLayer, ...dischargeZoneLayers],
[incidentLayer, vesselIconLayer, dischargeZoneLayers],
)
return (
@ -320,8 +347,17 @@ export function IncidentsView() {
<Map
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
mapStyle={BASE_STYLE}
style={{ width: '100%', height: '100%', background: '#0a0e1a' }}
style={{ width: '100%', height: '100%', background: '#f0f0f0' }}
attributionControl={false}
onClick={(e) => {
if (dischargeMode && e.lngLat) {
const lat = e.lngLat.lat
const lon = e.lngLat.lng
const distanceNm = estimateDistanceFromCoast(lat, lon)
setDischargeInfo({ lat, lon, distanceNm })
}
}}
cursor={dischargeMode ? 'crosshair' : undefined}
>
<DeckGLOverlay layers={deckLayers} />
@ -428,6 +464,57 @@ export function IncidentsView() {
</div>
)}
{/* 오염물 배출 규정 토글 */}
<button
onClick={() => {
setDischargeMode(!dischargeMode)
if (dischargeMode) setDischargeInfo(null)
}}
className="absolute z-[500] cursor-pointer rounded-md text-[10px] font-bold font-korean"
style={{
top: 10,
right: dischargeMode ? 340 : 180,
padding: '6px 10px',
background: dischargeMode ? 'rgba(6,182,212,0.15)' : 'rgba(13,17,23,0.88)',
border: dischargeMode ? '1px solid rgba(6,182,212,0.4)' : '1px solid #30363d',
color: dischargeMode ? '#22d3ee' : '#8b949e',
backdropFilter: 'blur(8px)',
transition: 'all 0.2s',
}}
>
🚢 {dischargeMode ? 'ON' : 'OFF'}
</button>
{/* 오염물 배출 규정 패널 */}
{dischargeMode && dischargeInfo && (
<DischargeZonePanel
lat={dischargeInfo.lat}
lon={dischargeInfo.lon}
distanceNm={dischargeInfo.distanceNm}
onClose={() => setDischargeInfo(null)}
/>
)}
{/* 배출규정 모드 안내 */}
{dischargeMode && !dischargeInfo && (
<div
className="absolute z-[500] rounded-md text-[11px] font-korean font-semibold"
style={{
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: '12px 20px',
background: 'rgba(13,17,23,0.9)',
border: '1px solid rgba(6,182,212,0.3)',
color: '#22d3ee',
backdropFilter: 'blur(8px)',
pointerEvents: 'none',
}}
>
📍
</div>
)}
{/* AIS Live Badge */}
<div
className="absolute top-[10px] right-[10px] z-[500] rounded-md"

파일 보기

@ -0,0 +1,166 @@
/**
* 22
*
* :
* https://lbox.kr/v2/statute/%ED%95%B4%EC%96%91%ED%99%98%EA%B2%BD%EA%B4%80%EB%A6%AC%EB%B2%95/%EB%B3%B8%EB%AC%B8%20%3E%20%EC%A0%9C3%EC%9E%A5%20%3E%20%EC%A0%9C1%EC%A0%88%20%3E%20%EC%A0%9C22%EC%A1%B0
* 8[ 2] 14
*/
// 한국 해안선 — OpenStreetMap Nominatim 기반 실측 좌표
// [lat, lon] 형식, 시계방향 (동해→남해→서해→DMZ)
const COASTLINE_POINTS: [number, number][] = [
// 동해안 (북→남)
[38.6177, 128.6560], [38.5504, 128.4092], [38.4032, 128.7767],
[38.1904, 128.8902], [38.0681, 128.9977], [37.9726, 129.0715],
[37.8794, 129.1721], [37.8179, 129.2397], [37.6258, 129.3669],
[37.5053, 129.4577], [37.3617, 129.5700], [37.1579, 129.6538],
[37.0087, 129.6706], [36.6618, 129.7210], [36.3944, 129.6827],
[36.2052, 129.7641], [35.9397, 129.8124], [35.6272, 129.7121],
[35.4732, 129.6908], [35.2843, 129.5924], [35.1410, 129.4656],
[35.0829, 129.2125],
// 남해안 (부산→여수→목포)
[34.8950, 129.0658], [34.2050, 128.3063], [35.0220, 128.0362],
[34.9663, 127.8732], [34.9547, 127.7148], [34.8434, 127.6625],
[34.7826, 127.7422], [34.6902, 127.6324], [34.8401, 127.5236],
[34.8230, 127.4043], [34.6882, 127.4234], [34.6252, 127.4791],
[34.5525, 127.4012], [34.4633, 127.3246], [34.5461, 127.1734],
[34.6617, 127.2605], [34.7551, 127.2471], [34.6069, 127.0308],
[34.4389, 126.8975], [34.4511, 126.8263], [34.4949, 126.7965],
[34.5119, 126.7548], [34.4035, 126.6108], [34.3175, 126.5844],
[34.3143, 126.5314], [34.3506, 126.5083], [34.4284, 126.5064],
[34.4939, 126.4817], [34.5896, 126.3326], [34.6732, 126.2645],
// 서해안 (목포→인천)
[34.7200, 126.3011], [34.6946, 126.4256], [34.6979, 126.5245],
[34.7787, 126.5386], [34.8244, 126.5934], [34.8104, 126.4785],
[34.8234, 126.4207], [34.9328, 126.3979], [35.0451, 126.3274],
[35.1542, 126.2911], [35.2169, 126.3605], [35.3144, 126.3959],
[35.4556, 126.4604], [35.5013, 126.4928], [35.5345, 126.5822],
[35.5710, 126.6141], [35.5897, 126.5649], [35.6063, 126.4865],
[35.6471, 126.4885], [35.6693, 126.5419], [35.7142, 126.6016],
[35.7688, 126.7174], [35.8720, 126.7530], [35.8979, 126.7196],
[35.9225, 126.6475], [35.9745, 126.6637], [36.0142, 126.6935],
[36.0379, 126.6823], [36.1050, 126.5971], [36.1662, 126.5404],
[36.2358, 126.5572], [36.3412, 126.5442], [36.4297, 126.5520],
[36.4776, 126.5482], [36.5856, 126.5066], [36.6938, 126.4877],
[36.6780, 126.4330], [36.6512, 126.3888], [36.6893, 126.2307],
[36.6916, 126.1809], [36.7719, 126.1605], [36.8709, 126.2172],
[36.9582, 126.3516], [36.9690, 126.4287], [37.0075, 126.4870],
[37.0196, 126.5777], [36.9604, 126.6867], [36.9484, 126.7845],
[36.8461, 126.8388], [36.8245, 126.8721], [36.8621, 126.8791],
[36.9062, 126.9580], [36.9394, 126.9769], [36.9576, 126.9598],
[36.9757, 126.8689], [37.1027, 126.7874], [37.1582, 126.7761],
[37.1936, 126.7464], [37.2949, 126.7905], [37.4107, 126.6962],
[37.4471, 126.6503], [37.5512, 126.6568], [37.6174, 126.6076],
[37.6538, 126.5802], [37.7165, 126.5634], [37.7447, 126.5777],
[37.7555, 126.6207], [37.7818, 126.6339], [37.8007, 126.6646],
[37.8279, 126.6665], [37.9172, 126.6668], [37.9790, 126.7543],
// DMZ (간소화)
[38.1066, 126.8789], [38.1756, 126.9400], [38.2405, 127.0097],
[38.2839, 127.0903], [38.3045, 127.1695], [38.3133, 127.2940],
[38.3244, 127.5469], [38.3353, 127.7299], [38.3469, 127.7858],
[38.3066, 127.8207], [38.3250, 127.9001], [38.3150, 128.0083],
[38.3107, 128.0314], [38.3189, 128.0887], [38.3317, 128.1269],
[38.3481, 128.1606], [38.3748, 128.2054], [38.4032, 128.2347],
[38.4797, 128.3064], [38.5339, 128.6952], [38.6177, 128.6560],
]
// 제주도 — OpenStreetMap 기반 (26 points)
const JEJU_POINTS: [number, number][] = [
[33.5168, 126.0128], [33.5067, 126.0073], [33.1190, 126.0102],
[33.0938, 126.0176], [33.0748, 126.0305], [33.0556, 126.0355],
[33.0280, 126.0492], [33.0159, 126.4783], [33.0115, 126.5186],
[33.0143, 126.5572], [33.0231, 126.5970], [33.0182, 126.6432],
[33.0201, 126.7129], [33.0458, 126.7847], [33.0662, 126.8169],
[33.0979, 126.8512], [33.1192, 126.9292], [33.1445, 126.9783],
[33.1683, 127.0129], [33.1974, 127.0430], [33.2226, 127.0634],
[33.2436, 127.0723], [33.4646, 127.2106], [33.5440, 126.0355],
[33.5808, 126.0814], [33.5168, 126.0128],
]
const ALL_COASTLINE = [...COASTLINE_POINTS, ...JEJU_POINTS]
/** 두 좌표 간 대략적 해리(NM) 계산 (Haversine) */
function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 3440.065
const dLat = (lat2 - lat1) * Math.PI / 180
const dLon = (lon2 - lon1) * Math.PI / 180
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(a))
}
/** 클릭 지점에서 가장 가까운 해안선까지의 거리 (NM) */
export function estimateDistanceFromCoast(lat: number, lon: number): number {
let minDist = Infinity
for (const [cLat, cLon] of ALL_COASTLINE) {
const dist = haversineNm(lat, lon, cLat, cLon)
if (dist < minDist) minDist = dist
}
return minDist
}
/**
* (NM) ()
*/
function offsetCoastline(points: [number, number][], distanceNm: number, outwardSign: number = 1): [number, number][] {
const degPerNm = 1 / 60
const result: [number, number][] = []
for (let i = 0; i < points.length; i++) {
const prev = points[(i - 1 + points.length) % points.length]
const curr = points[i]
const next = points[(i + 1) % points.length]
const cosLat = Math.cos(curr[0] * Math.PI / 180)
const dx0 = (curr[1] - prev[1]) * cosLat
const dy0 = curr[0] - prev[0]
const dx1 = (next[1] - curr[1]) * cosLat
const dy1 = next[0] - curr[0]
let nx = -(dy0 + dy1) / 2
let ny = (dx0 + dx1) / 2
const len = Math.sqrt(nx * nx + ny * ny) || 1
nx /= len
ny /= len
const latOff = outwardSign * nx * distanceNm * degPerNm
const lonOff = outwardSign * ny * distanceNm * degPerNm / cosLat
result.push([curr[0] + latOff, curr[1] + lonOff])
}
return result
}
export interface ZoneLine {
path: [number, number][]
color: [number, number, number, number]
label: string
distanceNm: number
}
export function getDischargeZoneLines(): ZoneLine[] {
const zones = [
{ nm: 3, color: [239, 68, 68, 180] as [number, number, number, number], label: '3해리' },
{ nm: 12, color: [249, 115, 22, 160] as [number, number, number, number], label: '12해리' },
{ nm: 25, color: [234, 179, 8, 140] as [number, number, number, number], label: '25해리' },
]
const lines: ZoneLine[] = []
for (const zone of zones) {
const mainOffset = offsetCoastline(COASTLINE_POINTS, zone.nm, -1)
lines.push({
path: mainOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
color: zone.color,
label: zone.label,
distanceNm: zone.nm,
})
const jejuOffset = offsetCoastline(JEJU_POINTS, zone.nm, +1)
lines.push({
path: jejuOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
color: zone.color,
label: `${zone.label} (제주)`,
distanceNm: zone.nm,
})
}
return lines
}

파일 보기

@ -1,5 +1,4 @@
import { useState, useRef } from 'react'
import { decimalToDMS } from '@common/utils/coordinates'
import { useState, useRef, useEffect } from 'react'
import { ComboBox } from '@common/components/ui/ComboBox'
import type { PredictionModel } from './OilSpillView'
import { analyzeImage } from '../services/predictionApi'
@ -267,54 +266,33 @@ const PredictionInputSection = ({
{/* 사고 발생 시각 */}
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-text-3 font-korean"> (KST)</label>
<input
className="prd-i"
type="datetime-local"
<DateTimeInput
value={accidentTime}
onChange={(e) => onAccidentTimeChange(e.target.value)}
style={{ colorScheme: 'dark' }}
onChange={onAccidentTimeChange}
/>
</div>
{/* Coordinates + Map Button */}
{/* Coordinates (DMS) + Map Button */}
<div className="flex flex-col gap-1">
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
<input
className="prd-i"
type="number"
step="0.0001"
value={incidentCoord?.lat ?? ''}
onChange={(e) => {
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: isNaN(value) ? 0 : value })
}}
placeholder="위도°"
/>
<input
className="prd-i"
type="number"
step="0.0001"
value={incidentCoord?.lon ?? ''}
onChange={(e) => {
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: isNaN(value) ? 0 : value })
}}
placeholder="경도°"
<div className="grid grid-cols-[1fr_auto] gap-x-1 gap-y-1">
<DmsCoordInput
label="위도"
isLatitude={true}
decimal={incidentCoord?.lat ?? 0}
onChange={(val) => onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: val })}
/>
<button
className={`prd-map-btn${isSelectingLocation ? ' active' : ''}`}
onClick={onMapSelectClick}
>📍 </button>
style={{ gridRow: '1 / 3', gridColumn: 2, whiteSpace: 'nowrap', height: '100%', minWidth: 48, padding: '0 10px' }}
>📍<br/></button>
<DmsCoordInput
label="경도"
isLatitude={false}
decimal={incidentCoord?.lon ?? 0}
onChange={(val) => onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: val })}
/>
</div>
{/* 도분초 표시 */}
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
<div
className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}
>
{decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)}
</div>
)}
</div>
{/* Oil Type + Oil Kind */}
@ -384,7 +362,7 @@ const PredictionInputSection = ({
{/* Model Selection (다중 선택) */}
{/* POSEIDON: 엔진 연동 완료. KOSPS: 준비 중 (ready: false) */}
<div className="flex flex-wrap gap-[3px]">
<div className="grid grid-cols-3 gap-[3px]">
{([
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)', ready: false },
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)', ready: true },
@ -392,7 +370,7 @@ const PredictionInputSection = ({
] as const).map(m => (
<div
key={m.id}
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''} cursor-pointer`}
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''} cursor-pointer text-center`}
onClick={() => {
if (!m.ready) {
alert(`${m.id} 모델은 현재 준비중입니다.`)
@ -445,4 +423,294 @@ const PredictionInputSection = ({
)
}
// ── 커스텀 날짜/시간 선택 컴포넌트 ─────────────────────
function DateTimeInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [showCal, setShowCal] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const datePart = value ? value.split('T')[0] : ''
const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00'
const [hh, mm] = timePart.split(':').map(Number)
const parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date()
const [viewYear, setViewYear] = useState(parsed.getFullYear())
const [viewMonth, setViewMonth] = useState(parsed.getMonth())
const selY = datePart ? parsed.getFullYear() : -1
const selM = datePart ? parsed.getMonth() : -1
const selD = datePart ? parsed.getDate() : -1
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setShowCal(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
const firstDay = new Date(viewYear, viewMonth, 1).getDay()
const days: (number | null)[] = []
for (let i = 0; i < firstDay; i++) days.push(null)
for (let i = 1; i <= daysInMonth; i++) days.push(i)
const pickDate = (day: number) => {
const m = String(viewMonth + 1).padStart(2, '0')
const d = String(day).padStart(2, '0')
onChange(`${viewYear}-${m}-${d}T${timePart}`)
setShowCal(false)
}
const updateTime = (newHH: number, newMM: number) => {
const date = datePart || new Date().toISOString().split('T')[0]
onChange(`${date}T${String(newHH).padStart(2, '0')}:${String(newMM).padStart(2, '0')}`)
}
const prevMonth = () => {
if (viewMonth === 0) { setViewYear(viewYear - 1); setViewMonth(11) }
else setViewMonth(viewMonth - 1)
}
const nextMonth = () => {
if (viewMonth === 11) { setViewYear(viewYear + 1); setViewMonth(0) }
else setViewMonth(viewMonth + 1)
}
const displayDate = datePart
? `${selY}.${String(selM + 1).padStart(2, '0')}.${String(selD).padStart(2, '0')}`
: '날짜 선택'
const today = new Date()
const todayY = today.getFullYear()
const todayM = today.getMonth()
const todayD = today.getDate()
return (
<div ref={ref} className="flex items-center gap-1 relative">
{/* 날짜 버튼 */}
<button
type="button"
onClick={() => setShowCal(!showCal)}
className="prd-i flex-1 flex items-center justify-between cursor-pointer"
style={{ padding: '5px 8px', fontSize: 10 }}
>
<span className="font-mono" style={{ color: datePart ? 'var(--t1)' : 'var(--t3)' }}>{displayDate}</span>
<span className="text-[9px] opacity-60">📅</span>
</button>
{/* 시 */}
<TimeDropdown value={hh} max={24} onChange={(v) => updateTime(v, mm)} />
<span className="text-[8px] text-text-3 font-bold">:</span>
{/* 분 */}
<TimeDropdown value={mm} max={60} onChange={(v) => updateTime(hh, v)} />
{/* 캘린더 팝업 */}
{showCal && (
<div
className="absolute z-[9999] rounded-md overflow-hidden"
style={{
top: '100%',
left: 0,
marginTop: 4,
width: 200,
background: 'var(--bg3)',
border: '1px solid var(--bd)',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
}}
>
{/* 헤더 */}
<div className="flex items-center justify-between" style={{ padding: '6px 8px', borderBottom: '1px solid var(--bd)' }}>
<button type="button" onClick={prevMonth} className="text-[10px] text-text-3 cursor-pointer px-1 hover:text-text-1"></button>
<span className="text-[10px] font-bold text-text-1 font-korean">{viewYear} {viewMonth + 1}</span>
<button type="button" onClick={nextMonth} className="text-[10px] text-text-3 cursor-pointer px-1 hover:text-text-1"></button>
</div>
{/* 요일 */}
<div className="grid grid-cols-7 text-center" style={{ padding: '3px 4px 0' }}>
{['일', '월', '화', '수', '목', '금', '토'].map((d) => (
<span key={d} className="text-[8px] text-text-3 font-korean" style={{ padding: '2px 0' }}>{d}</span>
))}
</div>
{/* 날짜 */}
<div className="grid grid-cols-7 text-center" style={{ padding: '2px 4px 6px' }}>
{days.map((day, i) => {
if (day === null) return <span key={`e-${i}`} />
const isSelected = viewYear === selY && viewMonth === selM && day === selD
const isToday = viewYear === todayY && viewMonth === todayM && day === todayD
return (
<button
key={day}
type="button"
onClick={() => pickDate(day)}
className="cursor-pointer rounded-sm"
style={{
padding: '3px 0',
fontSize: 9,
fontFamily: 'var(--fM)',
fontWeight: isSelected ? 700 : 400,
color: isSelected ? '#fff' : isToday ? 'var(--cyan)' : 'var(--t2)',
background: isSelected ? 'var(--cyan)' : 'transparent',
border: 'none',
}}
>
{day}
</button>
)
})}
</div>
{/* 오늘 버튼 */}
<div style={{ padding: '0 8px 6px' }}>
<button
type="button"
onClick={() => {
setViewYear(todayY)
setViewMonth(todayM)
pickDate(todayD)
}}
className="w-full text-[8px] font-korean font-semibold cursor-pointer rounded-sm"
style={{
padding: '3px 0',
background: 'rgba(6,182,212,0.08)',
border: '1px solid rgba(6,182,212,0.2)',
color: 'var(--cyan)',
}}
>
</button>
</div>
</div>
)}
</div>
)
}
// ── 커스텀 시간 드롭다운 (다크 테마) ───────────────────
function TimeDropdown({ value, max, onChange }: { value: number; max: number; onChange: (v: number) => void }) {
const [open, setOpen] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
useEffect(() => {
if (open && listRef.current) {
const activeEl = listRef.current.querySelector('[data-active="true"]')
if (activeEl) activeEl.scrollIntoView({ block: 'center' })
}
}, [open])
return (
<div ref={dropRef} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="prd-i text-center font-mono cursor-pointer"
style={{ width: 38, padding: '5px 2px', fontSize: 9 }}
>
{String(value).padStart(2, '0')}
</button>
{open && (
<div
ref={listRef}
className="absolute z-[9999] overflow-y-auto rounded-md"
style={{
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: 2,
width: 42,
maxHeight: 160,
background: 'var(--bg3)',
border: '1px solid var(--bd)',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
scrollbarWidth: 'thin',
scrollbarColor: 'var(--bd) transparent',
}}
>
{Array.from({ length: max }, (_, i) => (
<button
key={i}
type="button"
data-active={i === value}
onClick={() => { onChange(i); setOpen(false) }}
className="w-full text-center font-mono cursor-pointer"
style={{
padding: '4px 0',
fontSize: 9,
color: i === value ? 'var(--cyan)' : 'var(--t2)',
background: i === value ? 'rgba(6,182,212,0.15)' : 'transparent',
fontWeight: i === value ? 700 : 400,
border: 'none',
}}
>
{String(i).padStart(2, '0')}
</button>
))}
</div>
)}
</div>
)
}
// ── 도분초 좌표 입력 컴포넌트 ──────────────────────────
function DmsCoordInput({
label,
isLatitude,
decimal,
onChange,
}: {
label: string
isLatitude: boolean
decimal: number
onChange: (val: number) => void
}) {
const abs = Math.abs(decimal)
const d = Math.floor(abs)
const mDec = (abs - d) * 60
const m = Math.floor(mDec)
const s = parseFloat(((mDec - m) * 60).toFixed(2))
const dir = isLatitude ? (decimal >= 0 ? 'N' : 'S') : (decimal >= 0 ? 'E' : 'W')
const update = (deg: number, min: number, sec: number, direction: string) => {
let val = deg + min / 60 + sec / 3600
if (direction === 'S' || direction === 'W') val = -val
onChange(val)
}
const fieldStyle = { padding: '5px 2px', fontSize: 10, minWidth: 0 }
return (
<div className="flex flex-col gap-0.5">
<span className="text-[8px] text-text-3 font-korean">{label}</span>
<div className="flex items-center gap-0.5">
<select
className="prd-i text-center"
value={dir}
onChange={(e) => update(d, m, s, e.target.value)}
style={{ width: 32, padding: '5px 1px', fontSize: 10, appearance: 'none', WebkitAppearance: 'none', backgroundImage: 'none' }}
>
{isLatitude ? (
<><option value="N">N</option><option value="S">S</option></>
) : (
<><option value="E">E</option><option value="W">W</option></>
)}
</select>
<input className="prd-i text-center flex-1" type="number" min={0} max={isLatitude ? 90 : 180}
value={d} onChange={(e) => update(parseInt(e.target.value) || 0, m, s, dir)} style={fieldStyle} />
<span className="text-[9px] text-text-3">°</span>
<input className="prd-i text-center flex-1" type="number" min={0} max={59}
value={m} onChange={(e) => update(d, parseInt(e.target.value) || 0, s, dir)} style={fieldStyle} />
<span className="text-[9px] text-text-3">'</span>
<input className="prd-i text-center flex-1" type="number" min={0} max={59.99} step={0.01}
value={s} onChange={(e) => update(d, m, parseFloat(e.target.value) || 0, dir)} style={fieldStyle} />
<span className="text-[9px] text-text-3">"</span>
</div>
</div>
)
}
export default PredictionInputSection

파일 보기

@ -1,10 +1,9 @@
import { useState, useCallback, useEffect } from 'react';
import type { ScatSegment, ScatDetail } from './scatTypes';
import { fetchSections, fetchSectionDetail, fetchZones, fetchJurisdictions } from '../services/scatApi';
import { fetchOffices, fetchSections, fetchSectionDetail, fetchZones, fetchJurisdictions } from '../services/scatApi';
import type { ApiZoneItem } from '../services/scatApi';
import ScatLeftPanel from './ScatLeftPanel';
import ScatMap from './ScatMap';
import ScatTimeline from './ScatTimeline';
import ScatPopup from './ScatPopup';
import ScatRightPanel from './ScatRightPanel';
@ -14,6 +13,8 @@ export function PreScatView() {
const [segments, setSegments] = useState<ScatSegment[]>([]);
const [zones, setZones] = useState<ApiZoneItem[]>([]);
const [jurisdictions, setJurisdictions] = useState<string[]>([]);
const [offices, setOffices] = useState<string[]>([]);
const [selectedOffice, setSelectedOffice] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedSeg, setSelectedSeg] = useState<ScatSegment | null>(null);
@ -25,30 +26,34 @@ export function PreScatView() {
const [popupData, setPopupData] = useState<ScatDetail | null>(null);
const [panelDetail, setPanelDetail] = useState<ScatDetail | null>(null);
const [panelLoading, setPanelLoading] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [timelineIdx, setTimelineIdx] = useState(6);
// 초기 관할 목록 로딩
// 초기 관할 목록 로딩
useEffect(() => {
let cancelled = false;
async function loadInit() {
try {
setLoading(true);
const jrsdList = await fetchJurisdictions();
const officeList = await fetchOffices();
if (cancelled) return;
setOffices(officeList);
const defaultOffice = officeList.includes('제주청') ? '제주청' : officeList[0] || '';
setSelectedOffice(defaultOffice);
const jrsdList = await fetchJurisdictions(defaultOffice);
if (cancelled) return;
setJurisdictions(jrsdList);
// 기본 관할해경: '제주' 또는 첫 항목
const defaultJrsd = jrsdList.includes('제주') ? '제주' : jrsdList[0] || '';
setJurisdictionFilter('');
const [zonesData, sectionsData] = await Promise.all([
fetchZones(defaultJrsd),
fetchSections({ jurisdiction: defaultJrsd }),
fetchZones(undefined, defaultOffice),
fetchSections({ officeCd: defaultOffice }),
]);
if (cancelled) return;
setZones(zonesData);
setSegments(sectionsData);
setJurisdictionFilter(defaultJrsd);
if (sectionsData.length > 0) {
setSelectedSeg(sectionsData[0]);
}
if (sectionsData.length > 0) setSelectedSeg(sectionsData[0]);
} catch (err) {
console.error('[SCAT] 데이터 로딩 오류:', err);
if (!cancelled) setError('데이터를 불러오지 못했습니다.');
@ -60,30 +65,58 @@ export function PreScatView() {
return () => { cancelled = true; };
}, []);
// 관할서 필터 변경 시 zones + sections 재로딩
// 관할청 변경 시 관할구역 + zones + sections 재로딩
useEffect(() => {
// 초기 로딩 시에는 스킵 (위의 useEffect에서 처리)
if (jurisdictions.length === 0 || !jurisdictionFilter) return;
if (offices.length === 0 || !selectedOffice) return;
let cancelled = false;
async function reload() {
try {
setLoading(true);
const jrsdList = await fetchJurisdictions(selectedOffice);
if (cancelled) return;
setJurisdictions(jrsdList);
setJurisdictionFilter('');
const [zonesData, sectionsData] = await Promise.all([
fetchZones(jurisdictionFilter),
fetchSections({ jurisdiction: jurisdictionFilter }),
fetchZones(undefined, selectedOffice),
fetchSections({ officeCd: selectedOffice }),
]);
if (cancelled) return;
setZones(zonesData);
setSegments(sectionsData);
setAreaFilter('');
if (sectionsData.length > 0) {
setSelectedSeg(sectionsData[0]);
} else {
setSelectedSeg(null);
}
if (sectionsData.length > 0) setSelectedSeg(sectionsData[0]);
else setSelectedSeg(null);
} catch (err) {
console.error('[SCAT] 데이터 재로딩 오류:', err);
console.error('[SCAT] 관할청 변경 오류:', err);
} finally {
if (!cancelled) setLoading(false);
}
}
reload();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedOffice]);
// 관할구역 필터 변경 시 zones + sections 재로딩 (선택된 관할청 내에서 필터링)
useEffect(() => {
if (!selectedOffice || !jurisdictionFilter) return;
let cancelled = false;
async function reload() {
try {
setLoading(true);
const [zonesData, sectionsData] = await Promise.all([
fetchZones(jurisdictionFilter, selectedOffice),
fetchSections({ jurisdiction: jurisdictionFilter, officeCd: selectedOffice }),
]);
if (cancelled) return;
setZones(zonesData);
setSegments(sectionsData);
setAreaFilter('');
if (sectionsData.length > 0) setSelectedSeg(sectionsData[0]);
else setSelectedSeg(null);
} catch (err) {
console.error('[SCAT] 관할구역 변경 오류:', err);
} finally {
if (!cancelled) setLoading(false);
}
@ -121,21 +154,6 @@ export function PreScatView() {
setPopupData(null);
}, []);
const handleTimelineSeek = useCallback(
(idx: number) => {
if (idx === -1) {
setTimelineIdx((prev) => {
const next = (prev + 1) % Math.min(segments.length, 12);
if (segments[next]) setSelectedSeg(segments[next]);
return next;
});
} else {
setTimelineIdx(idx);
if (segments[idx]) setSelectedSeg(segments[idx]);
}
},
[segments],
);
if (error) {
return (
@ -165,6 +183,9 @@ export function PreScatView() {
segments={segments}
zones={zones}
jurisdictions={jurisdictions}
offices={offices}
selectedOffice={selectedOffice}
onOfficeChange={setSelectedOffice}
selectedSeg={selectedSeg}
onSelectSeg={setSelectedSeg}
onOpenPopup={handleOpenPopup}
@ -189,11 +210,11 @@ export function PreScatView() {
onSelectSeg={setSelectedSeg}
onOpenPopup={handleOpenPopup}
/>
<ScatTimeline
{/* <ScatTimeline
segments={segments}
currentIdx={timelineIdx}
onSeek={handleTimelineSeek}
/>
/> */}
</div>
<ScatRightPanel

파일 보기

@ -1,3 +1,5 @@
import { useState, useEffect, useRef, type CSSProperties, type ReactElement } from 'react';
import { List } from 'react-window';
import type { ScatSegment } from './scatTypes';
import type { ApiZoneItem } from '../services/scatApi';
import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants';
@ -6,6 +8,9 @@ interface ScatLeftPanelProps {
segments: ScatSegment[];
zones: ApiZoneItem[];
jurisdictions: string[];
offices: string[];
selectedOffice: string;
onOfficeChange: (v: string) => void;
selectedSeg: ScatSegment;
onSelectSeg: (s: ScatSegment) => void;
onOpenPopup: (sn: number) => void;
@ -21,16 +26,97 @@ interface ScatLeftPanelProps {
onSearchChange: (v: string) => void;
}
interface SegRowData {
filtered: ScatSegment[];
selectedId: number;
onSelectSeg: (s: ScatSegment) => void;
onOpenPopup: (sn: number) => void;
}
function SegRow(props: {
ariaAttributes: { 'aria-posinset': number; 'aria-setsize': number; role: 'listitem' };
index: number;
style: CSSProperties;
} & SegRowData): ReactElement | null {
const { index, style, filtered, selectedId, onSelectSeg, onOpenPopup } = props;
const seg = filtered[index];
if (!seg) return null;
const lvl = esiLevel(seg.esiNum);
const borderColor =
lvl === 'h'
? 'border-l-status-red'
: lvl === 'm'
? 'border-l-status-orange'
: 'border-l-status-green';
const isSelected = selectedId === seg.id;
return (
<div style={{ ...style, paddingBottom: 6, paddingRight: 2 }}>
<div
onClick={() => {
onSelectSeg(seg);
onOpenPopup(seg.id);
}}
className={`bg-bg-3 border border-border rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${
isSelected
? 'border-status-green bg-[rgba(34,197,94,0.05)]'
: 'hover:border-border-light hover:bg-bg-hover'
}`}
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] font-semibold font-korean flex items-center gap-1.5">
📍 {seg.code} {seg.area}
</span>
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white"
style={{ background: esiColor(seg.esiNum) }}
>
ESI {seg.esi}
</span>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.type}</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.length}</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="font-medium font-mono text-[11px]" style={{ color: sensColor[seg.sensitivity] }}>
{seg.sensitivity}
</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="font-medium font-mono text-[11px]" style={{ color: statusColor[seg.status] }}>
{seg.status}
</span>
</div>
</div>
</div>
</div>
);
}
function ScatLeftPanel({
segments,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
zones,
jurisdictions,
offices,
selectedOffice,
onOfficeChange,
selectedSeg,
onSelectSeg,
onOpenPopup,
jurisdictionFilter,
onJurisdictionChange,
areaFilter,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onAreaChange,
phaseFilter,
onPhaseChange,
@ -46,6 +132,21 @@ function ScatLeftPanel({
return true;
});
const listContainerRef = useRef<HTMLDivElement>(null);
const [listHeight, setListHeight] = useState(400);
useEffect(() => {
const el = listContainerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
setListHeight(entry.contentRect.height);
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div className="w-[340px] min-w-[340px] bg-bg-1 border-r border-border flex flex-col overflow-hidden">
{/* Filters */}
@ -59,18 +160,34 @@ function ScatLeftPanel({
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
</label>
<select
value={selectedOffice}
onChange={(e) => onOfficeChange(e.target.value)}
className="prd-i w-full"
>
{offices.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
</div>
<div className="mb-2.5">
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
</label>
<select
value={jurisdictionFilter}
onChange={(e) => onJurisdictionChange(e.target.value)}
className="prd-i w-full"
>
<option value=""></option>
{jurisdictions.map((j) => (
<option key={j} value={j}>{j}</option>
))}
</select>
</div>
<div className="mb-2.5">
{/* <div className="mb-2.5">
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
</label>
@ -86,7 +203,7 @@ function ScatLeftPanel({
</option>
))}
</select>
</div>
</div> */}
<div className="mb-2.5">
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
@ -135,75 +252,20 @@ function ScatLeftPanel({
{filtered.length}
</span>
</div>
<div className="flex-1 overflow-y-auto scrollbar-thin flex flex-col gap-1.5">
{filtered.map((seg) => {
const lvl = esiLevel(seg.esiNum);
const borderColor =
lvl === 'h'
? 'border-l-status-red'
: lvl === 'm'
? 'border-l-status-orange'
: 'border-l-status-green';
const isSelected = selectedSeg.id === seg.id;
return (
<div
key={seg.id}
onClick={() => {
onSelectSeg(seg);
onOpenPopup(seg.id);
}}
className={`bg-bg-3 border border-border rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${
isSelected
? 'border-status-green bg-[rgba(34,197,94,0.05)]'
: 'hover:border-border-light hover:bg-bg-hover'
}`}
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] font-semibold font-korean flex items-center gap-1.5">
📍 {seg.code} {seg.area}
</span>
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white"
style={{ background: esiColor(seg.esiNum) }}
>
ESI {seg.esi}
</span>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-medium font-mono text-[11px]">
{seg.type}
</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-medium font-mono text-[11px]">
{seg.length}
</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span
className="font-medium font-mono text-[11px]"
style={{ color: sensColor[seg.sensitivity] }}
>
{seg.sensitivity}
</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span
className="font-medium font-mono text-[11px]"
style={{ color: statusColor[seg.status] }}
>
{seg.status}
</span>
</div>
</div>
</div>
);
})}
<div className="flex-1 overflow-hidden" ref={listContainerRef}>
<List<SegRowData>
rowCount={filtered.length}
rowHeight={88}
overscanCount={5}
style={{ height: listHeight }}
rowComponent={SegRow}
rowProps={{
filtered,
selectedId: selectedSeg.id,
onSelectSeg,
onOpenPopup,
}}
/>
</div>
</div>
</div>

파일 보기

@ -367,7 +367,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
</div>
{/* Coordinates */}
<div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-border rounded-sm px-3 py-1.5 font-mono text-[11px] text-text-2 flex gap-3.5">
{/* <div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-border rounded-sm px-3 py-1.5 font-mono text-[11px] text-text-2 flex gap-3.5">
<span>
<span className="text-status-green font-medium">{selectedSeg.lat.toFixed(4)}°N</span>
</span>
@ -377,7 +377,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
<span>
<span className="text-status-green font-medium">1:25,000</span>
</span>
</div>
</div> */}
</div>
)
}

파일 보기

@ -15,6 +15,7 @@ const tabs = [
{ id: 2, label: '방제 권고', icon: '🛡️' },
] as const;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function ScatRightPanel({ detail, loading, onOpenReport, onNewSurvey }: ScatRightPanelProps) {
const [activeTab, setActiveTab] = useState(0);
@ -79,7 +80,7 @@ export default function ScatRightPanel({ detail, loading, onOpenReport, onNewSur
</div>
{/* 하단 버튼 */}
<div className="flex flex-col gap-1.5 p-2.5 border-t border-border shrink-0">
{/* <div className="flex flex-col gap-1.5 p-2.5 border-t border-border shrink-0">
<button onClick={onOpenReport}
className="w-full py-2 text-[11px] font-semibold rounded-md cursor-pointer text-primary-cyan"
style={{ background: 'rgba(6,182,212,.08)', border: '1px solid rgba(6,182,212,.25)' }}>
@ -90,7 +91,7 @@ export default function ScatRightPanel({ detail, loading, onOpenReport, onNewSur
style={{ background: 'rgba(34,197,94,.08)', border: '1px solid rgba(34,197,94,.25)' }}>
</button>
</div>
</div> */}
</div>
);
}

파일 보기

@ -102,14 +102,24 @@ function toScatDetail(item: ApiSectionDetail): ScatDetail {
// API 호출 함수
// ============================================================
export async function fetchJurisdictions(): Promise<string[]> {
const { data } = await api.get<string[]>('/scat/jurisdictions');
export async function fetchOffices(): Promise<string[]> {
const { data } = await api.get<string[]>('/scat/offices');
return data;
}
export async function fetchZones(jurisdiction?: string): Promise<ApiZoneItem[]> {
const params = jurisdiction ? `?jurisdiction=${encodeURIComponent(jurisdiction)}` : '';
const { data } = await api.get<ApiZoneItem[]>(`/scat/zones${params}`);
export async function fetchJurisdictions(officeCd?: string): Promise<string[]> {
const params = officeCd ? `?officeCd=${encodeURIComponent(officeCd)}` : '';
const { data } = await api.get<string[]>(`/scat/jurisdictions${params}`);
return data;
}
export async function fetchZones(jurisdiction?: string, officeCd?: string): Promise<ApiZoneItem[]> {
const params = new URLSearchParams();
if (jurisdiction) params.set('jurisdiction', jurisdiction);
if (officeCd) params.set('officeCd', officeCd);
const query = params.toString();
const url = query ? `/scat/zones?${query}` : '/scat/zones';
const { data } = await api.get<ApiZoneItem[]>(url);
return data;
}
@ -119,6 +129,7 @@ export interface SectionFilters {
sensitivity?: string;
jurisdiction?: string;
search?: string;
officeCd?: string;
}
export async function fetchSections(filters?: SectionFilters): Promise<ScatSegment[]> {
@ -128,6 +139,7 @@ export async function fetchSections(filters?: SectionFilters): Promise<ScatSegme
if (filters?.sensitivity) params.set('sensitivity', filters.sensitivity);
if (filters?.jurisdiction) params.set('jurisdiction', filters.jurisdiction);
if (filters?.search) params.set('search', filters.search);
if (filters?.officeCd) params.set('officeCd', filters.officeCd);
const query = params.toString();
const url = query ? `/scat/sections?${query}` : '/scat/sections';

파일 보기

@ -46,231 +46,223 @@ interface WeatherRightPanelProps {
weatherData: WeatherData | null;
}
/* ── Local Helpers (not exported) ─────────────────────────── */
function WindCompass({ degrees }: { degrees: number }) {
// center=28, radius=22
return (
<svg width="56" height="56" viewBox="0 0 56 56" className="shrink-0">
{/* arcs connecting N→E→S→W→N */}
<path d="M 28,6 A 22,22 0 0,1 50,28" fill="none" stroke="#1e2a42" strokeWidth="1" />
<path d="M 50,28 A 22,22 0 0,1 28,50" fill="none" stroke="#1e2a42" strokeWidth="1" />
<path d="M 28,50 A 22,22 0 0,1 6,28" fill="none" stroke="#1e2a42" strokeWidth="1" />
<path d="M 6,28 A 22,22 0 0,1 28,6" fill="none" stroke="#1e2a42" strokeWidth="1" />
{/* cardinal labels — same color as 풍향/기압 text (#edf0f7 = text-1) */}
<text x="28" y="10" textAnchor="middle" fontSize="8" fill="#edf0f7" fontWeight="600">N</text>
<text x="28" y="53" textAnchor="middle" fontSize="8" fill="#edf0f7" fontWeight="600">S</text>
<text x="50" y="31" textAnchor="middle" fontSize="8" fill="#edf0f7" fontWeight="600">E</text>
<text x="6" y="31" textAnchor="middle" fontSize="8" fill="#edf0f7" fontWeight="600">W</text>
{/* clock-hand needle */}
<g style={{ transform: `rotate(${degrees}deg)`, transformOrigin: '28px 28px' }}>
<line x1="28" y1="28" x2="28" y2="10" stroke="#eab308" strokeWidth="2" strokeLinecap="round" />
<circle cx="28" cy="10" r="2" fill="#eab308" />
</g>
{/* center dot */}
<circle cx="28" cy="28" r="2" fill="#8690a6" />
</svg>
);
/** 풍속 등급 색상 */
function windColor(speed: number): string {
if (speed >= 14) return '#ef4444';
if (speed >= 10) return '#f97316';
if (speed >= 6) return '#eab308';
return '#22c55e';
}
function ProgressBar({ value, max, gradient, label }: { value: number; max: number; gradient: string; label: string }) {
const pct = Math.min(100, (value / max) * 100);
return (
<div className="mt-3 flex items-center gap-2">
<div className="h-1.5 flex-1 rounded-full bg-bg-2 overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: gradient }} />
</div>
<span className="text-[10px] text-text-3 shrink-0">{label}</span>
</div>
);
/** 파고 등급 색상 */
function waveColor(height: number): string {
if (height >= 3) return '#ef4444';
if (height >= 2) return '#f97316';
if (height >= 1) return '#eab308';
return '#22c55e';
}
function StatCard({ value, label, valueClass = 'text-primary-cyan' }: { value: string; label: string; valueClass?: string }) {
return (
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-0.5">
<span className={`text-sm font-bold font-mono ${valueClass}`}>{value}</span>
<span className="text-[10px] text-text-3">{label}</span>
</div>
);
/** 풍향 텍스트 */
function windDirText(deg: number): string {
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
return dirs[Math.round(deg / 22.5) % 16];
}
/* ── Main Component ───────────────────────────────────────── */
export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
if (!weatherData) {
return (
<div className="flex flex-col bg-bg-1 border-l border-border overflow-hidden w-[380px] shrink-0">
<div className="flex flex-col bg-bg-1 border-l border-border overflow-hidden w-[320px] shrink-0">
<div className="p-6 text-center">
<p className="text-text-3 text-sm"> </p>
<p className="text-text-3 text-[13px] font-korean"> </p>
</div>
</div>
);
}
const {
wind, wave, temperature, pressure, visibility,
salinity, astronomy, alert, forecast,
} = weatherData;
const { wind, wave, temperature, pressure, visibility, salinity, astronomy, alert, forecast } = weatherData;
const wSpd = wind.speed;
const wHgt = wave.height;
const wTemp = temperature.current;
const windDir = windDirText(wind.direction);
return (
<div className="flex flex-col bg-bg-1 border-l border-border overflow-hidden w-[380px] shrink-0">
{/* ── Header ─────────────────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="flex flex-col bg-bg-1 border-l border-border overflow-hidden w-[320px] shrink-0">
{/* 헤더 */}
<div className="px-4 py-3 border-b border-border bg-bg-2">
<div className="flex items-center gap-2 mb-1">
<span className="text-primary-cyan text-sm font-semibold">📍 {weatherData.stationName}</span>
<span className="px-2 py-0.5 text-[10px] rounded bg-primary-cyan/20 text-primary-cyan font-semibold">
</span>
<span className="text-[13px] font-bold text-primary-cyan font-korean">📍 {weatherData.stationName}</span>
<span className="px-1.5 py-px text-[11px] rounded bg-primary-cyan/15 text-primary-cyan font-bold"></span>
</div>
<p className="text-[11px] text-text-3 font-mono">
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E · {weatherData.currentTime}
</p>
</div>
{/* ── Scrollable Content ─────────────────────────────── */}
<div className="flex-1 overflow-auto">
{/* ── Summary Cards ──────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="grid grid-cols-3 gap-2">
<div className="bg-bg-2 border border-border rounded-lg p-3 flex flex-col items-center">
<span className="text-[22px] font-bold text-primary-cyan font-mono leading-none">
{wind.speed.toFixed(1)}
</span>
<span className="text-[12px] text-text-3 mt-1"> (m/s)</span>
{/* 스크롤 콘텐츠 */}
<div className="flex-1 overflow-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
{/* ── 핵심 지표 3칸 카드 ── */}
<div className="grid grid-cols-3 gap-1 px-3 py-2.5">
<div className="text-center py-2.5 bg-bg-0 border border-border rounded-md">
<div className="text-[20px] font-bold font-mono" style={{ color: windColor(wSpd) }}>{wSpd.toFixed(1)}</div>
<div className="text-[11px] text-text-3 font-korean"> (m/s)</div>
</div>
<div className="text-center py-2.5 bg-bg-0 border border-border rounded-md">
<div className="text-[20px] font-bold font-mono" style={{ color: waveColor(wHgt) }}>{wHgt.toFixed(1)}</div>
<div className="text-[11px] text-text-3 font-korean"> (m)</div>
</div>
<div className="text-center py-2.5 bg-bg-0 border border-border rounded-md">
<div className="text-[20px] font-bold font-mono text-primary-cyan">{wTemp.toFixed(1)}</div>
<div className="text-[11px] text-text-3 font-korean"> (°C)</div>
</div>
</div>
{/* ── 바람 상세 ── */}
<div className="px-3 py-2 border-b border-border">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2">🌬 </div>
<div className="flex items-center gap-3 mb-2">
{/* 풍향 컴파스 */}
<div className="relative w-[50px] h-[50px] shrink-0">
<svg viewBox="0 0 50 50" className="w-full h-full">
<circle cx="25" cy="25" r="22" fill="none" stroke="var(--bd)" strokeWidth="1" />
<circle cx="25" cy="25" r="16" fill="none" stroke="var(--bd)" strokeWidth="0.5" strokeDasharray="2 2" />
{['N', 'E', 'S', 'W'].map((d, i) => {
const angle = i * 90;
const rad = (angle - 90) * Math.PI / 180;
const x = 25 + 20 * Math.cos(rad);
const y = 25 + 20 * Math.sin(rad);
return <text key={d} x={x} y={y} textAnchor="middle" dominantBaseline="central" fill="var(--t3)" fontSize="6" fontWeight="bold">{d}</text>;
})}
{/* 풍향 화살표 */}
<line
x1="25" y1="25"
x2={25 + 14 * Math.sin(wind.direction * Math.PI / 180)}
y2={25 - 14 * Math.cos(wind.direction * Math.PI / 180)}
stroke={windColor(wSpd)} strokeWidth="2" strokeLinecap="round"
/>
<circle cx="25" cy="25" r="3" fill={windColor(wSpd)} />
</svg>
</div>
<div className="bg-bg-2 border border-border rounded-lg p-3 flex flex-col items-center">
<span className="text-[22px] font-bold text-primary-cyan font-mono leading-none">
{wave.height.toFixed(1)}
</span>
<span className="text-[12px] text-text-3 mt-1"> (m)</span>
<div className="flex-1 grid grid-cols-2 gap-x-3 gap-y-1.5 text-[11px]">
<div className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-mono font-bold text-[13px]">{windDir} {wind.direction}°</span></div>
<div className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-mono text-[13px]">{pressure} hPa</span></div>
<div className="flex justify-between"><span className="text-text-3">1k </span><span className="font-mono text-[13px]" style={{ color: windColor(wind.speed_1k) }}>{Number(wind.speed_1k).toFixed(1)}</span></div>
<div className="flex justify-between"><span className="text-text-3">3k </span><span className="font-mono text-[13px]" style={{ color: windColor(wind.speed_3k) }}>{Number(wind.speed_3k).toFixed(1)}</span></div>
<div className="col-span-2 flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-mono text-[13px]">{visibility} km</span></div>
</div>
<div className="bg-bg-2 border border-border rounded-lg p-3 flex flex-col items-center">
<span className="text-[22px] font-bold text-primary-cyan font-mono leading-none">
{temperature.current.toFixed(1)}
</span>
<span className="text-[12px] text-text-3 mt-1"> (°C)</span>
</div>
{/* 풍속 게이지 바 */}
<div className="flex items-center gap-2">
<div className="flex-1 h-[5px] bg-bg-3 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${Math.min(wSpd / 20 * 100, 100)}%`, background: windColor(wSpd) }} />
</div>
<span className="text-[11px] font-mono text-text-3 shrink-0">{wSpd.toFixed(1)}/20</span>
</div>
</div>
{/* ── 파도 상세 ── */}
<div className="px-3 py-2 border-b border-border">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2">🌊 </div>
<div className="grid grid-cols-4 gap-1">
<div className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-[14px] font-bold font-mono" style={{ color: waveColor(wHgt) }}>{wHgt.toFixed(1)}m</div>
<div className="text-[10px] text-text-3"></div>
</div>
<div className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-[14px] font-bold font-mono text-status-red">{wave.maxHeight.toFixed(1)}m</div>
<div className="text-[10px] text-text-3"></div>
</div>
<div className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-[14px] font-bold font-mono text-primary-cyan">{wave.period}s</div>
<div className="text-[10px] text-text-3"></div>
</div>
<div className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-[14px] font-bold font-mono text-text-1">{wave.direction}</div>
<div className="text-[10px] text-text-3"></div>
</div>
</div>
{/* 파고 게이지 바 */}
<div className="flex items-center gap-2 mt-1.5">
<div className="flex-1 h-[5px] bg-bg-3 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${Math.min(wHgt / 5 * 100, 100)}%`, background: waveColor(wHgt) }} />
</div>
<span className="text-[11px] font-mono text-text-3 shrink-0">{wHgt.toFixed(1)}/5m</span>
</div>
</div>
{/* ── 수온/공기 ── */}
<div className="px-3 py-2 border-b border-border">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2">🌡 · </div>
<div className="grid grid-cols-3 gap-1">
<div className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-[14px] font-bold font-mono text-primary-cyan">{wTemp.toFixed(1)}°</div>
<div className="text-[10px] text-text-3"></div>
</div>
<div className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-[14px] font-bold font-mono text-text-1">{temperature.feelsLike.toFixed(1)}°</div>
<div className="text-[10px] text-text-3"></div>
</div>
<div className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-[14px] font-bold font-mono text-text-1">{salinity.toFixed(1)}</div>
<div className="text-[10px] text-text-3">(PSU)</div>
</div>
</div>
</div>
{/* ── 바람 현황 ──────────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="text-text-3 mb-3">🏳 </div>
<div className="flex gap-4 items-start">
<WindCompass degrees={wind.direction} />
<div className="flex-1 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[13px]">
<span className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-semibold font-mono">{wind.directionLabel} {wind.direction}°</span></span>
<span className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-semibold font-mono">{pressure} hPa</span></span>
<span className="flex justify-between"><span className="text-text-3">1k </span><span className="text-text-1 font-semibold font-mono">{wind.speed_1k.toFixed(1)}</span></span>
<span className="flex justify-between"><span className="text-text-3">3k </span><span className="text-text-1 font-semibold font-mono">{wind.speed_3k.toFixed(1)}</span></span>
<span className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-semibold font-mono">{visibility} km</span></span>
</div>
</div>
<ProgressBar
value={wind.speed}
max={20}
gradient="linear-gradient(to right, #f97316, #eab308)"
label={`${wind.speed.toFixed(1)}/20`}
/>
</div>
{/* ── 파도 ───────────────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="text-[11px] text-text-3 mb-3">🌊 </div>
<div className="grid grid-cols-4 gap-1.5">
<StatCard value={`${wave.height.toFixed(1)}m`} label="유의파고" />
<StatCard value={`${wave.maxHeight.toFixed(1)}m`} label="최고파고" />
<StatCard value={`${wave.period}s`} label="주기" />
<StatCard value={wave.direction} label="파향" />
</div>
<ProgressBar
value={wave.height}
max={5}
gradient="linear-gradient(to right, #f97316, #6b7280)"
label={`${wave.height.toFixed(1)}/5m`}
/>
</div>
{/* ── 수온 · 공기 ────────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="text-[11px] text-text-3 mb-3">💧 · </div>
<div className="grid grid-cols-3 gap-1.5">
<StatCard value={`${temperature.current.toFixed(1)}°`} label="수온" />
<StatCard value={`${temperature.feelsLike.toFixed(1)}°`} label="기온" />
<StatCard value={`${salinity.toFixed(1)}`} label="염분(PSU)" />
</div>
</div>
{/* ── 시간별 예보 ────────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="text-[11px] text-text-3 mb-3">🕐 </div>
<div className="grid grid-cols-5 gap-1.5">
{forecast.map((fc, i) => (
<div
key={i}
className="bg-bg-2 border border-border rounded-md p-2 flex flex-col items-center gap-0.5"
>
<span className="text-[10px] text-text-3">{fc.hour}</span>
<span className="text-lg">{fc.icon}</span>
<span className="text-sm font-bold text-primary-cyan">{fc.temperature}°</span>
<span className="text-[10px] text-text-3">{fc.windSpeed}</span>
{/* ── 시간별 예보 ── */}
<div className="px-3 py-2 border-b border-border">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2"> </div>
<div className="grid grid-cols-5 gap-1">
{forecast.map((f, i) => (
<div key={i} className="flex flex-col items-center py-2 px-1 bg-bg-0 border border-border rounded">
<span className="text-[11px] text-text-3 mb-0.5">{f.hour}</span>
<span className="text-lg mb-0.5">{f.icon}</span>
<span className="text-[13px] font-bold text-text-1">{f.temperature}°</span>
<span className="text-[11px] text-text-3 font-mono">{f.windSpeed}</span>
</div>
))}
</div>
</div>
{/* ── 천문 · 조석 ────────────────────────────────── */}
<div className="px-5 py-3 border-b border-border">
<div className="text-[11px] text-text-3 mb-3"> · </div>
{astronomy && (
<>
<div className="grid grid-cols-4 gap-1.5">
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1">
<span className="text-base">🌅</span>
<span className="text-[9px] text-text-3"></span>
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.sunrise}</span>
{/* ── 천문/조석 ── */}
{astronomy && (
<div className="px-3 py-2 border-b border-border">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2"> · </div>
<div className="grid grid-cols-4 gap-1">
{[
{ icon: '🌅', label: '일출', value: astronomy.sunrise },
{ icon: '🌄', label: '일몰', value: astronomy.sunset },
{ icon: '🌙', label: '월출', value: astronomy.moonrise },
{ icon: '🌜', label: '월몰', value: astronomy.moonset },
].map((item, i) => (
<div key={i} className="text-center py-2 bg-bg-0 border border-border rounded">
<div className="text-base mb-0.5">{item.icon}</div>
<div className="text-[10px] text-text-3">{item.label}</div>
<div className="text-[13px] font-bold font-mono text-text-1">{item.value}</div>
</div>
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1">
<span className="text-base">🌇</span>
<span className="text-[9px] text-text-3"></span>
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.sunset}</span>
</div>
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1">
<span className="text-base">🌙</span>
<span className="text-[9px] text-text-3"></span>
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.moonrise}</span>
</div>
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1">
<span className="text-base">🌜</span>
<span className="text-[9px] text-text-3"></span>
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.moonset}</span>
</div>
</div>
<div className="flex items-center justify-between mt-2 text-[11px] bg-bg-2 border border-border rounded-md p-2.5">
<div className="flex items-center gap-2">
<span>🌓</span>
<span className="text-text-3">{astronomy.moonPhase}</span>
</div>
<div className="text-text-3">
<span className="ml-2 text-text-1 font-semibold font-mono">{astronomy.tidalRange}m</span>
</div>
</div>
</>
)}
</div>
))}
</div>
<div className="flex items-center gap-2 mt-1.5 px-2 py-1 bg-bg-0 border border-border rounded text-[11px]">
<span className="text-sm">🌓</span>
<span className="text-text-3">{astronomy.moonPhase}</span>
<span className="ml-auto text-text-1 font-mono"> {astronomy.tidalRange}m</span>
</div>
</div>
)}
{/* ── 날씨 특보 ──────────────────────────────────── */}
{/* ── 날씨 특보 ── */}
{alert && (
<div className="px-5 py-3">
<div className="text-[11px] text-text-3 mb-3">🚨 </div>
<div className="p-3 bg-status-red/10 border border-status-red/30 rounded-md">
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-status-red text-white"></span>
<span className="text-text-1 text-xs">{alert}</span>
<div className="px-3 py-2">
<div className="text-[11px] font-bold text-text-3 font-korean mb-2">🚨 </div>
<div className="px-2.5 py-2 rounded border" style={{ background: 'rgba(239,68,68,.06)', borderColor: 'rgba(239,68,68,.2)' }}>
<div className="flex items-center gap-2 text-[11px]">
<span className="px-1.5 py-px rounded text-[11px] font-bold" style={{ background: 'rgba(239,68,68,.15)', color: 'var(--red)' }}></span>
<span className="text-text-1 font-korean">{alert}</span>
</div>
</div>
</div>
)}
</div>
</div>
);

파일 보기

@ -396,99 +396,74 @@ export function WeatherView() {
</Map>
{/* 레이어 컨트롤 */}
<div className="absolute top-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm z-10">
<div className="text-sm font-semibold text-text-1 mb-3"> </div>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<div className="absolute top-4 left-4 bg-bg-1/85 border border-border rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px' }}>
<div className="text-[9px] font-semibold text-text-1 mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('windParticle')}
onChange={() => toggleLayer('windParticle')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
/>
<span className="text-xs text-text-2">🌬 </span>
<span className="text-[9px] text-text-2">🌬 </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('wind')}
onChange={() => toggleLayer('wind')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
/>
<span className="text-xs text-text-2">🌬 </span>
<span className="text-[9px] text-text-2">🌬 </span>
</label>
{/*
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('labels')}
onChange={() => toggleLayer('labels')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
/>
<span className="text-xs text-text-2">📊 </span>
</label>
*/}
<label className="flex items-center gap-2 cursor-pointer">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('waves')}
onChange={() => toggleLayer('waves')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
/>
<span className="text-xs text-text-2">🌊 </span>
<span className="text-[9px] text-text-2">🌊 </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('temperature')}
onChange={() => toggleLayer('temperature')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
/>
<span className="text-xs text-text-2">🌡 </span>
<span className="text-[9px] text-text-2">🌡 </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('oceanCurrentParticle')}
onChange={() => toggleLayer('oceanCurrentParticle')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
/>
<span className="text-xs text-text-2">🌊 </span>
<span className="text-[9px] text-text-2">🌊 </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('waterTemperature')}
onChange={() => toggleLayer('waterTemperature')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
/>
<span className="text-xs text-text-2">🌡 </span>
<span className="text-[9px] text-text-2">🌡 </span>
</label>
{/*
<div className="pt-2 mt-2 border-t border-border">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabledLayers.has('oceanForecast')}
onChange={() => toggleLayer('oceanForecast')}
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
/>
<span className="text-xs text-text-2">🌊 </span>
</label>
</div>
*/}
</div>
</div>
{/* 범례 */}
<div className="absolute bottom-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm z-10">
<div className="text-sm font-semibold text-text-1 mb-3"> </div>
<div className="space-y-3 text-xs">
{/* 바람 (Windy 스타일) */}
<div className="absolute bottom-4 left-4 bg-bg-1/85 border border-border rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px', maxWidth: 180 }}>
<div className="text-[9px] font-semibold text-text-1 mb-1.5 font-korean"> </div>
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
{/* 바람 */}
<div>
<div className="font-semibold text-text-2 mb-1"> (m/s)</div>
<div className="flex items-center gap-1 h-3 rounded-sm overflow-hidden mb-1">
<div className="font-semibold text-text-2 mb-0.5" style={{ fontSize: 8 }}> (m/s)</div>
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
<div className="flex-1 h-full" style={{ background: '#50d591' }} />
@ -498,53 +473,38 @@ export function WeatherView() {
<div className="flex-1 h-full" style={{ background: '#f05421' }} />
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
</div>
<div className="flex justify-between text-text-3 text-[9px]">
<span>3</span>
<span>5</span>
<span>7</span>
<span>10</span>
<span>13</span>
<span>16</span>
<span>20+</span>
<div className="flex justify-between text-text-3" style={{ fontSize: 7 }}>
<span>3</span><span>5</span><span>7</span><span>10</span><span>13</span><span>16</span><span>20+</span>
</div>
</div>
{/* 해류 */}
<div className="pt-2 border-t border-border">
<div className="font-semibold text-text-2 mb-1"> (m/s)</div>
<div className="flex items-center gap-1 h-3 rounded-sm overflow-hidden mb-1">
<div className="pt-1 border-t border-border">
<div className="font-semibold text-text-2 mb-0.5" style={{ fontSize: 8 }}> (m/s)</div>
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
<div className="flex-1 h-full" style={{ background: 'rgb(34, 197, 94)' }} />
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
</div>
<div className="flex justify-between text-text-3 text-[9px]">
<span>0.2</span>
<span>0.4</span>
<span>0.6</span>
<span>0.6+</span>
<div className="flex justify-between text-text-3" style={{ fontSize: 7 }}>
<span>0.2</span><span>0.4</span><span>0.6</span><span>0.6+</span>
</div>
</div>
{/* 파고 */}
<div className="pt-2 border-t border-border">
<div className="font-semibold text-text-2 mb-1"> (m)</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-text-3">&lt; 1.5: 낮음</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-orange-500" />
<span className="text-text-3">1.5-2.5: 보통</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-text-3">&gt; 2.5: 높음</span>
<div className="pt-1 border-t border-border">
<div className="font-semibold text-text-2 mb-0.5" style={{ fontSize: 8 }}> (m)</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-text-3">&lt;1.5 </span>
<div className="w-2 h-2 rounded-full bg-orange-500 ml-1" />
<span className="text-text-3">~2.5</span>
<div className="w-2 h-2 rounded-full bg-red-500 ml-1" />
<span className="text-text-3">&gt;2.5</span>
</div>
</div>
</div>
<div className="mt-3 pt-3 border-t border-border text-xs text-text-3">
💡
<div className="mt-1 pt-1 border-t border-border text-text-3 font-korean" style={{ fontSize: 7 }}>
💡
</div>
</div>
</div>