Merge pull request 'release: 2026-03-20 (124건 커밋)' (#108) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 34s
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 34s
This commit is contained in:
커밋
5f622c7520
@ -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),
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-19.2]
|
||||
## [2026-03-20]
|
||||
|
||||
### 추가
|
||||
- 관리자: 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선
|
||||
@ -16,6 +16,9 @@
|
||||
- 항공 방제: 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시
|
||||
- 항공 방제: 위성 요청 목록 더보기 → 페이징 처리로 변경
|
||||
- 사고관리: UI 개선 + 오염물 배출규정 기능 추가
|
||||
- Pre-SCAT 해안조사 UI 개선
|
||||
- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화)
|
||||
- Pre-SCAT 관할서 필터링 + 해안조사 데이터 파이프라인 구축
|
||||
|
||||
### 수정
|
||||
- 항공 방제: UP42 모달 지도 크기 탭별 동일하게 고정
|
||||
@ -23,18 +26,8 @@
|
||||
|
||||
### 변경
|
||||
- 기상: 지역별 기상정보 패널 글자 사이즈 조정 + 시각화 개선
|
||||
|
||||
### 기타
|
||||
- 기상 탭 머지 충돌 해결
|
||||
|
||||
## [2026-03-19]
|
||||
|
||||
### 추가
|
||||
- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화)
|
||||
- Pre-SCAT 관할서 필터링 + 해안조사 데이터 파이프라인 구축
|
||||
|
||||
### 변경
|
||||
- SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제)
|
||||
- WeatherRightPanel 중복 코드 정리
|
||||
|
||||
## [2026-03-18]
|
||||
|
||||
|
||||
22
frontend/package-lock.json
generated
22
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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);
|
||||
<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,
|
||||
}}
|
||||
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>
|
||||
|
||||
@ -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,56 +46,28 @@ 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 (
|
||||
@ -107,170 +79,190 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
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="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>
|
||||
<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 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-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 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="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 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>
|
||||
<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 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-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-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-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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-text-3">
|
||||
조차 <span className="ml-2 text-text-1 font-semibold font-mono">{astronomy.tidalRange}m</span>
|
||||
<span className="ml-auto text-text-1 font-mono">조차 {astronomy.tidalRange}m</span>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user