release: 2026-03-20 (124건 커밋) #108

병합
dnlee develop 에서 main 로 7 commits 를 머지했습니다 2026-03-20 10:38:46 +09:00
12개의 변경된 파일479개의 추가작업 그리고 326개의 파일을 삭제

파일 보기

@ -1,15 +1,29 @@
import { Router } from 'express'; import { Router } from 'express';
import { requireAuth } from '../auth/authMiddleware.js'; 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(); 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 — 관할서 목록 // GET /api/scat/jurisdictions — 관할서 목록
// ============================================================ // ============================================================
router.get('/jurisdictions', requireAuth, async (_req, res) => { router.get('/jurisdictions', requireAuth, async (req, res) => {
try { try {
const jurisdictions = await listJurisdictions(); const { officeCd } = req.query as { officeCd?: string };
const jurisdictions = await listJurisdictions(officeCd);
res.json(jurisdictions); res.json(jurisdictions);
} catch (err) { } catch (err) {
console.error('[scat] 관할서 목록 조회 오류:', err); console.error('[scat] 관할서 목록 조회 오류:', err);
@ -22,8 +36,8 @@ router.get('/jurisdictions', requireAuth, async (_req, res) => {
// ============================================================ // ============================================================
router.get('/zones', requireAuth, async (req, res) => { router.get('/zones', requireAuth, async (req, res) => {
try { try {
const { jurisdiction } = req.query as { jurisdiction?: string }; const { jurisdiction, officeCd } = req.query as { jurisdiction?: string; officeCd?: string };
const zones = await listZones({ jurisdiction }); const zones = await listZones({ jurisdiction, officeCd });
res.json(zones); res.json(zones);
} catch (err) { } catch (err) {
console.error('[scat] 조사구역 목록 조회 오류:', err); console.error('[scat] 조사구역 목록 조회 오류:', err);
@ -36,14 +50,15 @@ router.get('/zones', requireAuth, async (req, res) => {
// ============================================================ // ============================================================
router.get('/sections', requireAuth, async (req, res) => { router.get('/sections', requireAuth, async (req, res) => {
try { try {
const { zone, status, sensitivity, jurisdiction, search } = req.query as { const { zone, status, sensitivity, jurisdiction, search, officeCd } = req.query as {
zone?: string; zone?: string;
status?: string; status?: string;
sensitivity?: string; sensitivity?: string;
jurisdiction?: string; jurisdiction?: string;
search?: 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); res.json(sections);
} catch (err) { } catch (err) {
console.error('[scat] 해안구간 목록 조회 오류:', err); console.error('[scat] 해안구간 목록 조회 오류:', err);

파일 보기

@ -60,18 +60,40 @@ interface SectionDetail {
notes: string[]; 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 = ` const sql = `
SELECT DISTINCT JRSD_NM SELECT DISTINCT JRSD_NM
FROM wing.CST_SRVY_ZONE FROM wing.CST_SRVY_ZONE
WHERE USE_YN = 'Y' AND JRSD_NM IS NOT NULL WHERE ${conditions.join(' AND ')}
ORDER BY JRSD_NM 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); 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?: { export async function listZones(filters?: {
jurisdiction?: string; jurisdiction?: string;
officeCd?: string;
}): Promise<ZoneItem[]> { }): Promise<ZoneItem[]> {
const conditions: string[] = ["USE_YN = 'Y'"]; const conditions: string[] = ["USE_YN = 'Y'"];
const params: unknown[] = []; const params: unknown[] = [];
@ -90,6 +113,10 @@ export async function listZones(filters?: {
conditions.push(`JRSD_NM ILIKE '%' || $${idx++} || '%'`); conditions.push(`JRSD_NM ILIKE '%' || $${idx++} || '%'`);
params.push(filters.jurisdiction); params.push(filters.jurisdiction);
} }
if (filters?.officeCd) {
conditions.push(`OFFICE_CD = $${idx++}`);
params.push(filters.officeCd);
}
const where = 'WHERE ' + conditions.join(' AND '); const where = 'WHERE ' + conditions.join(' AND ');
@ -126,6 +153,7 @@ export async function listSections(filters: {
sensitivity?: string; sensitivity?: string;
jurisdiction?: string; jurisdiction?: string;
search?: string; search?: string;
officeCd?: string;
}): Promise<SectionListItem[]> { }): Promise<SectionListItem[]> {
const conditions: string[] = []; const conditions: string[] = [];
const params: unknown[] = []; const params: unknown[] = [];
@ -151,6 +179,10 @@ export async function listSections(filters: {
conditions.push(`s.SECT_NM ILIKE '%' || $${idx++} || '%'`); conditions.push(`s.SECT_NM ILIKE '%' || $${idx++} || '%'`);
params.push(filters.search); 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("s.USE_YN = 'Y'");
conditions.push("z.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_CD VARCHAR(10) NOT NULL UNIQUE,
ZONE_NM VARCHAR(100) NOT NULL, ZONE_NM VARCHAR(100) NOT NULL,
JRSD_NM VARCHAR(20), JRSD_NM VARCHAR(20),
OFFICE_CD VARCHAR(20),
SECT_CNT INTEGER DEFAULT 0, SECT_CNT INTEGER DEFAULT 0,
LAT_CENTER NUMERIC(9,6), LAT_CENTER NUMERIC(9,6),
LNG_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 ( CREATE TABLE IF NOT EXISTS CST_SECT (
CST_SECT_SN SERIAL PRIMARY KEY, CST_SECT_SN SERIAL PRIMARY KEY,
CST_SRVY_ZONE_SN INTEGER REFERENCES CST_SRVY_ZONE(CST_SRVY_ZONE_SN), 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), SECT_NM VARCHAR(200),
CST_TP_CD VARCHAR(30), CST_TP_CD VARCHAR(100),
ESI_CD VARCHAR(5), ESI_CD VARCHAR(5),
ESI_NUM SMALLINT, ESI_NUM SMALLINT,
LEN_M NUMERIC(8,1), LEN_M NUMERIC(8,1),

파일 보기

@ -4,7 +4,7 @@
## [Unreleased] ## [Unreleased]
## [2026-03-19.2] ## [2026-03-20]
### 추가 ### 추가
- 관리자: 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선 - 관리자: 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선
@ -16,6 +16,9 @@
- 항공 방제: 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시 - 항공 방제: 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시
- 항공 방제: 위성 요청 목록 더보기 → 페이징 처리로 변경 - 항공 방제: 위성 요청 목록 더보기 → 페이징 처리로 변경
- 사고관리: UI 개선 + 오염물 배출규정 기능 추가 - 사고관리: UI 개선 + 오염물 배출규정 기능 추가
- Pre-SCAT 해안조사 UI 개선
- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화)
- Pre-SCAT 관할서 필터링 + 해안조사 데이터 파이프라인 구축
### 수정 ### 수정
- 항공 방제: UP42 모달 지도 크기 탭별 동일하게 고정 - 항공 방제: UP42 모달 지도 크기 탭별 동일하게 고정
@ -23,18 +26,8 @@
### 변경 ### 변경
- 기상: 지역별 기상정보 패널 글자 사이즈 조정 + 시각화 개선 - 기상: 지역별 기상정보 패널 글자 사이즈 조정 + 시각화 개선
### 기타
- 기상 탭 머지 충돌 해결
## [2026-03-19]
### 추가
- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화)
- Pre-SCAT 관할서 필터링 + 해안조사 데이터 파이프라인 구축
### 변경
- SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제) - SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제)
- WeatherRightPanel 중복 코드 정리
## [2026-03-18] ## [2026-03-18]

파일 보기

@ -32,6 +32,7 @@
"maplibre-gl": "^5.19.0", "maplibre-gl": "^5.19.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-window": "^2.2.7",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zustand": "^5.0.11" "zustand": "^5.0.11"
@ -41,6 +42,7 @@
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"eslint": "^9.39.1", "eslint": "^9.39.1",
@ -2499,6 +2501,16 @@
"@types/react": "^19.2.0" "@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": { "node_modules/@types/supercluster": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
@ -5439,6 +5451,16 @@
"node": ">=0.10.0" "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

파일 보기

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

파일 보기

@ -1,10 +1,9 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import type { ScatSegment, ScatDetail } from './scatTypes'; 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 type { ApiZoneItem } from '../services/scatApi';
import ScatLeftPanel from './ScatLeftPanel'; import ScatLeftPanel from './ScatLeftPanel';
import ScatMap from './ScatMap'; import ScatMap from './ScatMap';
import ScatTimeline from './ScatTimeline';
import ScatPopup from './ScatPopup'; import ScatPopup from './ScatPopup';
import ScatRightPanel from './ScatRightPanel'; import ScatRightPanel from './ScatRightPanel';
@ -14,6 +13,8 @@ export function PreScatView() {
const [segments, setSegments] = useState<ScatSegment[]>([]); const [segments, setSegments] = useState<ScatSegment[]>([]);
const [zones, setZones] = useState<ApiZoneItem[]>([]); const [zones, setZones] = useState<ApiZoneItem[]>([]);
const [jurisdictions, setJurisdictions] = useState<string[]>([]); const [jurisdictions, setJurisdictions] = useState<string[]>([]);
const [offices, setOffices] = useState<string[]>([]);
const [selectedOffice, setSelectedOffice] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedSeg, setSelectedSeg] = useState<ScatSegment | null>(null); const [selectedSeg, setSelectedSeg] = useState<ScatSegment | null>(null);
@ -25,30 +26,34 @@ export function PreScatView() {
const [popupData, setPopupData] = useState<ScatDetail | null>(null); const [popupData, setPopupData] = useState<ScatDetail | null>(null);
const [panelDetail, setPanelDetail] = useState<ScatDetail | null>(null); const [panelDetail, setPanelDetail] = useState<ScatDetail | null>(null);
const [panelLoading, setPanelLoading] = useState(false); const [panelLoading, setPanelLoading] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [timelineIdx, setTimelineIdx] = useState(6); const [timelineIdx, setTimelineIdx] = useState(6);
// 초기 관할 목록 로딩 // 초기 관할 목록 로딩
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
async function loadInit() { async function loadInit() {
try { try {
setLoading(true); 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; if (cancelled) return;
setJurisdictions(jrsdList); setJurisdictions(jrsdList);
// 기본 관할해경: '제주' 또는 첫 항목 setJurisdictionFilter('');
const defaultJrsd = jrsdList.includes('제주') ? '제주' : jrsdList[0] || '';
const [zonesData, sectionsData] = await Promise.all([ const [zonesData, sectionsData] = await Promise.all([
fetchZones(defaultJrsd), fetchZones(undefined, defaultOffice),
fetchSections({ jurisdiction: defaultJrsd }), fetchSections({ officeCd: defaultOffice }),
]); ]);
if (cancelled) return; if (cancelled) return;
setZones(zonesData); setZones(zonesData);
setSegments(sectionsData); setSegments(sectionsData);
setJurisdictionFilter(defaultJrsd); if (sectionsData.length > 0) setSelectedSeg(sectionsData[0]);
if (sectionsData.length > 0) {
setSelectedSeg(sectionsData[0]);
}
} catch (err) { } catch (err) {
console.error('[SCAT] 데이터 로딩 오류:', err); console.error('[SCAT] 데이터 로딩 오류:', err);
if (!cancelled) setError('데이터를 불러오지 못했습니다.'); if (!cancelled) setError('데이터를 불러오지 못했습니다.');
@ -60,30 +65,58 @@ export function PreScatView() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
// 관할서 필터 변경 시 zones + sections 재로딩 // 관할청 변경 시 관할구역 + zones + sections 재로딩
useEffect(() => { useEffect(() => {
// 초기 로딩 시에는 스킵 (위의 useEffect에서 처리) if (offices.length === 0 || !selectedOffice) return;
if (jurisdictions.length === 0 || !jurisdictionFilter) return;
let cancelled = false; let cancelled = false;
async function reload() { async function reload() {
try { try {
setLoading(true); setLoading(true);
const jrsdList = await fetchJurisdictions(selectedOffice);
if (cancelled) return;
setJurisdictions(jrsdList);
setJurisdictionFilter('');
const [zonesData, sectionsData] = await Promise.all([ const [zonesData, sectionsData] = await Promise.all([
fetchZones(jurisdictionFilter), fetchZones(undefined, selectedOffice),
fetchSections({ jurisdiction: jurisdictionFilter }), fetchSections({ officeCd: selectedOffice }),
]); ]);
if (cancelled) return; if (cancelled) return;
setZones(zonesData); setZones(zonesData);
setSegments(sectionsData); setSegments(sectionsData);
setAreaFilter(''); setAreaFilter('');
if (sectionsData.length > 0) { if (sectionsData.length > 0) setSelectedSeg(sectionsData[0]);
setSelectedSeg(sectionsData[0]); else setSelectedSeg(null);
} else {
setSelectedSeg(null);
}
} catch (err) { } 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 { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
@ -121,21 +154,6 @@ export function PreScatView() {
setPopupData(null); 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) { if (error) {
return ( return (
@ -165,6 +183,9 @@ export function PreScatView() {
segments={segments} segments={segments}
zones={zones} zones={zones}
jurisdictions={jurisdictions} jurisdictions={jurisdictions}
offices={offices}
selectedOffice={selectedOffice}
onOfficeChange={setSelectedOffice}
selectedSeg={selectedSeg} selectedSeg={selectedSeg}
onSelectSeg={setSelectedSeg} onSelectSeg={setSelectedSeg}
onOpenPopup={handleOpenPopup} onOpenPopup={handleOpenPopup}
@ -189,11 +210,11 @@ export function PreScatView() {
onSelectSeg={setSelectedSeg} onSelectSeg={setSelectedSeg}
onOpenPopup={handleOpenPopup} onOpenPopup={handleOpenPopup}
/> />
<ScatTimeline {/* <ScatTimeline
segments={segments} segments={segments}
currentIdx={timelineIdx} currentIdx={timelineIdx}
onSeek={handleTimelineSeek} onSeek={handleTimelineSeek}
/> /> */}
</div> </div>
<ScatRightPanel <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 { ScatSegment } from './scatTypes';
import type { ApiZoneItem } from '../services/scatApi'; import type { ApiZoneItem } from '../services/scatApi';
import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants'; import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants';
@ -6,6 +8,9 @@ interface ScatLeftPanelProps {
segments: ScatSegment[]; segments: ScatSegment[];
zones: ApiZoneItem[]; zones: ApiZoneItem[];
jurisdictions: string[]; jurisdictions: string[];
offices: string[];
selectedOffice: string;
onOfficeChange: (v: string) => void;
selectedSeg: ScatSegment; selectedSeg: ScatSegment;
onSelectSeg: (s: ScatSegment) => void; onSelectSeg: (s: ScatSegment) => void;
onOpenPopup: (sn: number) => void; onOpenPopup: (sn: number) => void;
@ -21,16 +26,97 @@ interface ScatLeftPanelProps {
onSearchChange: (v: string) => void; 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({ function ScatLeftPanel({
segments, segments,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
zones, zones,
jurisdictions, jurisdictions,
offices,
selectedOffice,
onOfficeChange,
selectedSeg, selectedSeg,
onSelectSeg, onSelectSeg,
onOpenPopup, onOpenPopup,
jurisdictionFilter, jurisdictionFilter,
onJurisdictionChange, onJurisdictionChange,
areaFilter, areaFilter,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onAreaChange, onAreaChange,
phaseFilter, phaseFilter,
onPhaseChange, onPhaseChange,
@ -46,6 +132,21 @@ function ScatLeftPanel({
return true; 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 ( return (
<div className="w-[340px] min-w-[340px] bg-bg-1 border-r border-border flex flex-col overflow-hidden"> <div className="w-[340px] min-w-[340px] bg-bg-1 border-r border-border flex flex-col overflow-hidden">
{/* Filters */} {/* Filters */}
@ -59,18 +160,34 @@ function ScatLeftPanel({
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean"> <label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
</label> </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 <select
value={jurisdictionFilter} value={jurisdictionFilter}
onChange={(e) => onJurisdictionChange(e.target.value)} onChange={(e) => onJurisdictionChange(e.target.value)}
className="prd-i w-full" className="prd-i w-full"
> >
<option value=""></option>
{jurisdictions.map((j) => ( {jurisdictions.map((j) => (
<option key={j} value={j}>{j}</option> <option key={j} value={j}>{j}</option>
))} ))}
</select> </select>
</div> </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 className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
</label> </label>
@ -86,7 +203,7 @@ function ScatLeftPanel({
</option> </option>
))} ))}
</select> </select>
</div> </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 className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
@ -135,75 +252,20 @@ function ScatLeftPanel({
{filtered.length} {filtered.length}
</span> </span>
</div> </div>
<div className="flex-1 overflow-y-auto scrollbar-thin flex flex-col gap-1.5"> <div className="flex-1 overflow-hidden" ref={listContainerRef}>
{filtered.map((seg) => { <List<SegRowData>
const lvl = esiLevel(seg.esiNum); rowCount={filtered.length}
const borderColor = rowHeight={88}
lvl === 'h' overscanCount={5}
? 'border-l-status-red' style={{ height: listHeight }}
: lvl === 'm' rowComponent={SegRow}
? 'border-l-status-orange' rowProps={{
: 'border-l-status-green'; filtered,
const isSelected = selectedSeg.id === seg.id; selectedId: selectedSeg.id,
return ( onSelectSeg,
<div onOpenPopup,
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> </div>
</div> </div>
</div> </div>

파일 보기

@ -367,7 +367,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
</div> </div>
{/* Coordinates */} {/* 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>
<span className="text-status-green font-medium">{selectedSeg.lat.toFixed(4)}°N</span> <span className="text-status-green font-medium">{selectedSeg.lat.toFixed(4)}°N</span>
</span> </span>
@ -377,7 +377,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
<span> <span>
<span className="text-status-green font-medium">1:25,000</span> <span className="text-status-green font-medium">1:25,000</span>
</span> </span>
</div> </div> */}
</div> </div>
) )
} }

파일 보기

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

파일 보기

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

파일 보기

@ -46,56 +46,28 @@ interface WeatherRightPanelProps {
weatherData: WeatherData | null; weatherData: WeatherData | null;
} }
/* ── Local Helpers (not exported) ─────────────────────────── */ /** 풍속 등급 색상 */
function windColor(speed: number): string {
function WindCompass({ degrees }: { degrees: number }) { if (speed >= 14) return '#ef4444';
// center=28, radius=22 if (speed >= 10) return '#f97316';
return ( if (speed >= 6) return '#eab308';
<svg width="56" height="56" viewBox="0 0 56 56" className="shrink-0"> return '#22c55e';
{/* 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 ProgressBar({ value, max, gradient, label }: { value: number; max: number; gradient: string; label: string }) { /** 파고 등급 색상 */
const pct = Math.min(100, (value / max) * 100); function waveColor(height: number): string {
return ( if (height >= 3) return '#ef4444';
<div className="mt-3 flex items-center gap-2"> if (height >= 2) return '#f97316';
<div className="h-1.5 flex-1 rounded-full bg-bg-2 overflow-hidden"> if (height >= 1) return '#eab308';
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: gradient }} /> return '#22c55e';
</div>
<span className="text-[10px] text-text-3 shrink-0">{label}</span>
</div>
);
} }
function StatCard({ value, label, valueClass = 'text-primary-cyan' }: { value: string; label: string; valueClass?: string }) { /** 풍향 텍스트 */
return ( function windDirText(deg: number): string {
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-0.5"> const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
<span className={`text-sm font-bold font-mono ${valueClass}`}>{value}</span> return dirs[Math.round(deg / 22.5) % 16];
<span className="text-[10px] text-text-3">{label}</span>
</div>
);
} }
/* ── Main Component ───────────────────────────────────────── */
export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
if (!weatherData) { if (!weatherData) {
return ( return (
@ -107,170 +79,190 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
); );
} }
const { const { wind, wave, temperature, pressure, visibility, salinity, astronomy, alert, forecast } = weatherData;
wind, wave, temperature, pressure, visibility, const wSpd = wind.speed;
salinity, astronomy, alert, forecast, const wHgt = wave.height;
} = weatherData; const wTemp = temperature.current;
const windDir = windDirText(wind.direction);
return ( 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">
{/* ── Header ─────────────────────────────────────────── */} {/* 헤더 */}
<div className="px-5 py-3 border-b border-border"> <div className="px-4 py-3 border-b border-border bg-bg-2">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="text-primary-cyan text-sm font-semibold">📍 {weatherData.stationName}</span> <span className="text-[13px] font-bold text-primary-cyan font-korean">📍 {weatherData.stationName}</span>
<span className="px-2 py-0.5 text-[10px] rounded bg-primary-cyan/20 text-primary-cyan font-semibold"> <span className="px-1.5 py-px text-[11px] rounded bg-primary-cyan/15 text-primary-cyan font-bold"></span>
</span>
</div> </div>
<p className="text-[11px] text-text-3 font-mono"> <p className="text-[11px] text-text-3 font-mono">
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E · {weatherData.currentTime} {weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E · {weatherData.currentTime}
</p> </p>
</div> </div>
{/* ── Scrollable Content ─────────────────────────────── */} {/* 스크롤 콘텐츠 */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
{/* ── Summary Cards ──────────────────────────────── */}
<div className="px-5 py-3 border-b border-border"> {/* ── 핵심 지표 3칸 카드 ── */}
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-1 px-3 py-2.5">
<div className="bg-bg-2 border border-border rounded-lg p-3 flex flex-col items-center"> <div className="text-center py-2.5 bg-bg-0 border border-border rounded-md">
<span className="text-[22px] font-bold text-primary-cyan font-mono leading-none"> <div className="text-[20px] font-bold font-mono" style={{ color: windColor(wSpd) }}>{wSpd.toFixed(1)}</div>
{wind.speed.toFixed(1)} <div className="text-[11px] text-text-3 font-korean"> (m/s)</div>
</span> </div>
<span className="text-[12px] text-text-3 mt-1"> (m/s)</span> <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>
<div className="bg-bg-2 border border-border rounded-lg p-3 flex flex-col items-center"> <div className="flex-1 grid grid-cols-2 gap-x-3 gap-y-1.5 text-[11px]">
<span className="text-[22px] font-bold text-primary-cyan font-mono leading-none"> <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>
{wave.height.toFixed(1)} <div className="flex justify-between"><span className="text-text-3"></span><span className="text-text-1 font-mono text-[13px]">{pressure} hPa</span></div>
</span> <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>
<span className="text-[12px] text-text-3 mt-1"> (m)</span> <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>
<div className="bg-bg-2 border border-border rounded-lg p-3 flex flex-col items-center"> </div>
<span className="text-[22px] font-bold text-primary-cyan font-mono leading-none"> {/* 풍속 게이지 바 */}
{temperature.current.toFixed(1)} <div className="flex items-center gap-2">
</span> <div className="flex-1 h-[5px] bg-bg-3 rounded-full overflow-hidden">
<span className="text-[12px] text-text-3 mt-1"> (°C)</span> <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>
</div> </div>
{/* ── 바람 현황 ──────────────────────────────────── */} {/* ── 시간별 예보 ── */}
<div className="px-5 py-3 border-b border-border"> <div className="px-3 py-2 border-b border-border">
<div className="text-text-3 mb-3">🏳 </div> <div className="text-[11px] font-bold text-text-3 font-korean mb-2"> </div>
<div className="flex gap-4 items-start"> <div className="grid grid-cols-5 gap-1">
<WindCompass degrees={wind.direction} /> {forecast.map((f, i) => (
<div className="flex-1 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[13px]"> <div key={i} className="flex flex-col items-center py-2 px-1 bg-bg-0 border border-border rounded">
<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="text-[11px] text-text-3 mb-0.5">{f.hour}</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="text-lg mb-0.5">{f.icon}</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="text-[13px] font-bold text-text-1">{f.temperature}°</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="text-[11px] text-text-3 font-mono">{f.windSpeed}</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> </div>
))} ))}
</div> </div>
</div> </div>
{/* ── 천문 · 조석 ────────────────────────────────── */} {/* ── 천문/조석 ── */}
<div className="px-5 py-3 border-b border-border"> {astronomy && (
<div className="text-[11px] text-text-3 mb-3"> · </div> <div className="px-3 py-2 border-b border-border">
{astronomy && ( <div className="text-[11px] font-bold text-text-3 font-korean mb-2"> · </div>
<> <div className="grid grid-cols-4 gap-1">
<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"> { icon: '🌅', label: '일출', value: astronomy.sunrise },
<span className="text-base">🌅</span> { icon: '🌄', label: '일몰', value: astronomy.sunset },
<span className="text-[9px] text-text-3"></span> { icon: '🌙', label: '월출', value: astronomy.moonrise },
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.sunrise}</span> { 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>
<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> </div>
<span className="text-[9px] text-text-3"></span> <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-xs font-bold text-text-1 font-mono">{astronomy.sunset}</span> <span className="text-sm">🌓</span>
</div> <span className="text-text-3">{astronomy.moonPhase}</span>
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1"> <span className="ml-auto text-text-1 font-mono"> {astronomy.tidalRange}m</span>
<span className="text-base">🌙</span> </div>
<span className="text-[9px] text-text-3"></span> </div>
<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>
{/* ── 날씨 특보 ──────────────────────────────────── */} {/* ── 날씨 특보 ── */}
{alert && ( {alert && (
<div className="px-5 py-3"> <div className="px-3 py-2">
<div className="text-[11px] text-text-3 mb-3">🚨 </div> <div className="text-[11px] font-bold text-text-3 font-korean mb-2">🚨 </div>
<div className="p-3 bg-status-red/10 border border-status-red/30 rounded-md"> <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"> <div className="flex items-center gap-2 text-[11px]">
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-status-red text-white"></span> <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 text-xs">{alert}</span> <span className="text-text-1 font-korean">{alert}</span>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
); );