From 9881b99ee731f453baba8499b9c61d814d9924af Mon Sep 17 00:00:00 2001 From: leedano Date: Fri, 20 Mar 2026 10:22:20 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(scat):=20Pre-SCAT=20=ED=95=B4=EC=95=88?= =?UTF-8?q?=EC=A1=B0=EC=82=AC=20UI=20=EA=B0=9C=EC=84=A0=20+=20WeatherRight?= =?UTF-8?q?Panel=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCAT 좌측패널 리팩토링, 해안조사 뷰 기능 보강, 기상 우측패널 중복 코드 제거 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/scat/scatRouter.ts | 29 +- backend/src/scat/scatService.ts | 38 ++- database/migration/011_scat.sql | 5 +- frontend/package-lock.json | 22 ++ frontend/package.json | 2 + .../src/tabs/scat/components/PreScatView.tsx | 103 +++--- .../tabs/scat/components/ScatLeftPanel.tsx | 204 ++++++++---- frontend/src/tabs/scat/components/ScatMap.tsx | 4 +- .../tabs/scat/components/ScatRightPanel.tsx | 5 +- frontend/src/tabs/scat/services/scatApi.ts | 22 +- .../weather/components/WeatherRightPanel.tsx | 309 ++++-------------- 11 files changed, 365 insertions(+), 378 deletions(-) diff --git a/backend/src/scat/scatRouter.ts b/backend/src/scat/scatRouter.ts index ef35df1..f6abc5d 100644 --- a/backend/src/scat/scatRouter.ts +++ b/backend/src/scat/scatRouter.ts @@ -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); diff --git a/backend/src/scat/scatService.ts b/backend/src/scat/scatService.ts index 0e8d1b4..467bcc6 100644 --- a/backend/src/scat/scatService.ts +++ b/backend/src/scat/scatService.ts @@ -60,18 +60,40 @@ interface SectionDetail { notes: string[]; } +// ============================================================ +// 관할청 목록 조회 +// ============================================================ + +export async function listOffices(): Promise { + 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) => r.office_cd as string); +} + // ============================================================ // 관할서 목록 조회 // ============================================================ -export async function listJurisdictions(): Promise { +export async function listJurisdictions(officeCd?: string): Promise { + 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) => r.jrsd_nm as string); } @@ -81,6 +103,7 @@ export async function listJurisdictions(): Promise { export async function listZones(filters?: { jurisdiction?: string; + officeCd?: string; }): Promise { 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 { 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'"); diff --git a/database/migration/011_scat.sql b/database/migration/011_scat.sql index e67f29c..4bedb7e 100644 --- a/database/migration/011_scat.sql +++ b/database/migration/011_scat.sql @@ -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), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d2534ec..5c8fe25 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 14c3f0d..a1db360 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/tabs/scat/components/PreScatView.tsx b/frontend/src/tabs/scat/components/PreScatView.tsx index b22ee2c..0ad967d 100755 --- a/frontend/src/tabs/scat/components/PreScatView.tsx +++ b/frontend/src/tabs/scat/components/PreScatView.tsx @@ -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([]); const [zones, setZones] = useState([]); const [jurisdictions, setJurisdictions] = useState([]); + const [offices, setOffices] = useState([]); + const [selectedOffice, setSelectedOffice] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedSeg, setSelectedSeg] = useState(null); @@ -25,30 +26,34 @@ export function PreScatView() { const [popupData, setPopupData] = useState(null); const [panelDetail, setPanelDetail] = useState(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} /> - + /> */} 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 ( +
+
{ + 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' + }`} + > +
+ + 📍 {seg.code} {seg.area} + + + ESI {seg.esi} + +
+
+
+ 유형 + {seg.type} +
+
+ 길이 + {seg.length} +
+
+ 민감 + + {seg.sensitivity} + +
+
+ 현황 + + {seg.status} + +
+
+
+
+ ); +} + 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(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 (
{/* Filters */} @@ -59,18 +160,34 @@ function ScatLeftPanel({ + +
+ +
+
-
+ {/*
@@ -86,7 +203,7 @@ function ScatLeftPanel({ ))} -
+
*/}
-
- {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 ( -
{ - 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' - }`} - > -
- - 📍 {seg.code} {seg.area} - - - ESI {seg.esi} - -
-
-
- 유형 - - {seg.type} - -
-
- 길이 - - {seg.length} - -
-
- 민감 - - {seg.sensitivity} - -
-
- 현황 - - {seg.status} - -
-
-
- ); - })} +
+ + rowCount={filtered.length} + rowHeight={88} + overscanCount={5} + style={{ height: listHeight }} + rowComponent={SegRow} + rowProps={{ + filtered, + selectedId: selectedSeg.id, + onSelectSeg, + onOpenPopup, + }} + />
diff --git a/frontend/src/tabs/scat/components/ScatMap.tsx b/frontend/src/tabs/scat/components/ScatMap.tsx index 8f1463a..81eeb37 100644 --- a/frontend/src/tabs/scat/components/ScatMap.tsx +++ b/frontend/src/tabs/scat/components/ScatMap.tsx @@ -367,7 +367,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg {/* Coordinates */} -
+ {/*
위도 {selectedSeg.lat.toFixed(4)}°N @@ -377,7 +377,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg 축척 1:25,000 -
+
*/} ) } diff --git a/frontend/src/tabs/scat/components/ScatRightPanel.tsx b/frontend/src/tabs/scat/components/ScatRightPanel.tsx index 6275676..9ef47eb 100644 --- a/frontend/src/tabs/scat/components/ScatRightPanel.tsx +++ b/frontend/src/tabs/scat/components/ScatRightPanel.tsx @@ -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 {/* 하단 버튼 */} -
+ {/*
-
+
*/} ); } diff --git a/frontend/src/tabs/scat/services/scatApi.ts b/frontend/src/tabs/scat/services/scatApi.ts index a7e6f50..407783e 100644 --- a/frontend/src/tabs/scat/services/scatApi.ts +++ b/frontend/src/tabs/scat/services/scatApi.ts @@ -102,14 +102,24 @@ function toScatDetail(item: ApiSectionDetail): ScatDetail { // API 호출 함수 // ============================================================ -export async function fetchJurisdictions(): Promise { - const { data } = await api.get('/scat/jurisdictions'); +export async function fetchOffices(): Promise { + const { data } = await api.get('/scat/offices'); return data; } -export async function fetchZones(jurisdiction?: string): Promise { - const params = jurisdiction ? `?jurisdiction=${encodeURIComponent(jurisdiction)}` : ''; - const { data } = await api.get(`/scat/zones${params}`); +export async function fetchJurisdictions(officeCd?: string): Promise { + const params = officeCd ? `?officeCd=${encodeURIComponent(officeCd)}` : ''; + const { data } = await api.get(`/scat/jurisdictions${params}`); + return data; +} + +export async function fetchZones(jurisdiction?: string, officeCd?: string): Promise { + 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(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 { @@ -128,6 +139,7 @@ export async function fetchSections(filters?: SectionFilters): Promise - {/* arcs connecting N→E→S→W→N */} - - - - - {/* cardinal labels — same color as 풍향/기압 text (#edf0f7 = text-1) */} - N - S - E - W - {/* clock-hand needle */} - - - - - {/* center dot */} - - - ); -} - -function ProgressBar({ value, max, gradient, label }: { value: number; max: number; gradient: string; label: string }) { - const pct = Math.min(100, (value / max) * 100); - return ( -
-
-
-
- {label} -
- ); -} - -function StatCard({ value, label, valueClass = 'text-primary-cyan' }: { value: string; label: string; valueClass?: string }) { - return ( -
- {value} - {label} -
- ); -} - -/* ── Main Component ───────────────────────────────────────── */ - /** 풍속 등급 색상 */ function windColor(speed: number): string { - if (speed >= 14) return '#ef4444' - if (speed >= 10) return '#f97316' - if (speed >= 6) return '#eab308' - return '#22c55e' + if (speed >= 14) return '#ef4444'; + if (speed >= 10) return '#f97316'; + if (speed >= 6) return '#eab308'; + return '#22c55e'; } /** 파고 등급 색상 */ function waveColor(height: number): string { - if (height >= 3) return '#ef4444' - if (height >= 2) return '#f97316' - if (height >= 1) return '#eab308' - return '#22c55e' + if (height >= 3) return '#ef4444'; + if (height >= 2) return '#f97316'; + if (height >= 1) return '#eab308'; + return '#22c55e'; } /** 풍향 텍스트 */ 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] + 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]; } export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { @@ -129,20 +79,13 @@ 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 ( -
- {/* ── Header ─────────────────────────────────────────── */} -
-
- 📍 {weatherData.stationName} - - 기상예보관 -
{/* 헤더 */}
@@ -150,29 +93,11 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { 📍 {weatherData.stationName} 기상예보관
-

- {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}

- {/* ── Scrollable Content ─────────────────────────────── */} -
- {/* ── Summary Cards ──────────────────────────────── */} -
-
-
- - {wind.speed.toFixed(1)} - - 풍속 (m/s) -
-
- - {wave.height.toFixed(1)} - - 파고 (m) {/* 스크롤 콘텐츠 */}
@@ -202,33 +127,28 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { {['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 {d} + 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 {d}; })} {/* 풍향 화살표 */}
-
- - {temperature.current.toFixed(1)} - - 수온 (°C)
-
풍향{windDir} {weatherData.wind.direction}°
-
기압{weatherData.pressure} hPa
-
1k 최고{Number(weatherData.wind.speed_1k).toFixed(1)}
-
3k 평균{Number(weatherData.wind.speed_3k).toFixed(1)}
-
가시거리{weatherData.visibility} km
+
풍향{windDir} {wind.direction}°
+
기압{pressure} hPa
+
1k 최고{Number(wind.speed_1k).toFixed(1)}
+
3k 평균{Number(wind.speed_3k).toFixed(1)}
+
가시거리{visibility} km
{/* 풍속 게이지 바 */} @@ -240,17 +160,6 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
- {/* ── 바람 현황 ──────────────────────────────────── */} -
-
🏳️ 바람 현황
-
- -
- 풍향{wind.directionLabel} {wind.direction}° - 기압{pressure} hPa - 1k 최고{wind.speed_1k.toFixed(1)} - 3k 평균{wind.speed_3k.toFixed(1)} - 가시거리{visibility} km {/* ── 파도 상세 ── */}
🌊 파도
@@ -260,15 +169,15 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
유의파고
-
{(wHgt * 1.6).toFixed(1)}m
+
{wave.maxHeight.toFixed(1)}m
최고파고
-
{weatherData.wave.period}s
+
{wave.period}s
주기
-
NW
+
{wave.direction}
파향
@@ -279,38 +188,8 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{wHgt.toFixed(1)}/5m
-
- {/* ── 파도 ───────────────────────────────────────── */} -
-
🌊 파도
-
- - - - -
- -
- - {/* ── 수온 · 공기 ────────────────────────────────── */} -
-
💧 수온 · 공기
-
- - - {/* ── 수온/공기 ── */}
🌡️ 수온 · 공기
@@ -320,34 +199,21 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
수온
-
2.1°
+
{temperature.feelsLike.toFixed(1)}°
기온
-
31.2
+
{salinity.toFixed(1)}
염분(PSU)
- {/* ── 시간별 예보 ────────────────────────────────── */} -
-
🕐 시간별 예보
-
- {forecast.map((fc, i) => ( -
- {fc.hour} - {fc.icon} - {fc.temperature}° - {fc.windSpeed} {/* ── 시간별 예보 ── */}
⏰ 시간별 예보
- {weatherData.forecast.map((f, i) => ( + {forecast.map((f, i) => (
{f.hour} {f.icon} @@ -358,91 +224,44 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
- {/* ── 천문 · 조석 ────────────────────────────────── */} -
-
☀️ 천문 · 조석
- {astronomy && ( - <> -
-
- 🌅 - 일출 - {astronomy.sunrise} -
-
- 🌇 - 일몰 - {astronomy.sunset} -
-
- 🌙 - 월출 - {astronomy.moonrise} -
-
- 🌜 - 월몰 - {astronomy.moonset} -
-
-
-
- 🌓 - {astronomy.moonPhase} -
-
- 조차 {astronomy.tidalRange}m -
-
- - )} -
- - {/* ── 날씨 특보 ──────────────────────────────────── */} - {alert && ( -
-
🚨 날씨 특보
-
-
- 주의 - {alert} -
-
{/* ── 천문/조석 ── */} -
-
☀️ 천문 · 조석
-
- {[ - { icon: '🌅', label: '일출', value: sunriseTime }, - { icon: '🌄', label: '일몰', value: sunsetTime }, - { icon: '🌙', label: '월출', value: moonrise }, - { icon: '🌜', label: '월몰', value: moonset }, - ].map((item, i) => ( -
-
{item.icon}
-
{item.label}
-
{item.value}
-
- ))} -
-
- 🌓 - 상현달 14일 - 조차 6.7m + {astronomy && ( +
+
☀️ 천문 · 조석
+
+ {[ + { icon: '🌅', label: '일출', value: astronomy.sunrise }, + { icon: '🌄', label: '일몰', value: astronomy.sunset }, + { icon: '🌙', label: '월출', value: astronomy.moonrise }, + { icon: '🌜', label: '월몰', value: astronomy.moonset }, + ].map((item, i) => ( +
+
{item.icon}
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ 🌓 + {astronomy.moonPhase} + 조차 {astronomy.tidalRange}m +
)} -
{/* ── 날씨 특보 ── */} -
-
🚨 날씨 특보
-
-
- 주의 - 풍랑주의보 예상 08:00~ + {alert && ( +
+
🚨 날씨 특보
+
+
+ 주의 + {alert} +
-
+ )}
From 503b9a1d3c325b7017fdb1cf35da33bda9f37e97 Mon Sep 17 00:00:00 2001 From: leedano Date: Fri, 20 Mar 2026 10:26:49 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 4c44272..4d3c4f5 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,25 @@ ## [Unreleased] +### 추가 +- 항공 방제: WingAI (AI 탐지/분석) 서브탭 추가 +- 항공 방제: UP42 위성 패스 조회 + 궤도 지도 표시 +- 항공 방제: 위성 요청 취소 기능 +- 항공 방제: 위성 히스토리 지도에 캘린더 + 날짜별 촬영 리스트 + 영상 오버레이 +- 항공 방제: 위성 요청 목록/히스토리 지도 탭 분리 +- 항공 방제: 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시 +- 사건/사고: 오염물 배출규정 기능 추가 +- Pre-SCAT 해안조사 UI 개선 + +### 수정 +- 항공 방제: UP42 모달 지도 크기 탭별 동일하게 고정 +- 항공 방제: 촬영 히스토리 지도 리스트 위치 좌하단 이동 + +### 변경 +- 기상 패널 레이어 체크박스/글자 사이즈 조정 +- 위성 요청 목록 더보기 → 페이징 처리로 변경 +- WeatherRightPanel 중복 코드 정리 + ## [2026-03-19] ### 추가 From 4d22916ae19f0fc1c5ad0305c53e9f83ec1582cf Mon Sep 17 00:00:00 2001 From: leedano Date: Fri, 20 Mar 2026 10:36:23 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 46d21af..5809243 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,7 +4,7 @@ ## [Unreleased] -## [2026-03-19.2] +## [2026-03-20] ### 추가 - 관리자: 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선 @@ -16,7 +16,9 @@ - 항공 방제: 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시 - 항공 방제: 위성 요청 목록 더보기 → 페이징 처리로 변경 - 사고관리: UI 개선 + 오염물 배출규정 기능 추가 -- Pre-SCAT 해안조사 UI 개선 + WeatherRightPanel 정리 +- Pre-SCAT 해안조사 UI 개선 +- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화) +- Pre-SCAT 관할서 필터링 + 해안조사 데이터 파이프라인 구축 ### 수정 - 항공 방제: UP42 모달 지도 크기 탭별 동일하게 고정 @@ -24,18 +26,8 @@ ### 변경 - 기상: 지역별 기상정보 패널 글자 사이즈 조정 + 시각화 개선 - -### 기타 -- 기상 탭 머지 충돌 해결 - -## [2026-03-19] - -### 추가 -- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화) -- Pre-SCAT 관할서 필터링 + 해안조사 데이터 파이프라인 구축 - -### 변경 - SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제) +- WeatherRightPanel 중복 코드 정리 ## [2026-03-18]