From 15ca946a007436e2d9539894ce53b30041884056 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Tue, 14 Apr 2026 17:11:38 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(prediction):=20GSC=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=EC=82=AC=EA=B3=A0=20=EB=AA=A9=EB=A1=9D=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=85=80=EB=A0=89=ED=8A=B8?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20=EC=9E=90=EB=8F=99=20=EC=B1=84=EC=9B=80=20?= =?UTF-8?q?(prediction/hns/rescue)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/gsc/gscAccidentsRouter.ts | 20 +++++ backend/src/gsc/gscAccidentsService.ts | 65 ++++++++++++++++ backend/src/server.ts | 2 + .../src/tabs/hns/components/HNSLeftPanel.tsx | 33 ++++---- frontend/src/tabs/hns/components/HNSView.tsx | 4 + .../tabs/prediction/components/LeftPanel.tsx | 2 + .../prediction/components/OilSpillView.tsx | 3 + .../components/PredictionInputSection.tsx | 47 +++++++++++- .../prediction/components/leftPanelTypes.ts | 2 + .../tabs/prediction/services/predictionApi.ts | 17 +++++ .../src/tabs/rescue/components/RescueView.tsx | 76 +++++++++---------- 11 files changed, 217 insertions(+), 54 deletions(-) create mode 100644 backend/src/gsc/gscAccidentsRouter.ts create mode 100644 backend/src/gsc/gscAccidentsService.ts diff --git a/backend/src/gsc/gscAccidentsRouter.ts b/backend/src/gsc/gscAccidentsRouter.ts new file mode 100644 index 0000000..4ba3cbb --- /dev/null +++ b/backend/src/gsc/gscAccidentsRouter.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { requireAuth } from '../auth/authMiddleware.js'; +import { listGscAccidents } from './gscAccidentsService.js'; + +const router = Router(); + +// ============================================================ +// GET /api/gsc/accidents — 외부 수집 사고 목록 (최신 20건) +// ============================================================ +router.get('/', requireAuth, async (_req, res) => { + try { + const accidents = await listGscAccidents(20); + res.json(accidents); + } catch (err) { + console.error('[gsc] 사고 목록 조회 오류:', err); + res.status(500).json({ error: '사고 목록 조회 중 오류가 발생했습니다.' }); + } +}); + +export default router; diff --git a/backend/src/gsc/gscAccidentsService.ts b/backend/src/gsc/gscAccidentsService.ts new file mode 100644 index 0000000..69fbff0 --- /dev/null +++ b/backend/src/gsc/gscAccidentsService.ts @@ -0,0 +1,65 @@ +import { wingPool } from '../db/wingDb.js'; + +export interface GscAccidentListItem { + acdntMngNo: string; + pollNm: string; + pollDate: string | null; + lat: number | null; + lon: number | null; +} + +const ACDNT_ASORT_CODES = [ + '055001001', + '055001002', + '055001003', + '055001004', + '055001005', + '055001006', + '055003001', + '055003002', + '055003003', + '055003004', + '055003005', + '055004003', +]; + +export async function listGscAccidents(limit = 20): Promise { + const sql = ` + SELECT DISTINCT ON (a.acdnt_mng_no) + a.acdnt_mng_no AS "acdntMngNo", + a.acdnt_title AS "pollNm", + to_char(a.rcept_dt, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate", + a.rcept_dt AS "rceptDt", + b.la AS "lat", + b.lo AS "lon" + FROM gsc.tgs_acdnt_info AS a + LEFT JOIN gsc.tgs_acdnt_lc AS b + ON a.acdnt_mng_no = b.acdnt_mng_no + WHERE a.acdnt_asort_code = ANY($1::varchar[]) + AND a.acdnt_title IS NOT NULL + ORDER BY a.acdnt_mng_no, b.acdnt_lc_sn ASC + `; + + const orderedSql = ` + SELECT "acdntMngNo", "pollNm", "pollDate", "lat", "lon" + FROM (${sql}) t + ORDER BY t."rceptDt" DESC NULLS LAST + LIMIT $2 + `; + + const result = await wingPool.query<{ + acdntMngNo: string; + pollNm: string; + pollDate: string | null; + lat: string | null; + lon: string | null; + }>(orderedSql, [ACDNT_ASORT_CODES, limit]); + + return result.rows.map((row) => ({ + acdntMngNo: row.acdntMngNo, + pollNm: row.pollNm, + pollDate: row.pollDate, + lat: row.lat != null ? Number(row.lat) : null, + lon: row.lon != null ? Number(row.lon) : null, + })); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index a577c9d..09999dc 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -19,6 +19,7 @@ import hnsRouter from './hns/hnsRouter.js' import reportsRouter from './reports/reportsRouter.js' import assetsRouter from './assets/assetsRouter.js' import incidentsRouter from './incidents/incidentsRouter.js' +import gscAccidentsRouter from './gsc/gscAccidentsRouter.js' import scatRouter from './scat/scatRouter.js' import predictionRouter from './prediction/predictionRouter.js' import aerialRouter from './aerial/aerialRouter.js' @@ -168,6 +169,7 @@ app.use('/api/hns', hnsRouter) app.use('/api/reports', reportsRouter) app.use('/api/assets', assetsRouter) app.use('/api/incidents', incidentsRouter) +app.use('/api/gsc/accidents', gscAccidentsRouter) app.use('/api/scat', scatRouter) app.use('/api/prediction', predictionRouter) app.use('/api/aerial', aerialRouter) diff --git a/frontend/src/tabs/hns/components/HNSLeftPanel.tsx b/frontend/src/tabs/hns/components/HNSLeftPanel.tsx index ed939ac..7822dd8 100755 --- a/frontend/src/tabs/hns/components/HNSLeftPanel.tsx +++ b/frontend/src/tabs/hns/components/HNSLeftPanel.tsx @@ -3,8 +3,8 @@ import { ComboBox } from '@common/components/ui/ComboBox'; import { useWeatherFetch } from '../hooks/useWeatherFetch'; import { getSubstanceToxicity } from '../utils/toxicityData'; import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes'; -import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi'; -import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi'; +import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi'; +import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi'; /** HNS 분석 입력 파라미터 (부모에 전달) */ export interface HNSInputParams { @@ -44,6 +44,7 @@ interface HNSLeftPanelProps { onParamsChange?: (params: HNSInputParams) => void; onReset?: () => void; loadedParams?: Partial | null; + onFlyToCoord?: (coord: { lon: number; lat: number }) => void; } /** 십진 좌표 → 도분초 변환 */ @@ -67,8 +68,9 @@ export function HNSLeftPanel({ onParamsChange, onReset, loadedParams, + onFlyToCoord, }: HNSLeftPanelProps) { - const [incidents, setIncidents] = useState([]); + const [incidents, setIncidents] = useState([]); const [selectedIncidentSn, setSelectedIncidentSn] = useState(''); const [expandedSections, setExpandedSections] = useState({ accident: true, params: true }); const toggleSection = (key: 'accident' | 'params') => @@ -138,21 +140,26 @@ export function HNSLeftPanel({ // 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴) const incidentsPromiseRef = useRef | null>(null); if (incidentsPromiseRef.current == null) { - incidentsPromiseRef.current = fetchIncidentsRaw() + incidentsPromiseRef.current = fetchGscAccidents() .then((data) => setIncidents(data)) .catch(() => setIncidents([])); } // 사고 선택 시 필드 자동 채움 - const handleSelectIncident = (snStr: string) => { - setSelectedIncidentSn(snStr); - const sn = parseInt(snStr); - const incident = incidents.find((i) => i.acdntSn === sn); + const handleSelectIncident = (mngNo: string) => { + setSelectedIncidentSn(mngNo); + const incident = incidents.find((i) => i.acdntMngNo === mngNo); if (!incident) return; - setAccidentName(incident.acdntNm); - if (incident.lat && incident.lng) { - onCoordChange({ lat: incident.lat, lon: incident.lng }); + setAccidentName(incident.pollNm); + if (incident.pollDate) { + const [d, t] = incident.pollDate.split('T'); + if (d) setAccidentDate(d); + if (t) setAccidentTime(t); + } + if (incident.lat != null && incident.lon != null) { + onCoordChange({ lat: incident.lat, lon: incident.lon }); + onFlyToCoord?.({ lat: incident.lat, lon: incident.lon }); } }; @@ -266,8 +273,8 @@ export function HNSLeftPanel({ onChange={handleSelectIncident} placeholder="또는 사고 리스트에서 선택" options={incidents.map((inc) => ({ - value: String(inc.acdntSn), - label: `${inc.acdntNm} (${new Date(inc.occrnDtm).toLocaleDateString('ko-KR')})`, + value: inc.acdntMngNo, + label: `${inc.pollNm} (${inc.pollDate ? inc.pollDate.replace('T', ' ') : '-'})`, }))} /> diff --git a/frontend/src/tabs/hns/components/HNSView.tsx b/frontend/src/tabs/hns/components/HNSView.tsx index 4a49115..4fc86e4 100755 --- a/frontend/src/tabs/hns/components/HNSView.tsx +++ b/frontend/src/tabs/hns/components/HNSView.tsx @@ -265,6 +265,7 @@ export function HNSView() { const [leftCollapsed, setLeftCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(false); const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null); + const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [isRunningPrediction, setIsRunningPrediction] = useState(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -904,6 +905,7 @@ export function HNSView() { onParamsChange={handleParamsChange} onReset={handleReset} loadedParams={loadedParams} + onFlyToCoord={(c) => setFlyToCoord({ lat: c.lat, lon: c.lon })} /> )} @@ -963,6 +965,8 @@ export function HNSView() { <> setFlyToCoord(undefined)} isSelectingLocation={isSelectingLocation} onMapClick={handleMapClick} oilTrajectory={[]} diff --git a/frontend/src/tabs/prediction/components/LeftPanel.tsx b/frontend/src/tabs/prediction/components/LeftPanel.tsx index 4edb0b6..4d0a1cb 100755 --- a/frontend/src/tabs/prediction/components/LeftPanel.tsx +++ b/frontend/src/tabs/prediction/components/LeftPanel.tsx @@ -112,6 +112,7 @@ export function LeftPanel({ onLayerColorChange, sensitiveResources = [], onImageAnalysisResult, + onFlyToCoord, validationErrors, }: LeftPanelProps) { const [expandedSections, setExpandedSections] = useState({ @@ -166,6 +167,7 @@ export function LeftPanel({ spillUnit={spillUnit} onSpillUnitChange={onSpillUnitChange} onImageAnalysisResult={onImageAnalysisResult} + onFlyToCoord={onFlyToCoord} validationErrors={validationErrors} /> diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index f276a0b..3909742 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -1208,6 +1208,9 @@ export function OilSpillView() { onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))} sensitiveResources={sensitiveResourceCategories} onImageAnalysisResult={handleImageAnalysisResult} + onFlyToCoord={(c: { lon: number; lat: number }) => + setFlyToCoord({ lat: c.lat, lon: c.lon }) + } validationErrors={validationErrors} /> diff --git a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx index 68461ad..9366e18 100644 --- a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx +++ b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx @@ -1,8 +1,8 @@ import { useState, useRef, useEffect } from 'react'; import { ComboBox } from '@common/components/ui/ComboBox'; import type { PredictionModel } from './OilSpillView'; -import { analyzeImage } from '../services/predictionApi'; -import type { ImageAnalyzeResult } from '../services/predictionApi'; +import { analyzeImage, fetchGscAccidents } from '../services/predictionApi'; +import type { ImageAnalyzeResult, GscAccidentListItem } from '../services/predictionApi'; interface PredictionInputSectionProps { expanded: boolean; @@ -33,6 +33,7 @@ interface PredictionInputSectionProps { spillUnit: string; onSpillUnitChange: (unit: string) => void; onImageAnalysisResult?: (result: ImageAnalyzeResult) => void; + onFlyToCoord?: (coord: { lon: number; lat: number }) => void; validationErrors?: Set; } @@ -64,6 +65,7 @@ const PredictionInputSection = ({ spillUnit, onSpillUnitChange, onImageAnalysisResult, + onFlyToCoord, validationErrors, }: PredictionInputSectionProps) => { const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct'); @@ -71,8 +73,41 @@ const PredictionInputSection = ({ const [isAnalyzing, setIsAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); const [analyzeResult, setAnalyzeResult] = useState(null); + const [gscAccidents, setGscAccidents] = useState([]); + const [selectedGscMngNo, setSelectedGscMngNo] = useState(''); const fileInputRef = useRef(null); + useEffect(() => { + let cancelled = false; + fetchGscAccidents() + .then((list) => { + if (!cancelled) setGscAccidents(list); + }) + .catch((err) => { + console.error('[prediction] GSC 사고 목록 조회 실패:', err); + }); + return () => { + cancelled = true; + }; + }, []); + + const handleGscAccidentSelect = (mngNo: string) => { + setSelectedGscMngNo(mngNo); + const item = gscAccidents.find((a) => a.acdntMngNo === mngNo); + if (!item) return; + onIncidentNameChange(item.pollNm); + if (item.pollDate) onAccidentTimeChange(item.pollDate); + if (item.lat != null && item.lon != null) { + onCoordChange({ lat: item.lat, lon: item.lon }); + onFlyToCoord?.({ lat: item.lat, lon: item.lon }); + } + }; + + const gscOptions = gscAccidents.map((a) => ({ + value: a.acdntMngNo, + label: `${a.pollNm} (${a.pollDate ? a.pollDate.replace('T', ' ') : '-'})`, + })); + const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0] ?? null; setUploadedFile(file); @@ -161,7 +196,13 @@ const PredictionInputSection = ({ : undefined } /> - + {/* Image Upload Mode */} {inputMode === 'upload' && ( diff --git a/frontend/src/tabs/prediction/components/leftPanelTypes.ts b/frontend/src/tabs/prediction/components/leftPanelTypes.ts index 4ade4ad..82933ae 100644 --- a/frontend/src/tabs/prediction/components/leftPanelTypes.ts +++ b/frontend/src/tabs/prediction/components/leftPanelTypes.ts @@ -60,6 +60,8 @@ export interface LeftPanelProps { sensitiveResources?: SensitiveResourceCategory[]; // 이미지 분석 결과 콜백 onImageAnalysisResult?: (result: ImageAnalyzeResult) => void; + // 사고 리스트 선택 시 지도 이동 콜백 + onFlyToCoord?: (coord: { lon: number; lat: number }) => void; // 유효성 검증 에러 필드 validationErrors?: Set; } diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index 64c446f..e2783eb 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -321,3 +321,20 @@ export const analyzeImage = async (file: File, acdntNm?: string): Promise => { + const response = await api.get('/gsc/accidents'); + return response.data; +}; diff --git a/frontend/src/tabs/rescue/components/RescueView.tsx b/frontend/src/tabs/rescue/components/RescueView.tsx index 59d27d7..1244f38 100755 --- a/frontend/src/tabs/rescue/components/RescueView.tsx +++ b/frontend/src/tabs/rescue/components/RescueView.tsx @@ -5,8 +5,8 @@ import { RescueTheoryView } from './RescueTheoryView'; import { RescueScenarioView } from './RescueScenarioView'; import { fetchRescueOps } from '../services/rescueApi'; import type { RescueOpsItem } from '../services/rescueApi'; -import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi'; -import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi'; +import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi'; +import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi'; /* ─── Types ─── */ type AccidentType = @@ -230,9 +230,9 @@ function LeftPanel({ }: { activeType: AccidentType; onTypeChange: (t: AccidentType) => void; - incidents: IncidentListItem[]; - selectedAcdnt: IncidentListItem | null; - onSelectAcdnt: (item: IncidentListItem | null) => void; + incidents: GscAccidentListItem[]; + selectedAcdnt: GscAccidentListItem | null; + onSelectAcdnt: (item: GscAccidentListItem | null) => void; }) { const [acdntName, setAcdntName] = useState(''); const [acdntDate, setAcdntDate] = useState(''); @@ -242,18 +242,25 @@ function LeftPanel({ const [showList, setShowList] = useState(false); // 사고 선택 시 필드 자동 채움 - const handlePickIncident = (item: IncidentListItem) => { + const handlePickIncident = (item: GscAccidentListItem) => { onSelectAcdnt(item); - setAcdntName(item.acdntNm); - const dt = new Date(item.occrnDtm); - setAcdntDate( - `${dt.getFullYear()}. ${String(dt.getMonth() + 1).padStart(2, '0')}. ${String(dt.getDate()).padStart(2, '0')}.`, - ); - setAcdntTime( - `${dt.getHours() >= 12 ? '오후' : '오전'} ${String(dt.getHours() % 12 || 12).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`, - ); - setAcdntLat(String(item.lat)); - setAcdntLon(String(item.lng)); + setAcdntName(item.pollNm); + if (item.pollDate) { + const [d, t] = item.pollDate.split('T'); + if (d) { + const [y, m, day] = d.split('-'); + setAcdntDate(`${y}. ${m}. ${day}.`); + } + if (t) { + const [hhStr, mmStr] = t.split(':'); + const hh = parseInt(hhStr, 10); + const ampm = hh >= 12 ? '오후' : '오전'; + const hh12 = String(hh % 12 || 12).padStart(2, '0'); + setAcdntTime(`${ampm} ${hh12}:${mmStr}`); + } + } + if (item.lat != null) setAcdntLat(String(item.lat)); + if (item.lon != null) setAcdntLon(String(item.lon)); setShowList(false); }; @@ -283,7 +290,7 @@ function LeftPanel({ className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean text-left cursor-pointer hover:border-[var(--stroke-light)] flex items-center justify-between" > - {selectedAcdnt ? selectedAcdnt.acdntCd : '또는 사고 리스트에서 선택'} + {selectedAcdnt ? selectedAcdnt.pollNm : '또는 사고 리스트에서 선택'} {showList ? '▲' : '▼'} @@ -296,13 +303,13 @@ function LeftPanel({ )} {incidents.map((item) => ( ))} @@ -1523,13 +1530,14 @@ export function RescueView() { const { activeSubTab } = useSubMenu('rescue'); const [activeType, setActiveType] = useState('collision'); const [activeAnalysis, setActiveAnalysis] = useState('rescue'); - const [incidents, setIncidents] = useState([]); - const [selectedAcdnt, setSelectedAcdnt] = useState(null); + const [incidents, setIncidents] = useState([]); + const [selectedAcdnt, setSelectedAcdnt] = useState(null); const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null); + const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined); const [isSelectingLocation, setIsSelectingLocation] = useState(false); useEffect(() => { - fetchIncidentsRaw() + fetchGscAccidents() .then((items) => setIncidents(items)) .catch(() => setIncidents([])); }, []); @@ -1540,23 +1548,13 @@ export function RescueView() { setIsSelectingLocation(false); }, []); - // 사고 선택 시 사고유형 자동 매핑 + // 사고 선택 시 좌표 자동 반영 + 지도 이동 const handleSelectAcdnt = useCallback( - (item: IncidentListItem | null) => { + (item: GscAccidentListItem | null) => { setSelectedAcdnt(item); - if (item) { - const typeMap: Record = { - collision: 'collision', - grounding: 'grounding', - turning: 'turning', - capsizing: 'capsizing', - sharpTurn: 'sharpTurn', - flooding: 'flooding', - sinking: 'sinking', - }; - const mapped = typeMap[item.acdntTpCd]; - if (mapped) setActiveType(mapped); - setIncidentCoord({ lon: item.lng, lat: item.lat }); + if (item && item.lat != null && item.lon != null) { + setIncidentCoord({ lon: item.lon, lat: item.lat }); + setFlyToCoord({ lon: item.lon, lat: item.lat }); } }, [], @@ -1595,6 +1593,8 @@ export function RescueView() {
setFlyToCoord(undefined)} isSelectingLocation={isSelectingLocation} onMapClick={handleMapClick} oilTrajectory={[]} From 3eb66e2e54a21c9fdfa9a0ad252a11b6e94c9a91 Mon Sep 17 00:00:00 2001 From: leedano Date: Tue, 14 Apr 2026 17:20:01 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor(map):=20MapView=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=ED=83=AD=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=86=A0=ED=81=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/common/components/map/BaseMap.tsx | 311 ++++ .../common/components/map/DeckGLOverlay.tsx | 16 + .../common/components/map/FlyToController.tsx | 26 + .../src/common/components/map/MapView.tsx | 38 +- frontend/src/common/styles/components.css | 4 +- frontend/src/pages/design/LayoutContent.tsx | 1494 +++-------------- .../tabs/admin/components/DeidentifyPanel.tsx | 544 ++++-- .../admin/components/RndHnsAtmosPanel.tsx | 18 +- .../tabs/admin/components/RndKospsPanel.tsx | 18 +- .../admin/components/RndPoseidonPanel.tsx | 18 +- .../tabs/admin/components/RndRescuePanel.tsx | 18 +- .../tabs/admin/components/SystemArchPanel.tsx | 374 ++++- .../src/tabs/aerial/components/CctvView.tsx | 11 +- .../src/tabs/assets/components/AssetMap.tsx | 139 +- .../incidents/components/IncidentsView.tsx | 67 +- .../tabs/incidents/components/MediaModal.tsx | 81 +- .../components/AnalysisListTable.tsx | 36 +- .../prediction/components/BacktrackModal.tsx | 24 +- .../components/BoomDeploymentTheoryView.tsx | 60 +- .../components/InfoLayerSection.tsx | 8 +- .../tabs/prediction/components/LeftPanel.tsx | 32 +- .../prediction/components/OilBoomSection.tsx | 61 +- .../components/OilSpillTheoryView.tsx | 134 +- .../prediction/components/OilSpillView.tsx | 6 +- .../components/PredictionInputSection.tsx | 30 +- .../prediction/components/RecalcModal.tsx | 10 +- .../tabs/prediction/components/RightPanel.tsx | 65 +- .../prediction/components/leftPanelTypes.ts | 2 + .../rescue/components/RescueScenarioView.tsx | 316 +++- .../src/tabs/rescue/components/RescueView.tsx | 37 +- .../tabs/scat/components/ScatLeftPanel.tsx | 23 +- frontend/src/tabs/scat/components/ScatMap.tsx | 219 +-- .../tabs/scat/components/ScatRightPanel.tsx | 3 +- .../tabs/weather/components/WeatherView.tsx | 49 +- 34 files changed, 2097 insertions(+), 2195 deletions(-) create mode 100644 frontend/src/common/components/map/BaseMap.tsx create mode 100644 frontend/src/common/components/map/DeckGLOverlay.tsx create mode 100644 frontend/src/common/components/map/FlyToController.tsx diff --git a/frontend/src/common/components/map/BaseMap.tsx b/frontend/src/common/components/map/BaseMap.tsx new file mode 100644 index 0000000..b140a9b --- /dev/null +++ b/frontend/src/common/components/map/BaseMap.tsx @@ -0,0 +1,311 @@ +import { + useState, + useMemo, + useCallback, + useEffect, + type MutableRefObject, + type ReactNode, +} from 'react'; +import { Map, useMap } from '@vis.gl/react-maplibre'; +import type { MapLayerMouseEvent } from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; +import { useMapStore } from '@common/store/mapStore'; +import { useMeasureTool } from '@common/hooks/useMeasureTool'; +import { S57EncOverlay } from './S57EncOverlay'; +import { MeasureOverlay } from './MeasureOverlay'; +import { DeckGLOverlay } from './DeckGLOverlay'; +import { buildMeasureLayers } from './measureLayers'; + +const DEFAULT_CENTER: [number, number] = [37.39, 126.64]; +const DEFAULT_ZOOM = 10; + +export interface BaseMapProps { + /** 초기 중심 좌표 [lat, lng]. 기본: 인천 송도 */ + center?: [number, number]; + /** 초기 줌 레벨. 기본: 10 */ + zoom?: number; + /** 지도 클릭 핸들러 (측정 모드 중에는 호출되지 않음) */ + onMapClick?: (lon: number, lat: number) => void; + /** 줌 변경 핸들러. ScatMap 등 줌 기반 레이어 스케일 조정에 사용 */ + onZoom?: (zoom: number) => void; + /** 커서 스타일 (예: 'crosshair'). 기본: 'grab' */ + cursor?: string; + /** false 시 컨트롤 UI·좌표 표시를 숨김 (캡처 전용 모드). 기본: true */ + showOverlays?: boolean; + /** 지도 캡처 함수 ref (Reports 탭 전용) */ + mapCaptureRef?: MutableRefObject<(() => Promise) | null>; + /** 탭별 고유 오버레이·마커·팝업 등 */ + children?: ReactNode; +} + +// ─── 3D 모드 pitch/bearing 제어 ──────────────────────────────────────────── +function MapPitchController({ threeD }: { threeD: boolean }) { + const { current: map } = useMap(); + useEffect(() => { + if (!map) return; + map.easeTo( + threeD ? { pitch: 45, bearing: -17, duration: 800 } : { pitch: 0, bearing: 0, duration: 800 }, + ); + }, [threeD, map]); + return null; +} + +// ─── 지도 캡처 지원 ──────────────────────────────────────────────────────── +function MapCaptureSetup({ + captureRef, +}: { + captureRef: MutableRefObject<(() => Promise) | null>; +}) { + const { current: map } = useMap(); + useEffect(() => { + if (!map) return; + captureRef.current = () => + new Promise((resolve) => { + map.once('render', () => { + try { + const src = map.getCanvas(); + const maxW = 1200; + const scale = src.width > maxW ? maxW / src.width : 1; + const composite = document.createElement('canvas'); + composite.width = Math.round(src.width * scale); + composite.height = Math.round(src.height * scale); + const ctx = composite.getContext('2d')!; + ctx.fillStyle = '#0f1117'; + ctx.fillRect(0, 0, composite.width, composite.height); + ctx.drawImage(src, 0, 0, composite.width, composite.height); + resolve(composite.toDataURL('image/jpeg', 0.82)); + } catch { + resolve(null); + } + }); + map.triggerRepaint(); + }); + }, [map, captureRef]); + return null; +} + +// ─── 공통 컨트롤 UI + 좌표 표시 ─────────────────────────────────────────── +// Map 내부에 렌더링되어 useMap()으로 인스턴스에 접근함. +// 줌 버튼·지도 타입·측정 도구·좌표 표시를 하나의 컴포넌트로 통합. +function MapOverlayControls({ + initialCenter, + initialZoom, +}: { + initialCenter: [number, number]; + initialZoom: number; +}) { + const { current: map } = useMap(); + const mapToggles = useMapStore((s) => s.mapToggles); + const toggleMap = useMapStore((s) => s.toggleMap); + const measureMode = useMapStore((s) => s.measureMode); + const setMeasureMode = useMapStore((s) => s.setMeasureMode); + + const [pos, setPos] = useState({ + lat: initialCenter[0], + lng: initialCenter[1], + zoom: initialZoom, + }); + + useEffect(() => { + if (!map) return; + const update = () => { + const c = map.getCenter(); + setPos({ lat: c.lat, lng: c.lng, zoom: map.getZoom() }); + }; + update(); + map.on('move', update); + map.on('zoom', update); + return () => { + map.off('move', update); + map.off('zoom', update); + }; + }, [map]); + + const btn = + 'w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] ' + + 'backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center ' + + 'hover:bg-bg-surface-hover hover:text-fg transition-all text-caption select-none cursor-pointer'; + const btnOn = 'text-color-accent border-color-accent bg-[rgba(6,182,212,0.08)]'; + + // 좌표·축척 계산 + const { lat, lng, zoom } = pos; + const latDir = lat >= 0 ? 'N' : 'S'; + const lngDir = lng >= 0 ? 'E' : 'W'; + const mpp = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (256 * Math.pow(2, zoom)); + const sr = Math.round(mpp * (96 / 0.0254)); + const scaleLabel = + sr >= 1_000_000 ? `1:${(sr / 1_000_000).toFixed(1)}M` : `1:${sr.toLocaleString()}`; + + return ( + <> + {/* 좌측 컨트롤 컬럼 */} +
+ {/* 줌 */} + + + + +
+ + {/* 지도 타입 */} + + + +
+ + {/* 측정 도구 */} + + +
+ + {/* 좌표 표시 (좌하단) */} +
+ + 위도{' '} + + {Math.abs(lat).toFixed(4)}°{latDir} + + + + 경도{' '} + + {Math.abs(lng).toFixed(4)}°{lngDir} + + + + 축척 {scaleLabel} + +
+ + ); +} + +// ─── BaseMap ─────────────────────────────────────────────────────────────── +export function BaseMap({ + center = DEFAULT_CENTER, + zoom = DEFAULT_ZOOM, + onMapClick, + onZoom, + cursor, + showOverlays = true, + mapCaptureRef, + children, +}: BaseMapProps) { + const mapStyle = useBaseMapStyle(); + const mapToggles = useMapStore((s) => s.mapToggles); + const measureMode = useMapStore((s) => s.measureMode); + const measureInProgress = useMapStore((s) => s.measureInProgress); + const measurements = useMapStore((s) => s.measurements); + const { handleMeasureClick } = useMeasureTool(); + + const handleClick = useCallback( + (e: MapLayerMouseEvent) => { + const { lng, lat } = e.lngLat; + if (measureMode !== null) { + handleMeasureClick(lng, lat); + return; + } + onMapClick?.(lng, lat); + }, + [measureMode, handleMeasureClick, onMapClick], + ); + + const handleZoom = useCallback( + (e: { viewState: { zoom: number } }) => { + onZoom?.(e.viewState.zoom); + }, + [onZoom], + ); + + const measureDeckLayers = useMemo( + () => buildMeasureLayers(measureInProgress, measureMode, measurements), + [measureInProgress, measureMode, measurements], + ); + + return ( +
+ + {/* 공통 오버레이 */} + + + + + {mapCaptureRef && } + + {/* 공통 컨트롤 UI (줌·지도 타입·측정·좌표) */} + {showOverlays && } + + {/* 탭별 주입 */} + {children} + + + {/* 측정 모드 힌트 */} + {showOverlays && measureMode === 'distance' && ( +
+ 거리 재기 — {measureInProgress.length === 0 ? '시작점을 클릭하세요' : '끝점을 클릭하세요'} +
+ )} + {showOverlays && measureMode === 'area' && ( +
+ 면적 재기 — 꼭짓점을 클릭하세요 ({measureInProgress.length}개) + {measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'} +
+ )} +
+ ); +} diff --git a/frontend/src/common/components/map/DeckGLOverlay.tsx b/frontend/src/common/components/map/DeckGLOverlay.tsx new file mode 100644 index 0000000..72f86eb --- /dev/null +++ b/frontend/src/common/components/map/DeckGLOverlay.tsx @@ -0,0 +1,16 @@ +import { useControl } from '@vis.gl/react-maplibre'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import type { Layer } from '@deck.gl/core'; + +interface DeckGLOverlayProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + layers: Layer[]; +} + +/** deck.gl 레이어를 MapLibre에 interleaved 방식으로 통합하는 공통 컴포넌트. + * 반드시 자식으로 사용해야 한다. */ +export function DeckGLOverlay({ layers }: DeckGLOverlayProps) { + const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); + overlay.setProps({ layers }); + return null; +} diff --git a/frontend/src/common/components/map/FlyToController.tsx b/frontend/src/common/components/map/FlyToController.tsx new file mode 100644 index 0000000..3c1bd78 --- /dev/null +++ b/frontend/src/common/components/map/FlyToController.tsx @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; +import { useMap } from '@vis.gl/react-maplibre'; + +interface FlyToControllerProps { + target?: { lng: number; lat: number; zoom?: number } | null; + /** 이동 애니메이션 시간(ms). 기본: 1000 */ + duration?: number; +} + +/** 지도 특정 좌표로 flyTo 트리거 컴포넌트. + * target이 바뀔 때마다 flyTo를 실행한다. + * 반드시 자식으로 사용해야 한다. */ +export function FlyToController({ target, duration = 1000 }: FlyToControllerProps) { + const { current: map } = useMap(); + + useEffect(() => { + if (!map || !target) return; + map.flyTo({ + center: [target.lng, target.lat], + zoom: target.zoom ?? 10, + duration, + }); + }, [target, map, duration]); + + return null; +} diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 02619bf..98fa5d1 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -1,6 +1,5 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; -import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'; -import { MapboxOverlay } from '@deck.gl/mapbox'; +import { Map, Marker, Popup, Source, Layer, useMap } from '@vis.gl/react-maplibre'; import { ScatterplotLayer, PathLayer, @@ -28,6 +27,8 @@ import { useMeasureTool } from '@common/hooks/useMeasureTool'; import { hexToRgba } from './mapUtils'; import { S57EncOverlay } from './S57EncOverlay'; import { SrOverlay } from './SrOverlay'; +import { DeckGLOverlay } from './DeckGLOverlay'; +import { FlyToController } from './FlyToController'; import { useMapStore } from '@common/store/mapStore'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; @@ -126,6 +127,7 @@ interface MapViewProps { dispersionResult?: DispersionResult | null; dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>; boomLines?: BoomLine[]; + showBoomLines?: boolean; isDrawingBoom?: boolean; drawingPoints?: BoomLineCoord[]; layerOpacity?: number; @@ -165,31 +167,7 @@ interface MapViewProps { showOverlays?: boolean; } -// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function DeckGLOverlay({ layers }: { layers: any[] }) { - const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); - overlay.setProps({ layers }); - return null; -} - -// flyTo 트리거 컴포넌트 (Map 내부에서 useMap() 사용) -function FlyToController({ - flyToTarget, -}: { - flyToTarget?: { lng: number; lat: number; zoom?: number } | null; -}) { - const { current: map } = useMap(); - useEffect(() => { - if (!map || !flyToTarget) return; - map.flyTo({ - center: [flyToTarget.lng, flyToTarget.lat], - zoom: flyToTarget.zoom ?? 10, - duration: 1200, - }); - }, [flyToTarget, map]); - return null; -} +// DeckGLOverlay, FlyToController → @common/components/map/DeckGLOverlay, FlyToController 에서 import // fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용) function FitBoundsController({ @@ -341,6 +319,7 @@ export function MapView({ dispersionResult = null, dispersionHeatmap = [], boomLines = [], + showBoomLines = true, isDrawingBoom = false, drawingPoints = [], layerOpacity = 50, @@ -587,7 +566,7 @@ export function MapView({ } // --- 오일펜스 라인 (PathLayer) --- - if (boomLines.length > 0) { + if (showBoomLines && boomLines.length > 0) { result.push( new PathLayer({ id: 'boom-lines', @@ -1243,6 +1222,7 @@ export function MapView({ currentTime, selectedModels, boomLines, + showBoomLines, isDrawingBoom, drawingPoints, dispersionResult, @@ -1295,7 +1275,7 @@ export function MapView({ {/* 사고 지점 변경 시 지도 이동 */} {/* 외부에서 flyTo 트리거 */} - + {/* 예측 완료 시 궤적 전체 범위로 fitBounds */} diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 15aabeb..3d2a173 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -903,10 +903,10 @@ background: var(--bg-base); border: 1px solid var(--stroke-default); border-radius: 4px; - color: var(--color-accent); + color: var(--color-default); font-family: var(--font-mono); font-size: 0.75rem; - font-weight: 600; + font-weight: 400; text-align: right; outline: none; transition: border-color 0.2s; diff --git a/frontend/src/pages/design/LayoutContent.tsx b/frontend/src/pages/design/LayoutContent.tsx index 0a180f1..1282eeb 100644 --- a/frontend/src/pages/design/LayoutContent.tsx +++ b/frontend/src/pages/design/LayoutContent.tsx @@ -1,1361 +1,297 @@ -// LayoutContent.tsx — WING-OPS Layout 카탈로그 (KT 디자인시스템 시각화 영감) +// LayoutContent.tsx — WING-OPS Layout 카탈로그 (신한 UX 벤치마킹) import type { DesignTheme } from './designTheme'; -// ---------- 데이터 타입 ---------- - -interface Breakpoint { - name: string; - krdsName: string; - prefix: string; - minWidth: number; - maxWidth: number | null; - columns: number; - inUse: boolean; - note: string; -} - -interface DeviceSpec { - device: string; - prefix: string; - width: string; - columns: number; - gutter: string; - margin: string; - supported: boolean; -} - -interface SpacingToken { - className: string; - rem: string; - px: number; - usage: string; -} - -interface ZLayer { - name: string; - zIndex: number; - description: string; - color: string; -} - -interface ShellClass { - className: string; - role: string; - styles: string; -} - -interface GridRule { - name: string; - krds: string; - wingOps: string; - note: string; -} - -interface SubPageMapping { - krdsRegion: string; - wingOpsComponent: string; - implementation: string; - color: string; -} - -interface MultiplierAnnotation { - position: 'top' | 'left' | 'right' | 'bottom'; - multiplier: string; - px: number; -} - -// ---------- Breakpoints ---------- - -const BREAKPOINTS: Breakpoint[] = [ - { - name: '-', - krdsName: 'xsmall', - prefix: '-', - minWidth: 0, - maxWidth: 360, - columns: 4, - inUse: false, - note: '미지원', - }, - { - name: '-', - krdsName: 'small', - prefix: '-', - minWidth: 360, - maxWidth: 768, - columns: 4, - inUse: false, - note: '미지원 (모바일)', - }, - { - name: 'md', - krdsName: 'medium', - prefix: 'md:', - minWidth: 768, - maxWidth: 1024, - columns: 8, - inUse: false, - note: '미지원 (태블릿)', - }, - { - name: 'lg', - krdsName: 'large', - prefix: 'lg:', - minWidth: 1024, - maxWidth: 1280, - columns: 12, - inUse: false, - note: '미지원', - }, - { - name: 'xl', - krdsName: 'xlarge', - prefix: 'xl:', - minWidth: 1280, - maxWidth: 1440, - columns: 12, - inUse: true, - note: 'WING-OPS 최소 지원', - }, - { - name: '2xl', - krdsName: 'xxlarge', - prefix: '2xl:', - minWidth: 1440, - maxWidth: null, - columns: 12, - inUse: true, - note: 'WING-OPS 주 사용 해상도', - }, -]; - -// 타임라인 정규화 (0 ~ 2000px 범위) -const TIMELINE_MAX = 2000; -const toPercent = (px: number) => Math.min(100, (px / TIMELINE_MAX) * 100); -const TIMELINE_MARKERS = [360, 768, 1024, 1280, 1440, 1920]; - -// ---------- Device Specs (xl/2xl만 활성) ---------- - -const DEVICE_SPECS: DeviceSpec[] = [ - { - device: 'Desktop xl', - prefix: 'xl', - width: '1280px – 1439px', - columns: 12, - gutter: '24px (gap-6)', - margin: '24px (px-6)', - supported: true, - }, - { - device: 'Desktop 2xl', - prefix: '2xl', - width: '≥ 1440px', - columns: 12, - gutter: '24px (gap-6)', - margin: '32px (px-8)', - supported: true, - }, -]; - -// ---------- Spacing Scale ---------- - -const SPACING_TOKENS: SpacingToken[] = [ - { className: '0.5', rem: '0.125rem', px: 2, usage: '미세 간격' }, - { className: '1', rem: '0.25rem', px: 4, usage: '최소 간격 (gap-1)' }, - { className: '1.5', rem: '0.375rem', px: 6, usage: '컴팩트 간격 (gap-1.5)' }, - { className: '2', rem: '0.5rem', px: 8, usage: '기본 간격 (gap-2, p-2)' }, - { className: '2.5', rem: '0.625rem', px: 10, usage: '중간 간격' }, - { className: '3', rem: '0.75rem', px: 12, usage: '표준 간격 (gap-3, p-3)' }, - { className: '4', rem: '1rem', px: 16, usage: '넓은 간격 (p-4, gap-4)' }, - { className: '5', rem: '1.25rem', px: 20, usage: '패널 패딩 (px-5, py-5)' }, - { className: '6', rem: '1.5rem', px: 24, usage: '섹션 간격 (gap-6, p-6)' }, - { className: '8', rem: '2rem', px: 32, usage: '큰 간격 (px-8, gap-8)' }, - { className: '16', rem: '4rem', px: 64, usage: '최대 간격' }, -]; - -const SPACING_MAX_PX = Math.max(...SPACING_TOKENS.map((s) => s.px)); - -// ---------- Z-Index Layers (논리적 계층 — 디자인 시스템 진실 소스) ---------- - -const Z_LAYERS: ZLayer[] = [ - { name: 'Tooltip', zIndex: 60, description: '툴팁, 드롭다운 메뉴', color: '#a855f7' }, - { name: 'Popup', zIndex: 50, description: '팝업, 지도 오버레이', color: '#f97316' }, - { name: 'Modal', zIndex: 40, description: '모달 다이얼로그, 백드롭', color: '#ef4444' }, - { name: 'TopBar', zIndex: 30, description: '상단 네비게이션 바', color: '#3b82f6' }, - { name: 'Sidebar', zIndex: 20, description: '사이드바, 패널', color: '#06b6d4' }, - { name: 'Content', zIndex: 10, description: '메인 콘텐츠 영역', color: '#22c55e' }, - { name: 'Base', zIndex: 0, description: '기본 레이어, 배경', color: '#9ba3b8' }, -]; - -// ---------- App Shell Classes ---------- - -const SHELL_CLASSES: ShellClass[] = [ - { - className: '.wing-panel', - role: '탭 콘텐츠 패널', - styles: 'flex flex-col h-full overflow-hidden', - }, - { - className: '.wing-panel-scroll', - role: '패널 내 스크롤 영역', - styles: 'flex-1 overflow-y-auto', - }, - { - className: '.wing-header-bar', - role: '패널 헤더', - styles: 'flex items-center justify-between shrink-0 px-5 border-b', - }, - { className: '.wing-sidebar', role: '사이드바', styles: 'flex flex-col border-r border-stroke' }, -]; - -// ---------- KRDS Grid Rules ---------- - -const GRID_RULES: GridRule[] = [ - { - name: 'Grid base', - krds: '8pt grid', - wingOps: 'Tailwind 4px base + 8pt 권장', - note: 'gap-2=8px, p-4=16px, gap-6=24px 등 8pt 배수 우선', - }, - { - name: 'Max container', - krds: '1200px fixed', - wingOps: 'max-w-[1440px]', - note: '데스크톱 전용 풀스크린 앱 특성 반영', - }, - { - name: 'Screen margin', - krds: '24px (≥medium)', - wingOps: 'px-5 ~ px-8 (20–32px)', - note: 'KRDS medium+ 기준 충족', - }, - { - name: 'Column gutter', - krds: '16–24px (medium+)', - wingOps: 'gap-2 ~ gap-6 (8–24px)', - note: 'KRDS 권장 범위 내 사용', - }, - { - name: 'Sub-page 구조', - krds: 'Header → Left → Main → Right → Footer', - wingOps: 'TopBar → Sidebar → Content', - note: 'Footer 없음 — 풀스크린 앱', - }, -]; - -// ---------- KRDS Sub-page Mappings ---------- - -const SUB_PAGE_MAPPINGS: SubPageMapping[] = [ - { - krdsRegion: 'Header', - wingOpsComponent: 'TopBar', - implementation: 'h-[52px] / shrink-0 / z-30', - color: '#3b82f6', - }, - { - krdsRegion: 'Sub Navigation', - wingOpsComponent: 'SubMenuBar', - implementation: 'shrink-0 / 조건부 렌더', - color: '#06b6d4', - }, - { - krdsRegion: 'Left Menu', - wingOpsComponent: 'Sidebar', - implementation: '가변 너비 / flex-col / border-r', - color: '#a855f7', - }, - { - krdsRegion: 'Main Contents', - wingOpsComponent: 'Content', - implementation: 'flex-1 / overflow-y-auto', - color: '#22c55e', - }, - { - krdsRegion: 'Right Menu', - wingOpsComponent: 'Right Panel', - implementation: '조건부 / 탭 콘텐츠 내부', - color: '#f97316', - }, - { - krdsRegion: 'Footer', - wingOpsComponent: '없음', - implementation: '풀스크린 앱 — 미사용', - color: '#9ba3b8', - }, -]; - -// ---------- Multiplier 어노테이션 (4pt Grid 데모용) ---------- - -const CARD_ANNOTATIONS: MultiplierAnnotation[] = [ - { position: 'top', multiplier: 'x4', px: 16 }, - { position: 'left', multiplier: 'x5', px: 20 }, - { position: 'right', multiplier: 'x5', px: 20 }, - { position: 'bottom', multiplier: 'x4', px: 16 }, -]; - // ---------- Props ---------- interface LayoutContentProps { theme: DesignTheme; } +// ---------- 데이터 타입 ---------- + +interface WebResolution { + label: string; + gridLabel: string; + gutter: string; + margin: string; +} + +const WEB_RESOLUTIONS: WebResolution[] = [ + { label: 'WEB - 1280', gridLabel: '12 Grid (1280)', gutter: '24px', margin: '24px (px-6)' }, + { label: 'WEB - 1440', gridLabel: '12 Grid (1440)', gutter: '24px', margin: '32px (px-8)' }, + { label: 'WEB - 1600', gridLabel: '12 Grid (1600)', gutter: '24px', margin: '32px (px-8)' }, + { label: 'WEB - 1920', gridLabel: '12 Grid (1920)', gutter: '24px', margin: '32px (px-8)' }, +]; + // ---------- 컴포넌트 ---------- export const LayoutContent = ({ theme }: LayoutContentProps) => { const t = theme; const isDark = t.mode === 'dark'; - - // 시각화 색상 - const accentTint = isDark ? 'rgba(76,215,246,0.18)' : 'rgba(6,182,212,0.14)'; - const accentTintLight = isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)'; - const dimBg = isDark ? 'rgba(140,144,159,0.12)' : 'rgba(148,163,184,0.10)'; - const dimText = isDark ? '#8c909f' : '#94a3b8'; - const cardSurface = isDark ? '#1b1f2c' : '#ffffff'; + const previewBg = isDark ? '#1a1f30' : '#f1f5f9'; + const innerBg = isDark ? '#0a0e1a' : '#ffffff'; + const colCyan = 'rgba(6,182,212,0.18)'; + const gutterCyan = 'rgba(6,182,212,0.45)'; + const marginCyan = 'rgba(6,182,212,0.5)'; return (
- {/* ── 섹션 1: 헤더 + 개요 ── */} -
-
-

- Layout -

-

- WING-OPS는 데스크톱 전용 고정 뷰포트 애플리케이션입니다. 화면 전체를 채우는 고정 - 레이아웃(100vh)으로, flex 기반의 패널 구조를 사용합니다. KRDS 가이드라인을 기반으로 - xlarge / xxlarge 구간에 최적화되어 있습니다. -

-
-
- {[ - { label: 'Viewport', value: '100vh fixed', desc: 'overflow: hidden' }, - { label: 'Layout', value: 'flex 기반', desc: 'grid 보조' }, - { label: 'Min Width', value: '1280px', desc: 'xl: 이상' }, - ].map((item) => ( -
- - {item.label} - - - {item.value} - - - {item.desc} - -
- ))} -
-
- - {/* ── 섹션 2: Breakpoint 타임라인 ── */} + {/* ── 섹션 1: Layout grid ── */}

- Breakpoint + Layout grid

- 화면 크기에 따라 반응형 레이아웃을 사용하여 환경에 최적화된 구조로 표시됩니다. WING-OPS - 사용 구간(xl, 2xl)은 cyan으로 강조되어 있습니다. -

-
- - {/* 타임라인 다이어그램 */} -
- {/* 가로축 마커 (분기점 라벨) */} -
- {TIMELINE_MARKERS.map((px) => ( -
- - {px} - -
- ))} -
- - {/* 분기점 점선 + tier 막대 컨테이너 */} -
- {/* 수직 점선 */} - {TIMELINE_MARKERS.map((px) => ( -
- ))} - - {/* tier 막대들 */} -
- {BREAKPOINTS.map((bp) => { - const startPct = toPercent(bp.minWidth); - const endPct = bp.maxWidth ? toPercent(bp.maxWidth) : 100; - const widthPct = endPct - startPct; - const barColor = bp.inUse ? accentTint : dimBg; - const labelColor = bp.inUse ? t.textAccent : dimText; - - return ( -
- {/* 컬럼 그리드 미니 */} -
- {Array.from({ length: bp.columns }).map((_, i) => ( -
- ))} -
- - {bp.krdsName} - - {bp.inUse && ( - - in use - - )} - - {bp.columns} cols - -
- ); - })} -
-
- - {/* 범례 */} -
-
-
- - WING-OPS 사용 중 - -
-
-
- - 미지원 (1280px 미만) - -
-
-
-
- - {/* ── 섹션 3: Grid 시각화 카드 ── */} -
-
-

- Grid -

-

- 컬럼, 마진, 거터로 구성된 그리드 시스템입니다. 데스크톱 전용으로 xl / 2xl 두 구간만 - 지원합니다. -

-
- -
- {DEVICE_SPECS.map((spec) => ( -
- {/* 카드 헤더 */} -
-
- - Breakpoint - - - {spec.prefix} - -
-
- - Width - - - {spec.width} - -
-
- - {/* 시각화 영역 */} -
- {/* 컬럼 위 어노테이션 (margin / gutter) */} -
- {Array.from({ length: spec.columns + 1 }).map((_, idx) => { - const isEdge = idx === 0 || idx === spec.columns; - return ( - - {isEdge ? (spec.prefix === '2xl' ? '32' : '24') : '24'} - - ); - })} -
- - {/* 컬럼 막대 시각화 */} -
- {Array.from({ length: spec.columns }).map((_, i) => ( -
- ))} -
- - {/* 가짜 TopBar mockup */} -
- - WING-OPS - -
- {['예측', 'HNS', '구조', '관리'].map((label) => ( - - {label} - - ))} -
-
-
- - {/* 카드 푸터 — Grid 정보 */} -
- {[ - { label: 'Columns', value: `${spec.columns}` }, - { label: 'Gutter', value: spec.gutter }, - { label: 'Margin', value: spec.margin }, - ].map((info) => ( -
- - {info.label} - - - {info.value} - -
- ))} -
-
- ))} -
- - {/* 미지원 안내 */} -
- - ⚠ - - - 1280px 미만 미지원 — Mobile / Tablet - 구간(xs / s / md / lg)은 데스크톱 전용 운영 정책에 따라 지원하지 않습니다. - -
-
- - {/* ── 섹션 4: App Shell + KRDS Region 매핑 ── */} -
-
-

- App Shell -

-

- WING-OPS 애플리케이션의 기본 레이아웃 구조와 KRDS Sub-page 영역 매핑입니다. + 그리드 시스템은 columns, gutters, margins 세 가지 요소로 구성됩니다.

{/* 다이어그램 */}
- {/* TopBar */} -
-
- - TopBar - - - KRDS · Header - -
- - h-[52px] / shrink-0 + {/* Gutters 어노테이션 상단 */} +
+ + ⌐ Gutters ¬
- {/* SubMenuBar */} -
-
- - SubMenuBar - - - KRDS · Sub Navigation - + {/* 그리드 시각화 */} +
+ {/* Margin 좌 */} +
+ {/* 내부 컬럼 4개 그룹 */} +
+ {[0, 1, 2, 3].map((g) => ( +
+ ))}
- - shrink-0 / 조건부 - + {/* Margin 우 */} +
- {/* Content Area */} -
- {/* Sidebar */} -
- - Sidebar - - - KRDS · Left Menu - + {/* 어노테이션 하단 레이블 */} +
+
+
- flex-col / border-r + Margin
- - {/* Main Content */} -
- - Content - - - KRDS · Main Contents - +
+
- flex-1 / overflow-y-auto + Columns
- - {/* Right Panel (조건부) */} -
- - Right Panel - - - KRDS · Right Menu - +
+
- 조건부 렌더 + Gutters
+
+
- {/* Footer 안내 */} -
- - KRDS · Footer - - - 풀스크린 앱 — 미사용 - + {/* ── 섹션 2: 컬럼 ── */} +
+
+

+ 컬럼 (Column) +

+
+

+ 컬럼 단위를 사용해 콘텐츠의 크기를 조정합니다. +

+

+ 콘텐츠 영역을 동일 너비의 균일한 컬럼으로 나눠 1개 이상의 컬럼을 조합해 콘텐츠의 + 크기를 결정합니다. +

+

+ WING-OPS는 데스크톱 전용 앱으로 12컬럼 그리드를 사용하며, 거터는 24px(gap-6)입니다. +

- {/* 매핑 표 (다이어그램 부속) */} -
-
- {(['KRDS Region', 'WING-OPS Component', 'Implementation'] as const).map((col) => ( -
- + {WEB_RESOLUTIONS.map((res) => ( +
+
+

- {col} - + {res.label} +

+

+ 해상도 {res.label.replace('WEB - ', '')}은 12컬럼 {res.gutter}거터 사용을 + 권장합니다. +

- ))} -
- {SUB_PAGE_MAPPINGS.map((row, idx) => ( -
-
-
- - {row.krdsRegion} - -
-
- - {row.wingOpsComponent} - -
-
+ + {/* 프리뷰 박스 */} +
- {row.implementation} + {res.gridLabel} +
+
+ {Array.from({ length: 12 }).map((_, colIdx) => ( +
+ {/* 이미지 영역 */} +
+ {/* 텍스트 라인 3개 */} +
+
+
+
+ ))} +
+
))}
- {/* ── 섹션 5: Spacing Scale 막대 시각화 ── */} + {/* ── 섹션 3: 거터 ── */}

- Spacing + 거터 (Gutters)

-

- UI 요소 간의 간격과 여백을 정의하여 콘텐츠의 위계와 가독성을 조율합니다. Tailwind - spacing 토큰과 직결되며, 막대 길이는 실제 px 비율입니다. -

+
+

+ 거터는 컬럼간의 사이 공간으로 콘텐츠 간의 간격입니다. 거터의 너비는 고정값으로 + 정의됩니다. +

+

+ 상황에 따라 화면의 너비에 비례하는 거터값을 사용할 수 있습니다. +

+

+ 거터값은 4의 배수를 기본으로 합니다. WING-OPS 기본 거터: 24px (gap-6) +

+
+ {/* 거터 다이어그램 */}
- {SPACING_TOKENS.map((token) => { - const widthPct = (token.px / SPACING_MAX_PX) * 100; - return ( -
- {/* 토큰 배지 */} - - {token.className} - - {/* px 값 */} - - {token.px}px - - {/* 막대 시각화 */} -
+ {/* Gutters 어노테이션 */} +
+ + [ Gutters ] + +
+ + {/* 3컬럼 거터 하이라이트 */} +
+
+ {[0, 1, 2].map((colIdx) => ( +
+ {/* 컬럼 본체 */}
-
- {/* 사용처 */} - - {token.usage} - -
- ); - })} -
-
- - {/* ── 섹션 6: 4pt Grid 원칙 + Multiplier ── */} -
-
-

- 4pt Grid -

-

- 모든 여백과 간격을 4point 단위로 설정해 규칙성을 확보합니다. 컴팩트한 컴포넌트의 경우, - 2의 배수 단위를 제한적으로 사용합니다. -

-
- -
- {/* Part A — 원칙 카드 */} -
- - Principles - -
- {[ - { - label: '기본', - text: '4px base — 모든 spacing은 4의 배수', - example: '4 / 8 / 12 / 16 / 20 / 24 / 32', - }, - { - label: '권장', - text: '8pt 배수 우선 — gap-2, p-4, gap-6', - example: '8 / 16 / 24 / 32 / 64', - }, - { - label: '예외', - text: '컴팩트 컴포넌트만 4px / 12px 사용', - example: 'gap-1, gap-3, p-1.5', - }, - ].map((item) => ( -
-
- - {item.label} - - - {item.text} - -
- - {item.example} - + {/* 거터 (마지막 컬럼 제외) */} + {colIdx < 2 && ( +
+ )}
))}
- {/* Part B — Multiplier 어노테이션 시각화 */} -
- - Multiplier Annotation + {/* 거터 레이블 */} +
+
+ + Gutter — 24px (gap-6) -
- {/* 어노테이션 — top */} -
- - {CARD_ANNOTATIONS[0].multiplier}={CARD_ANNOTATIONS[0].px} - -
- {/* 어노테이션 — left */} -
- - {CARD_ANNOTATIONS[1].multiplier}={CARD_ANNOTATIONS[1].px} - -
- {/* 어노테이션 — right */} -
- - {CARD_ANNOTATIONS[2].multiplier}={CARD_ANNOTATIONS[2].px} - -
- {/* 어노테이션 — bottom */} -
- - {CARD_ANNOTATIONS[3].multiplier}={CARD_ANNOTATIONS[3].px} - -
- - {/* 실제 카드 mockup */} -
- - 카드 타이틀 - - - 본문 내용 예시 - -
- - Action - -
-
-
-

- 어노테이션은 spacing 값의 4px 배수 multiplier를 표시합니다. -

-
-
-
- - {/* ── 섹션 7: Z-Index Layers ── */} -
-
-
-

- Z-Index Layers -

- - 디자인 시스템 진실 소스 - -
-

- UI 요소의 레이어 스택 순서입니다. 높은 z-index가 위에 표시되며, 이 값은 디자인 시스템의 - 이상적 설계 값으로 실제 코드는 이 값에 맞춰 정정되어야 합니다. -

-
- -
- {Z_LAYERS.map((layer, idx) => ( -
-
- - {layer.zIndex} - -
-
-
- - {layer.name} - - - {layer.description} - -
-
- ))} -
-
- - {/* ── 섹션 8: CSS 클래스 + KRDS Grid Rules 통합 ── */} -
-
-

- Reference -

-

- App Shell CSS 클래스와 KRDS Grid 규칙 비교 — 코드 작성 시 참조용입니다. -

-
- -
- {/* CSS 클래스 */} -
-
- - CSS Classes - -
- {SHELL_CLASSES.map((cls, idx) => ( -
-
- - {cls.className} - - - {cls.role} - -
- - {cls.styles} - -
- ))} -
- - {/* KRDS Grid Rules */} -
-
- - KRDS vs WING-OPS - -
- {GRID_RULES.map((rule, idx) => ( -
- - {rule.name} - -
- - KRDS: - - - {rule.krds} - -
-
- - WING: - - - {rule.wingOps} - -
- - {rule.note} - -
- ))}
); }; - -export default LayoutContent; diff --git a/frontend/src/tabs/admin/components/DeidentifyPanel.tsx b/frontend/src/tabs/admin/components/DeidentifyPanel.tsx index e5a579f..561727b 100644 --- a/frontend/src/tabs/admin/components/DeidentifyPanel.tsx +++ b/frontend/src/tabs/admin/components/DeidentifyPanel.tsx @@ -36,14 +36,7 @@ interface DeidentifyTask { type SourceType = 'db' | 'file' | 'api'; type ProcessMode = 'immediate' | 'scheduled' | 'oneshot'; type RepeatType = 'daily' | 'weekly' | 'monthly'; -type DeidentifyTechnique = - | '마스킹' - | '삭제' - | '범주화' - | '암호화' - | '샘플링' - | '가명처리' - | '유지'; +type DeidentifyTechnique = '마스킹' | '삭제' | '범주화' | '암호화' | '샘플링' | '가명처리' | '유지'; interface FieldConfig { name: string; @@ -97,24 +90,102 @@ interface WizardState { // ─── Mock 데이터 ──────────────────────────────────────────── const MOCK_TASKS: DeidentifyTask[] = [ - { id: '001', name: 'customer_2024', target: '선박/운항 - 선장·선원 성명', status: '완료', startTime: '2026-04-10 14:30', progress: 100, createdBy: '관리자' }, - { id: '002', name: 'transaction_04', target: '사고 현장 - 현장사진, 영상내 인물', status: '진행중', startTime: '2026-04-10 14:15', progress: 82, createdBy: '김담당' }, - { id: '003', name: 'employee_info', target: '인사정보 - 계정, 로그인 정보', status: '대기', startTime: '2026-04-10 22:00', progress: 0, createdBy: '이담당' }, - { id: '004', name: 'vendor_data', target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처', status: '오류', startTime: '2026-04-09 13:45', progress: 45, createdBy: '관리자' }, - { id: '005', name: 'partner_contacts', target: '시스템 운영 - 관리자, 운영자 접속로그', status: '완료', startTime: '2026-04-08 09:00', progress: 100, createdBy: '박담당' }, + { + id: '001', + name: 'customer_2024', + target: '선박/운항 - 선장·선원 성명', + status: '완료', + startTime: '2026-04-10 14:30', + progress: 100, + createdBy: '관리자', + }, + { + id: '002', + name: 'transaction_04', + target: '사고 현장 - 현장사진, 영상내 인물', + status: '진행중', + startTime: '2026-04-10 14:15', + progress: 82, + createdBy: '김담당', + }, + { + id: '003', + name: 'employee_info', + target: '인사정보 - 계정, 로그인 정보', + status: '대기', + startTime: '2026-04-10 22:00', + progress: 0, + createdBy: '이담당', + }, + { + id: '004', + name: 'vendor_data', + target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처', + status: '오류', + startTime: '2026-04-09 13:45', + progress: 45, + createdBy: '관리자', + }, + { + id: '005', + name: 'partner_contacts', + target: '시스템 운영 - 관리자, 운영자 접속로그', + status: '완료', + startTime: '2026-04-08 09:00', + progress: 100, + createdBy: '박담당', + }, ]; const DEFAULT_FIELDS: FieldConfig[] = [ { name: '고객ID', dataType: '문자열', technique: '삭제', configValue: '-', selected: true }, - { name: '이름', dataType: '문자열', technique: '마스킹', configValue: '*로 치환', selected: true }, - { name: '휴대폰', dataType: '문자열', technique: '마스킹', configValue: '010-****-****', selected: true }, - { name: '주소', dataType: '문자열', technique: '범주화', configValue: '시/도만 표시', selected: true }, - { name: '이메일', dataType: '문자열', technique: '가명처리', configValue: '키: random_001', selected: true }, - { name: '생년월일', dataType: '날짜', technique: '범주화', configValue: '연도만 표시', selected: true }, + { + name: '이름', + dataType: '문자열', + technique: '마스킹', + configValue: '*로 치환', + selected: true, + }, + { + name: '휴대폰', + dataType: '문자열', + technique: '마스킹', + configValue: '010-****-****', + selected: true, + }, + { + name: '주소', + dataType: '문자열', + technique: '범주화', + configValue: '시/도만 표시', + selected: true, + }, + { + name: '이메일', + dataType: '문자열', + technique: '가명처리', + configValue: '키: random_001', + selected: true, + }, + { + name: '생년월일', + dataType: '날짜', + technique: '범주화', + configValue: '연도만 표시', + selected: true, + }, { name: '회사', dataType: '문자열', technique: '유지', configValue: '변경 없음', selected: true }, ]; -const TECHNIQUES: DeidentifyTechnique[] = ['마스킹', '삭제', '범주화', '암호화', '샘플링', '가명처리', '유지']; +const TECHNIQUES: DeidentifyTechnique[] = [ + '마스킹', + '삭제', + '범주화', + '암호화', + '샘플링', + '가명처리', + '유지', +]; const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`); @@ -124,23 +195,161 @@ const TEMPLATES = ['기본 개인정보', '금융데이터', '의료데이터']; const MOCK_AUDIT_LOGS: Record = { '001': [ - { id: 'LOG_20260410_001', time: '2026-04-10 14:30:45', operator: '김철수', operatorId: 'user_12345', action: '처리완료', targetData: 'customer_2024', result: '성공 (100%)', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '마스킹 3, 범주화 2, 삭제 2', processedCount: 15240, errorCount: 0 } }, - { id: 'LOG_20260410_002', time: '2026-04-10 14:15:10', operator: '김철수', operatorId: 'user_12345', action: '처리시작', targetData: 'customer_2024', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '마스킹 3, 범주화 2, 삭제 2', processedCount: 0, errorCount: 0 } }, - { id: 'LOG_20260410_003', time: '2026-04-10 14:10:30', operator: '김철수', operatorId: 'user_12345', action: '규칙설정', targetData: 'customer_2024', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 } }, + { + id: 'LOG_20260410_001', + time: '2026-04-10 14:30:45', + operator: '김철수', + operatorId: 'user_12345', + action: '처리완료', + targetData: 'customer_2024', + result: '성공 (100%)', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { + dataCount: 15240, + rulesApplied: '마스킹 3, 범주화 2, 삭제 2', + processedCount: 15240, + errorCount: 0, + }, + }, + { + id: 'LOG_20260410_002', + time: '2026-04-10 14:15:10', + operator: '김철수', + operatorId: 'user_12345', + action: '처리시작', + targetData: 'customer_2024', + result: '성공', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { + dataCount: 15240, + rulesApplied: '마스킹 3, 범주화 2, 삭제 2', + processedCount: 0, + errorCount: 0, + }, + }, + { + id: 'LOG_20260410_003', + time: '2026-04-10 14:10:30', + operator: '김철수', + operatorId: 'user_12345', + action: '규칙설정', + targetData: 'customer_2024', + result: '성공', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 }, + }, ], '002': [ - { id: 'LOG_20260410_004', time: '2026-04-10 14:15:22', operator: '이영희', operatorId: 'user_23456', action: '처리시작', targetData: 'transaction_04', result: '진행중 (82%)', resultType: '진행중', ip: '192.168.1.101', browser: 'Firefox 124.0', detail: { dataCount: 8920, rulesApplied: '마스킹 2, 암호화 1, 삭제 3', processedCount: 7314, errorCount: 0 } }, + { + id: 'LOG_20260410_004', + time: '2026-04-10 14:15:22', + operator: '이영희', + operatorId: 'user_23456', + action: '처리시작', + targetData: 'transaction_04', + result: '진행중 (82%)', + resultType: '진행중', + ip: '192.168.1.101', + browser: 'Firefox 124.0', + detail: { + dataCount: 8920, + rulesApplied: '마스킹 2, 암호화 1, 삭제 3', + processedCount: 7314, + errorCount: 0, + }, + }, ], '003': [ - { id: 'LOG_20260410_005', time: '2026-04-10 13:45:30', operator: '박민준', operatorId: 'user_34567', action: '규칙수정', targetData: 'employee_info', result: '성공', resultType: '성공', ip: '192.168.1.102', browser: 'Chrome 123.0', detail: { dataCount: 3200, rulesApplied: '마스킹 4, 가명처리 1', processedCount: 0, errorCount: 0 } }, + { + id: 'LOG_20260410_005', + time: '2026-04-10 13:45:30', + operator: '박민준', + operatorId: 'user_34567', + action: '규칙수정', + targetData: 'employee_info', + result: '성공', + resultType: '성공', + ip: '192.168.1.102', + browser: 'Chrome 123.0', + detail: { + dataCount: 3200, + rulesApplied: '마스킹 4, 가명처리 1', + processedCount: 0, + errorCount: 0, + }, + }, ], '004': [ - { id: 'LOG_20260409_001', time: '2026-04-09 13:45:30', operator: '관리자', operatorId: 'user_admin', action: '처리오류', targetData: 'vendor_data', result: '오류 (45%)', resultType: '실패', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 5100, rulesApplied: '마스킹 2, 범주화 1, 삭제 1', processedCount: 2295, errorCount: 12 } }, - { id: 'LOG_20260409_002', time: '2026-04-09 13:40:15', operator: '김철수', operatorId: 'user_12345', action: '규칙조회', targetData: 'vendor_data', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 } }, - { id: 'LOG_20260409_003', time: '2026-04-09 09:25:00', operator: '이영희', operatorId: 'user_23456', action: '삭제시도', targetData: 'vendor_data', result: '거부 (권한부족)', resultType: '거부', ip: '192.168.1.101', browser: 'Firefox 124.0', detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 } }, + { + id: 'LOG_20260409_001', + time: '2026-04-09 13:45:30', + operator: '관리자', + operatorId: 'user_admin', + action: '처리오류', + targetData: 'vendor_data', + result: '오류 (45%)', + resultType: '실패', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { + dataCount: 5100, + rulesApplied: '마스킹 2, 범주화 1, 삭제 1', + processedCount: 2295, + errorCount: 12, + }, + }, + { + id: 'LOG_20260409_002', + time: '2026-04-09 13:40:15', + operator: '김철수', + operatorId: 'user_12345', + action: '규칙조회', + targetData: 'vendor_data', + result: '성공', + resultType: '성공', + ip: '192.168.1.100', + browser: 'Chrome 123.0', + detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 }, + }, + { + id: 'LOG_20260409_003', + time: '2026-04-09 09:25:00', + operator: '이영희', + operatorId: 'user_23456', + action: '삭제시도', + targetData: 'vendor_data', + result: '거부 (권한부족)', + resultType: '거부', + ip: '192.168.1.101', + browser: 'Firefox 124.0', + detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 }, + }, ], '005': [ - { id: 'LOG_20260408_001', time: '2026-04-08 09:15:00', operator: '박담당', operatorId: 'user_45678', action: '처리완료', targetData: 'partner_contacts', result: '성공 (100%)', resultType: '성공', ip: '192.168.1.103', browser: 'Edge 122.0', detail: { dataCount: 1850, rulesApplied: '마스킹 2, 유지 3', processedCount: 1850, errorCount: 0 } }, + { + id: 'LOG_20260408_001', + time: '2026-04-08 09:15:00', + operator: '박담당', + operatorId: 'user_45678', + action: '처리완료', + targetData: 'partner_contacts', + result: '성공 (100%)', + resultType: '성공', + ip: '192.168.1.103', + browser: 'Edge 122.0', + detail: { + dataCount: 1850, + rulesApplied: '마스킹 2, 유지 3', + processedCount: 1850, + errorCount: 0, + }, + }, ], }; @@ -154,10 +363,14 @@ function fetchTasks(): Promise { function getStatusBadgeClass(status: TaskStatus): string { switch (status) { - case '완료': return 'text-emerald-400 bg-emerald-500/10'; - case '진행중': return 'text-cyan-400 bg-cyan-500/10'; - case '대기': return 'text-yellow-400 bg-yellow-500/10'; - case '오류': return 'text-red-400 bg-red-500/10'; + case '완료': + return 'text-emerald-400 bg-emerald-500/10'; + case '진행중': + return 'text-cyan-400 bg-cyan-500/10'; + case '대기': + return 'text-yellow-400 bg-yellow-500/10'; + case '오류': + return 'text-red-400 bg-red-500/10'; } } @@ -169,7 +382,10 @@ function ProgressBar({ value }: { value: number }) { return (
-
+
{value}%
@@ -217,9 +433,16 @@ function TaskTable({ rows, loading, onAction }: TaskTableProps) { {row.id} {row.name} - {row.target} + + {row.target} + - + {row.status} @@ -289,13 +512,18 @@ function StepIndicator({ current }: { current: number }) { isDone ? 'bg-emerald-500 text-white' : isActive - ? 'bg-cyan-500 text-white' - : 'bg-bg-elevated text-t3' + ? 'bg-cyan-500 text-white' + : 'bg-bg-elevated text-t3' }`} > {isDone ? ( - + ) : ( stepNum @@ -352,11 +580,13 @@ function Step1({ wizard, onChange }: Step1Props) {
- {([ - ['db', '데이터베이스 연결'], - ['file', '파일 업로드'], - ['api', 'API 호출'], - ] as [SourceType, string][]).map(([val, label]) => ( + {( + [ + ['db', '데이터베이스 연결'], + ['file', '파일 업로드'], + ['api', 'API 호출'], + ] as [SourceType, string][] + ).map(([val, label]) => (
@@ -652,7 +895,11 @@ function Step4({ wizard, onChange }: Step4Props) { onChange={(e) => handleScheduleChange('hour', e.target.value)} className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" > - {HOURS.map((h) => )} + {HOURS.map((h) => ( + + ))}
@@ -681,7 +928,11 @@ function Step4({ wizard, onChange }: Step4Props) { onChange={(e) => handleScheduleChange('weekday', e.target.value)} className="ml-1 px-2 py-0.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" > - {WEEKDAYS.map((d) => )} + {WEEKDAYS.map((d) => ( + + ))} )}
@@ -738,7 +989,11 @@ function Step4({ wizard, onChange }: Step4Props) { onChange={(e) => handleOneshotChange('hour', e.target.value)} className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" > - {HOURS.map((h) => )} + {HOURS.map((h) => ( + + ))}
@@ -769,7 +1024,15 @@ function Step5({ wizard, onChange }: Step5Props) { const summaryRows = [ { label: '작업명', value: wizard.taskName || '(미입력)' }, - { label: '소스', value: wizard.sourceType === 'db' ? `DB: ${wizard.dbConfig.database}.${wizard.dbConfig.tableName}` : wizard.sourceType === 'file' ? '파일 업로드' : `API: ${wizard.apiConfig.url}` }, + { + label: '소스', + value: + wizard.sourceType === 'db' + ? `DB: ${wizard.dbConfig.database}.${wizard.dbConfig.tableName}` + : wizard.sourceType === 'file' + ? '파일 업로드' + : `API: ${wizard.apiConfig.url}`, + }, { label: '데이터 건수', value: '15,240건' }, { label: '선택 필드 수', value: `${selectedCount}개` }, { label: '비식별화 규칙 수', value: `${ruleCount}개` }, @@ -833,10 +1096,14 @@ const INITIAL_WIZARD: WizardState = { function getAuditResultClass(type: AuditLogEntry['resultType']): string { switch (type) { - case '성공': return 'text-emerald-400 bg-emerald-500/10'; - case '진행중': return 'text-cyan-400 bg-cyan-500/10'; - case '실패': return 'text-red-400 bg-red-500/10'; - case '거부': return 'text-yellow-400 bg-yellow-500/10'; + case '성공': + return 'text-emerald-400 bg-emerald-500/10'; + case '진행중': + return 'text-cyan-400 bg-cyan-500/10'; + case '실패': + return 'text-red-400 bg-red-500/10'; + case '거부': + return 'text-yellow-400 bg-yellow-500/10'; } } @@ -863,10 +1130,11 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) {
{/* 헤더 */}
-

- 감시 감독 (감사로그) — {task.name} -

-
@@ -894,7 +1162,9 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) { className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" > {operators.map((op) => ( - + ))}
@@ -905,7 +1175,10 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) { {['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => ( - + {h} ))} @@ -925,18 +1198,27 @@ function AuditLogModal({ task, onClose }: AuditLogModalProps) { className={`border-b border-stroke-1 hover:bg-bg-surface/50 cursor-pointer ${selectedLog?.id === log.id ? 'bg-bg-surface/70' : ''}`} onClick={() => setSelectedLog(log)} > - {log.time.split(' ')[1]} + + {log.time.split(' ')[1]} + {log.operator} {log.action} - {log.targetData} + + {log.targetData} + - + {log.result}
@@ -1128,22 +1449,32 @@ export default function DeidentifyPanel() { } }, []); - const handleWizardSubmit = useCallback((wizard: WizardState) => { - const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name); - const newTask: DeidentifyTask = { - id: String(tasks.length + 1).padStart(3, '0'), - name: wizard.taskName, - target: selectedFields.join(', ') || '-', - status: wizard.processMode === 'immediate' ? '진행중' : '대기', - startTime: new Date().toLocaleString('ko-KR', { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', hour12: false, - }).replace(/\. /g, '-').replace('.', ''), - progress: 0, - createdBy: '관리자', - }; - setTasks((prev) => [newTask, ...prev]); - }, [tasks.length]); + const handleWizardSubmit = useCallback( + (wizard: WizardState) => { + const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name); + const newTask: DeidentifyTask = { + id: String(tasks.length + 1).padStart(3, '0'), + name: wizard.taskName, + target: selectedFields.join(', ') || '-', + status: wizard.processMode === 'immediate' ? '진행중' : '대기', + startTime: new Date() + .toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + .replace(/\. /g, '-') + .replace('.', ''), + progress: 0, + createdBy: '관리자', + }; + setTasks((prev) => [newTask, ...prev]); + }, + [tasks.length], + ); const filteredTasks = tasks.filter((t) => { if (searchName && !t.name.includes(searchName)) return false; @@ -1205,7 +1536,9 @@ export default function DeidentifyPanel() { className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" > {(['모두', '완료', '진행중', '대기', '오류'] as FilterStatus[]).map((s) => ( - + ))} -
분석 범위
+
분석 범위
- NM + NM
@@ -226,7 +226,7 @@ export function BacktrackModal({ }} className="border border-stroke" > -
유출 위치
+
유출 위치
{conditions.spillLocation.lat.toFixed(4)}°N,{' '} {conditions.spillLocation.lon.toFixed(4)}°E @@ -243,10 +243,10 @@ export function BacktrackModal({ gridColumn: '1 / -1', }} > -
분석 대상 선박
+
분석 대상 선박
{conditions.totalVessels}척{' '} - (AIS 수신) + (AIS 수신)
@@ -380,7 +380,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
{vessel.name}
-
+
IMO: {vessel.imo} · {vessel.type} · {vessel.flag}
@@ -391,7 +391,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) { > {vessel.probability}%
-
유출 확률
+
유출 확률
@@ -429,7 +429,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) { : '1px solid var(--stroke-default)', }} > -
{s.label}
+
{s.label}
오일펜스 배치 최적화 알고리즘 이론
-
+
Oil Boom Deployment Optimization · 유출유 확산예측 연동 · 방제효율 최대화
@@ -54,7 +54,7 @@ export function BoomDeploymentTheoryView() { className={`flex-1 py-2 px-1 text-label-1 font-medium rounded-md transition-all duration-150 cursor-pointer border ${ activePanel === tab.id ? 'border-stroke-light bg-bg-elevated text-fg' - : 'border-transparent bg-bg-card text-fg-disabled hover:text-fg-sub' + : 'border-transparent bg-bg-card text-fg-default hover:text-fg-sub' }`} > {tab.label} @@ -207,11 +207,11 @@ function OverviewPanel() {
{step.label}
-
+
{step.sub}
- {i < 5 &&
} + {i < 5 &&
}
))}
@@ -369,10 +369,10 @@ function DeploymentTheoryPanel() { Floss(Un)
Un = U · sin(θ){' '} - (법선방향 유속) + (법선방향 유속)
E = 1 (Un ≤ Uc)
E = max(0, 1 − (Un/U c)²) (Un > Uc)
- + Uc: 임계유속(약 0.35m/s = 0.7 knot)
@@ -391,12 +391,12 @@ function DeploymentTheoryPanel() { style={{ border: '1px solid rgba(6,182,212,.2)' }} > θ* = arcsin(Uc / U){' '} - (임계조건) + (임계조건)
θopt = argmax [Ablock(θ) · E(θ,U)]
실용범위: 15° ≤ θ ≤ 60°
- + 단, θ < arcsin(Uc/U) 이면 기름 통과 발생
@@ -483,7 +483,7 @@ function DeploymentTheoryPanel() { > AV = L²·sin(2α)/2
- α: 반개각, L: 편측 길이 + α: 반개각, L: 편측 길이
최적 α = 30°~45°
@@ -554,7 +554,7 @@ function DeploymentTheoryPanel() { > AU = π·r²/2 + 2r·h
- r: 반경, h: 직선부 길이 + r: 반경, h: 직선부 길이
전제: U < 0.5 knot
@@ -625,7 +625,7 @@ function DeploymentTheoryPanel() { style={{ background: 'rgba(6,182,212,.05)' }} > θJ = arcsin(Uc/U) + δ
- δ: 안전여유각(5°~10°) + δ: 안전여유각(5°~10°)
활용: U > 0.7 knot
@@ -642,7 +642,7 @@ function DeploymentTheoryPanel() { n개 직렬 배치 시 누적 차단 효율:
Etotal = 1 − ∏(1−Ei)
- + Ei: i번째 오일펜스 단독 차단효율
@@ -728,18 +728,18 @@ function OptimizationPanel() { 최대화:
f₁(x) = Σ Ablocked,i · wESI,i{' '} - (가중 차단면적) + (가중 차단면적)
f₂(x) = Tdeadline − Tdeploy{' '} - (여유시간) + (여유시간)
최소화:
f₃(x) = Σ Lboom,j{' '} - (총 오일펜스 사용량) + (총 오일펜스 사용량)
f₄(x) = Σ Dvessel,k{' '} - (방제정 총 이동거리) + (방제정 총 이동거리)
g₁: U·sin(θi) ≤ Uc ∀i{' '} - (임계유속) + (임계유속)
g₂: Σ Lj ≤ Lmax{' '} - (자원 한계) + (자원 한계)
g₃: Tdeploy,i ≤ Tarrive,i{' '} - (시간 제약) + (시간 제약)
g₄: d(pi, shore) ≥ dmin{' '} - (연안 이격) + (연안 이격)
g₅: h(pi) ≥ hmin{' '} - (수심 조건) + (수심 조건)
@@ -824,7 +824,7 @@ function OptimizationPanel() {
{esi.grade}
-
{esi.desc}
+
{esi.desc}
{esi.w}
))} @@ -933,7 +933,7 @@ function OptimizationPanel() { {['알고리즘', '유형', '장점', '단점', 'WING 활용'].map((h) => ( {h} @@ -1031,11 +1031,11 @@ function FluidDynamicsPanel() { FD = ½ · ρ · CD · A · Un²
T = FD · L / (2·sin(α))
- + CD: 항력계수(≈1.2), A: 수중 투영면적
- T: 연결부 장력, α: 체인각도 + T: 연결부 장력, α: 체인각도
@@ -1054,11 +1054,11 @@ function FluidDynamicsPanel() {
Splash-over: Fr > 0.5~0.6
- + Fr: 수정 Froude수, h: 오일펜스 수중깊이
- Δρ/ρ: 기름-해수 밀도비 (~0.15) + Δρ/ρ: 기름-해수 밀도비 (~0.15)
@@ -1075,7 +1075,7 @@ function FluidDynamicsPanel() {
y(x) = a·cosh(x/a) − a
Larc = 2a·sinh(Lspan/(2a))
Leff = Lspan · cos(φmax)
- + a: catenary 파라미터, φ: 최대 편향각
@@ -1392,7 +1392,7 @@ function ReferencesPanel() { return ( <>
📚 오일펜스 배치 최적화 이론 근거 문헌
-
총 12편 · 4개 카테고리
+
총 12편 · 4개 카테고리
{categories.map((cat, ci) => (
@@ -1430,7 +1430,7 @@ function ReferencesPanel() {
{ref.title}
-
{ref.author}
+
{ref.author}
{ref.desc}
diff --git a/frontend/src/tabs/prediction/components/InfoLayerSection.tsx b/frontend/src/tabs/prediction/components/InfoLayerSection.tsx index 33985f9..b75a28c 100644 --- a/frontend/src/tabs/prediction/components/InfoLayerSection.tsx +++ b/frontend/src/tabs/prediction/components/InfoLayerSection.tsx @@ -38,7 +38,7 @@ const InfoLayerSection = ({

정보 레이어

@@ -117,7 +117,7 @@ const InfoLayerSection = ({ > 전체 끄기 - + {expanded ? '▼' : '▶'}
@@ -126,9 +126,9 @@ const InfoLayerSection = ({ {expanded && (
{isLoading && effectiveLayers.length === 0 ? ( -

레이어 로딩 중...

+

레이어 로딩 중...

) : effectiveLayers.length === 0 ? ( -

레이어 데이터가 없습니다.

+

레이어 데이터가 없습니다.

) : ( toggleSection('incident')} className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" > -

사고정보

- +

사고정보

+ {expandedSections.incident ? '▼' : '▶'}
@@ -202,7 +204,7 @@ export function LeftPanel({ CLOSED: { label: '종료', style: - 'bg-[rgba(100,116,139,0.15)] text-fg-disabled border border-[rgba(100,116,139,0.3)]', + 'bg-[rgba(100,116,139,0.15)] text-fg-default border border-[rgba(100,116,139,0.3)]', dot: 'bg-fg-disabled', }, }; @@ -220,7 +222,7 @@ export function LeftPanel({ {/* Info Grid */}
- + 사고코드 @@ -228,7 +230,7 @@ export function LeftPanel({
- + 사고명 @@ -236,7 +238,7 @@ export function LeftPanel({
- + 사고일시 @@ -246,7 +248,7 @@ export function LeftPanel({
- + 유종 @@ -254,7 +256,7 @@ export function LeftPanel({
- + 유출량 @@ -264,7 +266,7 @@ export function LeftPanel({
- + 담당자 @@ -272,7 +274,7 @@ export function LeftPanel({
- + 위치 @@ -283,7 +285,7 @@ export function LeftPanel({
) : (
-

+

선택된 사고정보가 없습니다.

@@ -296,8 +298,8 @@ export function LeftPanel({ onClick={() => toggleSection('impactResources')} className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" > -

영향 민감자원

- +

영향 민감자원

+ {expandedSections.impactResources ? '▼' : '▶'}
@@ -305,7 +307,7 @@ export function LeftPanel({ {expandedSections.impactResources && (
{sensitiveResources.length === 0 ? ( -

+

영향받는 민감자원 목록

) : ( @@ -357,6 +359,8 @@ export function LeftPanel({ onToggle={() => toggleSection('oilBoom')} boomLines={boomLines} onBoomLinesChange={onBoomLinesChange} + showBoomLines={showBoomLines} + onShowBoomLinesChange={onShowBoomLinesChange} oilTrajectory={oilTrajectory} incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }} algorithmSettings={algorithmSettings} diff --git a/frontend/src/tabs/prediction/components/OilBoomSection.tsx b/frontend/src/tabs/prediction/components/OilBoomSection.tsx index 2a708fb..a3ce490 100644 --- a/frontend/src/tabs/prediction/components/OilBoomSection.tsx +++ b/frontend/src/tabs/prediction/components/OilBoomSection.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; import type { BoomLine, BoomLineCoord, @@ -22,6 +23,8 @@ interface OilBoomSectionProps { onDrawingPointsChange: (points: BoomLineCoord[]) => void; containmentResult: ContainmentResult | null; onContainmentResultChange: (result: ContainmentResult | null) => void; + showBoomLines: boolean; + onShowBoomLinesChange: (show: boolean) => void; } const DEFAULT_SETTINGS: AlgorithmSettings = { @@ -44,6 +47,8 @@ const OilBoomSection = ({ onDrawingPointsChange, containmentResult, onContainmentResultChange, + showBoomLines, + onShowBoomLinesChange, }: OilBoomSectionProps) => { const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'simulation'>('simulation'); const [showResetConfirm, setShowResetConfirm] = useState(false); @@ -81,8 +86,22 @@ const OilBoomSection = ({ onClick={onToggle} className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" > -

오일펜스 배치 가이드

- {expanded ? '▼' : '▶'} +

오일펜스 배치 가이드

+
+ + {expanded ? '▼' : '▶'} +
{expanded && ( @@ -127,7 +146,7 @@ const OilBoomSection = ({ borderRadius: 'var(--radius-sm)', border: '1px solid var(--stroke-default)', background: 'var(--bg-base)', - color: hasData ? 'var(--color-danger)' : 'var(--fg-disabled)', + color: 'var(--fg-disabled)', cursor: hasData ? 'pointer' : 'not-allowed', transition: '0.15s', }} @@ -150,7 +169,7 @@ const OilBoomSection = ({
⚠ 오일펜스 배치 가이드를 초기화 합니다
-
+
배치된 오일펜스 라인과 시뮬레이션 결과가 삭제됩니다. 확산 예측 결과는 유지됩니다.
@@ -218,12 +237,12 @@ const OilBoomSection = ({ className="border border-stroke" >
{metric.value}
-
{metric.label}
+
{metric.label}
))}
@@ -242,16 +261,10 @@ const OilBoomSection = ({ width: '8px', height: '8px', borderRadius: '50%', - background: - oilTrajectory.length > 0 ? 'var(--color-success)' : 'var(--color-danger)', + background: 'var(--fg-default)', }} /> - 0 ? 'var(--color-success)' : 'var(--fg-disabled)', - }} - > + 확산 궤적 데이터{' '} {oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'} @@ -261,7 +274,7 @@ const OilBoomSection = ({ {/* 알고리즘 설정 */}

📊 V자형 배치 알고리즘 설정 @@ -301,7 +314,7 @@ const OilBoomSection = ({ }} className="flex items-center justify-between px-2.5 py-1.5 border border-stroke" > - + ● {setting.label}
@@ -315,7 +328,7 @@ const OilBoomSection = ({ className="boom-setting-input" step={setting.key === 'waveHeightCorrectionFactor' ? 0.1 : 1} /> - + {setting.unit}
@@ -342,7 +355,7 @@ const OilBoomSection = ({ V자형 오일펜스 배치 + 시뮬레이션 실행 -

+

확산 궤적을 분석하여 해류 직교 방향 1차 방어선(V형), U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 배치하고 차단 시뮬레이션을 실행합니다.

@@ -363,7 +376,7 @@ const OilBoomSection = ({
{containmentResult.overallEfficiency}%
-
전체 차단 효율
+
전체 차단 효율

{/* 차단/통과 카운트 */} @@ -380,7 +393,7 @@ const OilBoomSection = ({
{containmentResult.blockedParticles}
-
차단 입자
+
차단 입자
{containmentResult.passedParticles}
-
통과 입자
+
통과 입자
@@ -485,13 +498,13 @@ const OilBoomSection = ({ className="mb-1.5" >
- 길이 + 길이
{line.length.toFixed(0)}m
- 각도 + 각도
{line.angle.toFixed(0)}°
diff --git a/frontend/src/tabs/prediction/components/OilSpillTheoryView.tsx b/frontend/src/tabs/prediction/components/OilSpillTheoryView.tsx index ca75b77..7ac156f 100755 --- a/frontend/src/tabs/prediction/components/OilSpillTheoryView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillTheoryView.tsx @@ -85,7 +85,7 @@ ${styles} 🔴 POSEIDON 🔵 OpenDrift ⚡ 앙상블 - 라그랑지안 입자추적 이론 기반 + 라그랑지안 입자추적 이론 기반
@@ -111,7 +111,7 @@ ${styles} className={`flex-1 py-2 px-1 text-label-1 font-medium rounded-md transition-all duration-150 cursor-pointer border ${ activePanel === tab.id ? 'border-stroke-light bg-bg-elevated text-fg' - : 'border-transparent bg-bg-card text-fg-disabled hover:text-fg-sub' + : 'border-transparent bg-bg-card text-fg-default hover:text-fg-sub' }`} > {tab.icon} {tab.name} @@ -232,7 +232,7 @@ function SystemOverviewPanel() {
🤖 WING 탑재 유출유 확산 모델 비교
- 3종 앙상블 운용 · 불확실성 정량화 + 3종 앙상블 운용 · 불확실성 정량화
{[ @@ -289,7 +289,7 @@ function SystemOverviewPanel() {
{m.name}
-
{m.sub}
+
{m.sub}
{m.desc}
@@ -337,7 +337,7 @@ function SystemOverviewPanel() { }} > 구분 @@ -469,7 +469,7 @@ function SystemOverviewPanel() { }} > ')), }} @@ -538,7 +538,7 @@ function KospsPanel() {
KOSPS (Korea Oil Spill Prediction System)
-
+
한국해양연구원(KORDI) 개발 · 한국 해역 특화 유출유 확산 예측 상시 운용 시스템
@@ -584,9 +584,9 @@ function KospsPanel() { {/* 특허 1 */}
-
등록번호
+
등록번호
10-1567431
-
2015.11.03
+
2015.11.03
@@ -611,7 +611,7 @@ function KospsPanel() { ))}
-
+
국가R&D: ① 3차원 유출유 확산예측 기반 방제 지원기술 개발 (기여율 65%) ② HNS 유출 거동예측 및 대응정보 지원기술 개발 (기여율 35%) | 해양수산부
@@ -632,7 +632,7 @@ function KospsPanel() {
- /* 변조조석 수식 */ + /* 변조조석 수식 */
ζ(t) = A(t) cos[σt − θ(t)]
@@ -712,7 +712,7 @@ function KospsPanel() { {d.icon} {d.label} - {d.detail} + {d.detail}
))}
@@ -725,14 +725,14 @@ function KospsPanel() { style={{ background: 'rgba(6,182,212,.04)', border: '1px solid rgba(6,182,212,.12)' }} >
📍 수심·해안선
-
전자해도(ENC) → 500m 격자 보간
+
전자해도(ENC) → 500m 격자 보간
🗺️ 격자 구성
-
좌표변환 → 영역추출 → 격자보간 표준화
+
좌표변환 → 영역추출 → 격자보간 표준화
@@ -744,12 +744,12 @@ function KospsPanel() {
- /* 취송류 유속 (이·강, 2000) */ + /* 취송류 유속 (이·강, 2000) */
V_WDC = 0.029 × V_wind
- /* 취송류 유향 */ + /* 취송류 유향 */
θ_WDC = θ_wind + 18.6°
@@ -810,7 +810,7 @@ function KospsPanel() {
{node.label}
-
{node.sub}
+
{node.sub}
{i < 5 && (
@@ -818,7 +818,7 @@ function KospsPanel() {
))}
-
+
FTP 자동 갱신 → DB 정규화 → 격자 재구성 → 모델 구동 → 결과 표출
이문진 박사 특허 기반 핵심 기술 (등록특허 10-1567431)
-
+
해양 유류오염사고 발생시 효율적인 방제방안 수립을 위한 유출유 확산 예측 방법 · 한국해양과학기술원 · 2015년 등록
@@ -1058,7 +1058,7 @@ function KospsPanel() {
z(x,y) = Σ Σ qᵢⱼ xⁱ yʲ{' '} - (i≤5, i+j≤5) + (i≤5, i+j≤5)
@@ -1104,7 +1104,7 @@ function KospsPanel() {
3차원 유출유 확산예측 기반 해양유류오염 방제 지원기술 개발
- 해양수산부 | 2013.01~2013.12 + 해양수산부 | 2013.01~2013.12
주요 위험유해물질(HNS) 유출 거동예측 및 대응정보 지원기술 개발
- 해양수산부 | 2013.01~2013.12 + 해양수산부 | 2013.01~2013.12
@@ -1163,7 +1163,7 @@ function KospsPanel() { ▼
-
+
KOSPS 개발 · 허베이스피리트 검증 · 3D 확산예측 시스템 · 방제효과 모델링 — 한국해양환경·에너지학회
@@ -1246,10 +1246,10 @@ function KospsPanel() { ))}
- {paper.year} + {paper.year}
{paper.title}
-
{paper.authors}
+
{paper.authors}
{paper.desc}
))} @@ -1405,7 +1405,7 @@ function KospsPanel() {
{paper.title}
-
+
{paper.authors} | {paper.journal}{' '} {paper.detail}
@@ -1542,7 +1542,7 @@ function KospsPanel() {
{paper.title}
-
+
{paper.authors} | {paper.journal}{' '} {paper.detail}
@@ -1570,7 +1570,7 @@ function PoseidonPanel() {
POSEIDON (입자추적 최적화 예측 시스템)
-
+
한국환경연구원 · (주)아라종합기술 · 한국해양기상기술 공동개발 · MOHID 해양순환모델 기반 · 뜰개 관측 매개변수 자동 최적화
@@ -1631,9 +1631,9 @@ function PoseidonPanel() { fontFamily: 'var(--font-mono)', }} > -
등록번호
+
등록번호
10-1868791
-
2018 등록
+
2018 등록
@@ -1724,7 +1724,7 @@ function PoseidonPanel() {
POSEIDON 입자추적 핵심 수식
-
+
제1 입자추적 모델 (기본)
@@ -1732,12 +1732,12 @@ function PoseidonPanel() {
Model_y = Δt × current_v + Δt × c × wind_v
-
+
c : 풍속 가중치 (예: c=0.3 → 바람의 30% 반영)
-
+
제2 입자추적 모델 (최적화 후)
@@ -1749,7 +1749,7 @@ function PoseidonPanel() {            + a5·Model_x + a6·Model_y + a7
-
+
a1~a7 : GA·DE·PSO로 최적화된 매개변수
@@ -1760,7 +1760,7 @@ function PoseidonPanel() {
🔄 POSEIDON_V2 상시 운용 체계
{/* 외부 입력 자료 */} -
외부 입력 자료
+
외부 입력 자료
{[ { @@ -1818,12 +1818,12 @@ function PoseidonPanel() {
{/* 중앙 화살표 */} -
+
▼ DATA → PREP → 격자 보간/좌표 변환 ▼
{/* 4대 도메인 실행 모듈 */} -
+
POSEIDON 4대 실행 모듈 (EA012 대격자 → KO108 연안 상세격자)
@@ -1887,7 +1887,7 @@ function PoseidonPanel() {
{/* 화살표 + 최적화 */} -
+
▼ HYDR + WAVE + TIDE → OILS 강제력 입력 ▼ 뜰개 관측 → GA/DE/PSO 매개변수 자동 최적화 ▼
@@ -1914,7 +1914,7 @@ function PoseidonPanel() {
{node.label}
-
{node.sub}
+
{node.sub}
{i < 2 && (
@@ -1961,7 +1961,7 @@ function PoseidonPanel() {
POSEIDON관련 유출유 확산예측 논문
-
+
포세이돈 시스템 소개·활용 · 최적 방제전략 · 원격탐사 연동 · MOHID 검증 — 한국해양환경·에너지학회 외
@@ -2040,10 +2040,10 @@ function PoseidonPanel() { ))}
- {paper.year} + {paper.year}
{paper.title}
-
{paper.authors}
+
{paper.authors}
{paper.desc}
))} @@ -2066,7 +2066,7 @@ function OpenDriftPanel() {
OpenDrift (오픈소스 라그랑지안 확산 프레임워크)
-
+
노르웨이 MET Norway · OpenOil 공개 프레임워크 · Python 기반 · IMO/IPIECA 검증
@@ -2210,7 +2210,7 @@ function OpenDriftPanel() {
{w.title}
-
{w.desc}
+
{w.desc}
))}
@@ -2244,7 +2244,7 @@ function OpenDriftPanel() {
{node.label}
-
{node.sub}
+
{node.sub}
{i < 6 && (
@@ -2252,7 +2252,7 @@ function OpenDriftPanel() {
))}
-
+
해양모델(NEMO·ROMS·HYCOM) + 기상자료(ECMWF·GFS) → NOAA Oil Library 유종 매칭 → OpenDrift/OpenOil 모듈 구동 → NetCDF 결과 출력·시각화
@@ -2271,7 +2271,7 @@ function OpenDriftPanel() {
OpenDrift / OpenOil 국내 해역 적용 연구 논문
-
+
한국 연안 유출유 확산 수치모의 관련 핵심 논문 3편 — WING 모델 이론 근거
@@ -2309,13 +2309,13 @@ function OpenDriftPanel() { ))}
- 2024 + 2024
Numerical Model Test of Spilled Oil Transport Near the Korean Coasts Using Various Input Parametric Models
-
+
Hai Van Dang, Suchan Joo, Junhyeok Lim, Jinhwan Hur, Sungwon Shin | Hanyang University ERICA | Journal of Ocean Engineering and Technology, 2024
@@ -2417,13 +2417,13 @@ function OpenDriftPanel() { ))}
- 1998 + 1998
한국 동남해역에서의 유출유 확산예측모델 (Oil Spill Behavior Forecasting Model in South-eastern Coastal Area of Korea)
-
+
류청로, 김종규, 설동관, 강동욱 | 부경대학교 해양공학과 | 한국해양환경공학회지 Vol.1 No.2, pp.52–59, 1998
@@ -2520,13 +2520,13 @@ function OpenDriftPanel() { ))}
- 2008 + 2008
태안 기름유출사고의 유출유 확산특성 분석 (Analysis of Oil Spill Dispersion in Taean Coastal Zone)
-
+
정태성, 조형진 | 한남대학교 토목환경공학과 | 한국해안·해양공학회 학술발표논문집 제17권 pp.60–63, 2008
@@ -2593,7 +2593,7 @@ function OpenDriftPanel() { }} >
α = 3%
-
과대 확산
+
과대 확산
α = 2.5%
-
다소 빠름
+
다소 빠름
α = 2% ✓
-
최적 일치
+
최적 일치
θ = 20° ✓
-
최적 편향각
+
최적 편향각
@@ -2733,14 +2733,14 @@ function LagrangianPanel() {
- /* 중력-관성 체제 (초기) */ + /* 중력-관성 체제 (초기) */
R(t) = K₁ · ( ΔρgV² /{' '} ρw)¼ · t½
- /* 중력-점성 체제 (후기) */ + /* 중력-점성 체제 (후기) */
R(t) = K₂ · ( ΔρgV² /{' '} @@ -2832,7 +2832,7 @@ function WeatheringPanel() {
{w.title}
{w.desc}
{w.formula}
-
{w.note}
+
{w.note}
))}
@@ -2883,7 +2883,7 @@ function WeatheringPanel() { {s.time}
{s.title}
-
+
{s.desc}
@@ -2934,7 +2934,7 @@ function OceanInputPanel() { }} >
{t.label}
-
{t.desc}
+
{t.desc}
))}
@@ -2957,7 +2957,7 @@ function OceanInputPanel() { }} >
{t.label}
-
{t.desc}
+
{t.desc}
))}
@@ -3013,7 +3013,7 @@ function VerificationPanel() { > {s.value}
-
{s.label}
+
{s.label}
))}
@@ -3202,7 +3202,7 @@ function VerificationPanel() { {paper.system}
-
+
{paper.authors} | {paper.journal}{' '} {paper.detail}
@@ -3314,7 +3314,7 @@ function RoadmapPanel() { }} >
{r.title}
-
{r.desc}
+
{r.desc}
))} @@ -3367,7 +3367,7 @@ function RoadmapPanel() { {s.phase}
{s.title}
-
+
{s.desc}
diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index f276a0b..d64567e 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -208,6 +208,7 @@ export function OilSpillView() { // 오일펜스 배치 상태 const [boomLines, setBoomLines] = useState([]); + const [showBoomLines, setShowBoomLines] = useState(true); const [algorithmSettings, setAlgorithmSettings] = useState({ currentOrthogonalCorrection: 15, safetyMarginMinutes: 60, @@ -1191,6 +1192,8 @@ export function OilSpillView() { onSpillUnitChange={setSpillUnit} boomLines={boomLines} onBoomLinesChange={setBoomLines} + showBoomLines={showBoomLines} + onShowBoomLinesChange={setShowBoomLines} oilTrajectory={oilTrajectory} algorithmSettings={algorithmSettings} onAlgorithmSettingsChange={setAlgorithmSettings} @@ -1281,6 +1284,7 @@ export function OilSpillView() { )} selectedModels={selectedModels} boomLines={boomLines} + showBoomLines={showBoomLines} isDrawingBoom={isDrawingBoom} drawingPoints={drawingPoints} layerOpacity={layerOpacity} @@ -1659,7 +1663,7 @@ export function OilSpillView() { fontSize: 'var(--font-size-caption)', }} > - {s.label} + {s.label} -

예측정보 입력

- {expanded ? '▼' : '▶'} +

예측정보 입력

+ {expanded ? '▼' : '▶'} {expanded && ( @@ -169,7 +169,7 @@ const PredictionInputSection = ({ {/* 파일 선택 영역 */} {!uploadedFile ? (
@@ -587,7 +585,7 @@ function DateTimeInput({ @@ -597,7 +595,7 @@ function DateTimeInput({ {['일', '월', '화', '수', '목', '금', '토'].map((d) => ( {d} @@ -779,7 +777,7 @@ function DmsCoordInput({ return (
- {label} + {label}
update(parseInt(e.target.value) || 0, m, s, dir)} style={fieldStyle} /> - ° + ° update(d, parseInt(e.target.value) || 0, s, dir)} style={fieldStyle} /> - ' + ' update(d, m, parseFloat(e.target.value) || 0, dir)} style={fieldStyle} /> - " + "
); diff --git a/frontend/src/tabs/prediction/components/RecalcModal.tsx b/frontend/src/tabs/prediction/components/RecalcModal.tsx index 0956154..2ab79a3 100755 --- a/frontend/src/tabs/prediction/components/RecalcModal.tsx +++ b/frontend/src/tabs/prediction/components/RecalcModal.tsx @@ -167,7 +167,7 @@ export function RecalcModal({

확산예측 재계산

-
+
유출유·유출량 등 파라미터를 수정하여 재실행
@@ -180,7 +180,7 @@ export function RecalcModal({ background: 'var(--bg-card)', fontSize: 'var(--font-size-caption)', }} - className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center" + className="border border-stroke text-fg-default cursor-pointer flex items-center justify-center" > ✕ @@ -281,7 +281,7 @@ export function RecalcModal({
-
위도 (N)
+
위도 (N)
-
경도 (E)
+
경도 (E)
- {label} + {label} {value}
); diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index a584c70..4edadf7 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -165,7 +165,7 @@ export function RightPanel({
{windHydrModelOptions.length > 1 && (
- + 데이터 모델 - NM + NM
-
+
IMO {vessel?.imoNo || '—'} · {vessel?.vesselTp || '—'}
@@ -511,7 +511,7 @@ export function RightPanel({
⚠ 충돌 상대: {vessel2.vesselNm}
-
+
{vessel2.flagCd} {vessel2.vesselTp}{' '} {vessel2.gt ? `${vessel2.gt.toLocaleString()}GT` : ''}
@@ -570,7 +570,7 @@ export function RightPanel({ ))} ) : ( -
+
보험 정보가 없습니다.
)} @@ -647,12 +647,9 @@ function getSpreadSeverity( // Helper Components const BADGE_STYLES: Record = { red: 'bg-[rgba(239,68,68,0.08)] text-color-danger border border-[rgba(239,68,68,0.25)]', - orange: - 'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]', - yellow: - 'bg-[rgba(234,179,8,0.08)] text-color-caution border border-[rgba(234,179,8,0.25)]', - green: - 'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]', + orange: 'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]', + yellow: 'bg-[rgba(234,179,8,0.08)] text-color-caution border border-[rgba(234,179,8,0.25)]', + green: 'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]', }; function Section({ @@ -699,7 +696,7 @@ function ControlledCheckbox({ return (
); @@ -738,7 +735,7 @@ function StatBox({ function PredictionCard({ value, label, color }: { value: string; label: string; color: string }) { return (
- {label} + {label} {value}
); @@ -747,7 +744,7 @@ function PredictionCard({ value, label, color }: { value: string; label: string; function ProgressBar({ label, value, color }: { label: string; value: number; color: string }) { return (
- + {label}

{title}

- {expanded ? '▾' : '▸'} + {expanded ? '▾' : '▸'}
{expanded && children}
@@ -796,7 +793,7 @@ function SpecCard({ value, label, color }: { value: string; label: string; color
{value}
-
{label}
+
{label}
); } @@ -814,7 +811,7 @@ function InfoRow({ }) { return (
- {label} + {label} {items.map((item, i) => (
- {item.label} + {item.label} {result.area.toFixed(2)}
-
분석면적(km²)
+
분석면적(km²)
{result.particlePercent}%
-
오염비율
+
오염비율
{pollutedArea}
-
오염면적(km²)
+
오염면적(km²)
{summary && (
- 해상잔존량 + 해상잔존량 {summary.remainingVolume.toFixed(2)} m³ @@ -960,14 +957,14 @@ function PollResult({ )} {summary && (
- 연안부착량 + 연안부착량 {summary.beachedVolume.toFixed(2)} m³
)}
- 민감자원 포함 + 민감자원 포함 {result.sensitiveCount}개소 @@ -976,7 +973,7 @@ function PollResult({
diff --git a/frontend/src/tabs/prediction/components/leftPanelTypes.ts b/frontend/src/tabs/prediction/components/leftPanelTypes.ts index 4ade4ad..ce0c561 100644 --- a/frontend/src/tabs/prediction/components/leftPanelTypes.ts +++ b/frontend/src/tabs/prediction/components/leftPanelTypes.ts @@ -40,6 +40,8 @@ export interface LeftPanelProps { // 오일펜스 배치 관련 boomLines: BoomLine[]; onBoomLinesChange: (lines: BoomLine[]) => void; + showBoomLines: boolean; + onShowBoomLinesChange: (show: boolean) => void; oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>; algorithmSettings: AlgorithmSettings; onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void; diff --git a/frontend/src/tabs/rescue/components/RescueScenarioView.tsx b/frontend/src/tabs/rescue/components/RescueScenarioView.tsx index 9072508..e504353 100755 --- a/frontend/src/tabs/rescue/components/RescueScenarioView.tsx +++ b/frontend/src/tabs/rescue/components/RescueScenarioView.tsx @@ -120,10 +120,19 @@ const SCENARIO_MGMT_GUIDELINES = [ /* ─── Mock 시나리오 (API 미연결 시 폴백) — 긴급구난 모델 이론 기반 10개 ─── */ const MOCK_SCENARIOS: RescueScenarioItem[] = [ { - scenarioSn: 1, rescueOpsSn: 1, timeStep: 'T+0h', - scenarioDtm: '2024-10-27T01:30:00.000Z', svrtCd: 'CRITICAL', - gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0, oilRateLpm: 100.0, bmRatioPct: 92.0, - description: '좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.', + scenarioSn: 1, + rescueOpsSn: 1, + timeStep: 'T+0h', + scenarioDtm: '2024-10-27T01:30:00.000Z', + svrtCd: 'CRITICAL', + gmM: 0.8, + listDeg: 15.0, + trimM: 2.5, + buoyancyPct: 30.0, + oilRateLpm: 100.0, + bmRatioPct: 92.0, + description: + '좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' }, @@ -146,10 +155,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 1, }, { - scenarioSn: 2, rescueOpsSn: 1, timeStep: 'T+30m', - scenarioDtm: '2024-10-27T02:00:00.000Z', svrtCd: 'CRITICAL', - gmM: 0.7, listDeg: 17.0, trimM: 2.8, buoyancyPct: 28.0, oilRateLpm: 120.0, bmRatioPct: 90.0, - description: '잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min.', + scenarioSn: 2, + rescueOpsSn: 1, + timeStep: 'T+30m', + scenarioDtm: '2024-10-27T02:00:00.000Z', + svrtCd: 'CRITICAL', + gmM: 0.7, + listDeg: 17.0, + trimM: 2.8, + buoyancyPct: 28.0, + oilRateLpm: 120.0, + bmRatioPct: 90.0, + description: + '잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' }, @@ -172,10 +190,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 2, }, { - scenarioSn: 3, rescueOpsSn: 1, timeStep: 'T+1h', - scenarioDtm: '2024-10-27T02:30:00.000Z', svrtCd: 'CRITICAL', - gmM: 0.65, listDeg: 18.5, trimM: 3.0, buoyancyPct: 26.0, oilRateLpm: 135.0, bmRatioPct: 89.0, - description: '해경 3009함 현장 도착, SAR 작전 개시. Leeway 표류 예측 모델: 풍속 8m/s, 해류 2.5kn NE — 실종자 표류 반경 1.2nm. GZ 최대 복원력 각도 25°로 감소.', + scenarioSn: 3, + rescueOpsSn: 1, + timeStep: 'T+1h', + scenarioDtm: '2024-10-27T02:30:00.000Z', + svrtCd: 'CRITICAL', + gmM: 0.65, + listDeg: 18.5, + trimM: 3.0, + buoyancyPct: 26.0, + oilRateLpm: 135.0, + bmRatioPct: 89.0, + description: + '해경 3009함 현장 도착, SAR 작전 개시. Leeway 표류 예측 모델: 풍속 8m/s, 해류 2.5kn NE — 실종자 표류 반경 1.2nm. GZ 최대 복원력 각도 25°로 감소.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' }, @@ -198,10 +225,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 3, }, { - scenarioSn: 4, rescueOpsSn: 1, timeStep: 'T+2h', - scenarioDtm: '2024-10-27T03:30:00.000Z', svrtCd: 'CRITICAL', - gmM: 0.5, listDeg: 20.0, trimM: 3.5, buoyancyPct: 22.0, oilRateLpm: 160.0, bmRatioPct: 86.0, - description: '격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = 0.5m. 종강도: Sagging 모멘트 86%. 침몰 위험 단계 진입.', + scenarioSn: 4, + rescueOpsSn: 1, + timeStep: 'T+2h', + scenarioDtm: '2024-10-27T03:30:00.000Z', + svrtCd: 'CRITICAL', + gmM: 0.5, + listDeg: 20.0, + trimM: 3.5, + buoyancyPct: 22.0, + oilRateLpm: 160.0, + bmRatioPct: 86.0, + description: + '격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = 0.5m. 종강도: Sagging 모멘트 86%. 침몰 위험 단계 진입.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' }, @@ -224,10 +260,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 4, }, { - scenarioSn: 5, rescueOpsSn: 1, timeStep: 'T+3h', - scenarioDtm: '2024-10-27T04:30:00.000Z', svrtCd: 'HIGH', - gmM: 0.55, listDeg: 16.0, trimM: 3.2, buoyancyPct: 25.0, oilRateLpm: 140.0, bmRatioPct: 87.0, - description: 'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입, 횡경사 20°→16° 교정. 종강도: 중량 재배분으로 BM 87% 유지.', + scenarioSn: 5, + rescueOpsSn: 1, + timeStep: 'T+3h', + scenarioDtm: '2024-10-27T04:30:00.000Z', + svrtCd: 'HIGH', + gmM: 0.55, + listDeg: 16.0, + trimM: 3.2, + buoyancyPct: 25.0, + oilRateLpm: 140.0, + bmRatioPct: 87.0, + description: + 'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입, 횡경사 20°→16° 교정. 종강도: 중량 재배분으로 BM 87% 유지.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' }, @@ -250,10 +295,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 5, }, { - scenarioSn: 6, rescueOpsSn: 1, timeStep: 'T+6h', - scenarioDtm: '2024-10-27T07:30:00.000Z', svrtCd: 'HIGH', - gmM: 0.7, listDeg: 12.0, trimM: 2.5, buoyancyPct: 32.0, oilRateLpm: 80.0, bmRatioPct: 90.0, - description: '수중패치 설치, 유입률 감소. GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족.', + scenarioSn: 6, + rescueOpsSn: 1, + timeStep: 'T+6h', + scenarioDtm: '2024-10-27T07:30:00.000Z', + svrtCd: 'HIGH', + gmM: 0.7, + listDeg: 12.0, + trimM: 2.5, + buoyancyPct: 32.0, + oilRateLpm: 80.0, + bmRatioPct: 90.0, + description: + '수중패치 설치, 유입률 감소. GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, @@ -276,10 +330,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 6, }, { - scenarioSn: 7, rescueOpsSn: 1, timeStep: 'T+8h', - scenarioDtm: '2024-10-27T09:30:00.000Z', svrtCd: 'MEDIUM', - gmM: 0.8, listDeg: 10.0, trimM: 2.0, buoyancyPct: 38.0, oilRateLpm: 55.0, bmRatioPct: 91.0, - description: '오일붐 2중 전개, 유회수기 3대 가동. GNOME 확산 모델: 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35%.', + scenarioSn: 7, + rescueOpsSn: 1, + timeStep: 'T+8h', + scenarioDtm: '2024-10-27T09:30:00.000Z', + svrtCd: 'MEDIUM', + gmM: 0.8, + listDeg: 10.0, + trimM: 2.0, + buoyancyPct: 38.0, + oilRateLpm: 55.0, + bmRatioPct: 91.0, + description: + '오일붐 2중 전개, 유회수기 3대 가동. GNOME 확산 모델: 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35%.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, @@ -302,10 +365,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 7, }, { - scenarioSn: 8, rescueOpsSn: 1, timeStep: 'T+12h', - scenarioDtm: '2024-10-27T13:30:00.000Z', svrtCd: 'MEDIUM', - gmM: 0.9, listDeg: 8.0, trimM: 1.5, buoyancyPct: 45.0, oilRateLpm: 30.0, bmRatioPct: 94.0, - description: '예인 개시. 예인 저항 Rt=1/2·ρ·Cd·A·V² 기반 4,000HP급 배정. 목포항 42nm, 예인 속도 3kn, ETA 14h.', + scenarioSn: 8, + rescueOpsSn: 1, + timeStep: 'T+12h', + scenarioDtm: '2024-10-27T13:30:00.000Z', + svrtCd: 'MEDIUM', + gmM: 0.9, + listDeg: 8.0, + trimM: 1.5, + buoyancyPct: 45.0, + oilRateLpm: 30.0, + bmRatioPct: 94.0, + description: + '예인 개시. 예인 저항 Rt=1/2·ρ·Cd·A·V² 기반 4,000HP급 배정. 목포항 42nm, 예인 속도 3kn, ETA 14h.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, @@ -328,10 +400,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 8, }, { - scenarioSn: 9, rescueOpsSn: 1, timeStep: 'T+18h', - scenarioDtm: '2024-10-27T19:30:00.000Z', svrtCd: 'MEDIUM', - gmM: 1.0, listDeg: 5.0, trimM: 1.0, buoyancyPct: 55.0, oilRateLpm: 15.0, bmRatioPct: 96.0, - description: '예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s — 횡동요 ±3° 안전 범위. 잔류 유출률 15 L/min.', + scenarioSn: 9, + rescueOpsSn: 1, + timeStep: 'T+18h', + scenarioDtm: '2024-10-27T19:30:00.000Z', + svrtCd: 'MEDIUM', + gmM: 1.0, + listDeg: 5.0, + trimM: 1.0, + buoyancyPct: 55.0, + oilRateLpm: 15.0, + bmRatioPct: 96.0, + description: + '예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s — 횡동요 ±3° 안전 범위. 잔류 유출률 15 L/min.', compartments: [ { name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' }, { name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' }, @@ -354,10 +435,19 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ sortOrd: 9, }, { - scenarioSn: 10, rescueOpsSn: 1, timeStep: 'T+24h', - scenarioDtm: '2024-10-28T01:30:00.000Z', svrtCd: 'RESOLVED', - gmM: 1.2, listDeg: 3.0, trimM: 0.5, buoyancyPct: 75.0, oilRateLpm: 5.0, bmRatioPct: 98.0, - description: '목포항 접안 완료. 잔류유 전량 이적(120kL). 최종 GM 1.2m IMO 충족, BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료.', + scenarioSn: 10, + rescueOpsSn: 1, + timeStep: 'T+24h', + scenarioDtm: '2024-10-28T01:30:00.000Z', + svrtCd: 'RESOLVED', + gmM: 1.2, + listDeg: 3.0, + trimM: 0.5, + buoyancyPct: 75.0, + oilRateLpm: 5.0, + bmRatioPct: 98.0, + description: + '목포항 접안 완료. 잔류유 전량 이적(120kL). 최종 GM 1.2m IMO 충족, BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료.', compartments: [ { name: '#1 FP Tank', status: 'SEALED', color: 'var(--orange)' }, { name: '#1 Port Tank', status: 'SEALED', color: 'var(--orange)' }, @@ -383,15 +473,30 @@ const MOCK_SCENARIOS: RescueScenarioItem[] = [ const MOCK_OPS: RescueOpsItem[] = [ { - rescueOpsSn: 1, acdntSn: 1, opsCd: 'RSC-2026-001', acdntTpCd: 'collision', - vesselNm: 'M/V SEA GUARDIAN', commanderNm: null, - lon: 126.25, lat: 37.467, locDc: '37°28\'N, 126°15\'E', - depthM: 25.0, currentDc: '2.5kn NE', - gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0, - oilRateLpm: 100.0, bmRatioPct: 92.0, - totalCrew: 20, survivors: 15, missing: 5, - hydroData: null, gmdssData: null, - sttsCd: 'ACTIVE', regDtm: '2024-10-27T01:30:00.000Z', + rescueOpsSn: 1, + acdntSn: 1, + opsCd: 'RSC-2026-001', + acdntTpCd: 'collision', + vesselNm: 'M/V SEA GUARDIAN', + commanderNm: null, + lon: 126.25, + lat: 37.467, + locDc: "37°28'N, 126°15'E", + depthM: 25.0, + currentDc: '2.5kn NE', + gmM: 0.8, + listDeg: 15.0, + trimM: 2.5, + buoyancyPct: 30.0, + oilRateLpm: 100.0, + bmRatioPct: 92.0, + totalCrew: 20, + survivors: 15, + missing: 5, + hydroData: null, + gmdssData: null, + sttsCd: 'ACTIVE', + regDtm: '2024-10-27T01:30:00.000Z', }, ]; @@ -698,7 +803,9 @@ export function RescueScenarioView() {
{/* View content */} -
+
{/* ─── VIEW 0: 시나리오 상세 ─── */} {detailView === 0 && selected && (
@@ -1039,9 +1146,23 @@ function ScenarioMapOverlay({ maxWidth="320px" className="rescue-map-popup" > -
+
- + {sc.id} {sc.timeStep} @@ -1059,16 +1180,38 @@ function ScenarioMapOverlay({ {sev.label}
-
+
{sc.description}
{/* KPI */} -
+
{[ { label: 'GM', value: `${sc.gm}m`, color: gmColor(parseFloat(sc.gm)) }, - { label: '횡경사', value: `${sc.list}°`, color: listColor(parseFloat(sc.list)) }, + { + label: '횡경사', + value: `${sc.list}°`, + color: listColor(parseFloat(sc.list)), + }, { label: '부력', value: `${sc.buoyancy}%`, color: buoyColor(sc.buoyancy) }, - { label: '유출', value: sc.oilRate.split(' ')[0], color: oilColor(parseFloat(sc.oilRate)) }, + { + label: '유출', + value: sc.oilRate.split(' ')[0], + color: oilColor(parseFloat(sc.oilRate)), + }, ].map((m) => (
{m.label}
-
{m.value}
+
+ {m.value} +
))}
{/* 구획 상태 */} {sc.compartments.length > 0 && (
-
+
구획 상태
@@ -1122,11 +1274,16 @@ function ScenarioMapOverlay({ style={{ background: 'rgba(15,23,42,0.92)', width: 280, backdropFilter: 'blur(8px)' }} >
- {selected.id} + + {selected.id} + {selected.timeStep} {SEV_STYLE[selected.severity].label} @@ -1134,14 +1291,34 @@ function ScenarioMapOverlay({
{[ - { label: 'GM', value: `${selected.gm}m`, color: gmColor(parseFloat(selected.gm)) }, - { label: '횡경사', value: `${selected.list}°`, color: listColor(parseFloat(selected.list)) }, - { label: '부력', value: `${selected.buoyancy}%`, color: buoyColor(selected.buoyancy) }, - { label: '유출', value: selected.oilRate.split(' ')[0], color: oilColor(parseFloat(selected.oilRate)) }, + { + label: 'GM', + value: `${selected.gm}m`, + color: gmColor(parseFloat(selected.gm)), + }, + { + label: '횡경사', + value: `${selected.list}°`, + color: listColor(parseFloat(selected.list)), + }, + { + label: '부력', + value: `${selected.buoyancy}%`, + color: buoyColor(selected.buoyancy), + }, + { + label: '유출', + value: selected.oilRate.split(' ')[0], + color: oilColor(parseFloat(selected.oilRate)), + }, ].map((m) => (
-
{m.label}
-
{m.value}
+
+ {m.label} +
+
+ {m.value} +
))}
@@ -1171,7 +1348,12 @@ function ScenarioMapOverlay({
사고 위치
diff --git a/frontend/src/tabs/rescue/components/RescueView.tsx b/frontend/src/tabs/rescue/components/RescueView.tsx index 59d27d7..114e10c 100755 --- a/frontend/src/tabs/rescue/components/RescueView.tsx +++ b/frontend/src/tabs/rescue/components/RescueView.tsx @@ -1541,26 +1541,23 @@ export function RescueView() { }, []); // 사고 선택 시 사고유형 자동 매핑 - const handleSelectAcdnt = useCallback( - (item: IncidentListItem | null) => { - setSelectedAcdnt(item); - if (item) { - const typeMap: Record = { - collision: 'collision', - grounding: 'grounding', - turning: 'turning', - capsizing: 'capsizing', - sharpTurn: 'sharpTurn', - flooding: 'flooding', - sinking: 'sinking', - }; - const mapped = typeMap[item.acdntTpCd]; - if (mapped) setActiveType(mapped); - setIncidentCoord({ lon: item.lng, lat: item.lat }); - } - }, - [], - ); + const handleSelectAcdnt = useCallback((item: IncidentListItem | null) => { + setSelectedAcdnt(item); + if (item) { + const typeMap: Record = { + collision: 'collision', + grounding: 'grounding', + turning: 'turning', + capsizing: 'capsizing', + sharpTurn: 'sharpTurn', + flooding: 'flooding', + sinking: 'sinking', + }; + const mapped = typeMap[item.acdntTpCd]; + if (mapped) setActiveType(mapped); + setIncidentCoord({ lon: item.lng, lat: item.lat }); + } + }, []); if (activeSubTab === 'list') { return ( diff --git a/frontend/src/tabs/scat/components/ScatLeftPanel.tsx b/frontend/src/tabs/scat/components/ScatLeftPanel.tsx index 0e8b725..992e18f 100644 --- a/frontend/src/tabs/scat/components/ScatLeftPanel.tsx +++ b/frontend/src/tabs/scat/components/ScatLeftPanel.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, type CSSProperties, type ReactElement } fr import { List } from 'react-window'; import type { ScatSegment } from './scatTypes'; import type { ApiZoneItem } from '../services/scatApi'; -import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants'; +import { esiLevel } from './scatConstants'; interface ScatLeftPanelProps { segments: ScatSegment[]; @@ -71,8 +71,8 @@ function SegRow( 📍 {seg.code} {seg.area} ESI {seg.esi} @@ -89,8 +89,8 @@ function SegRow(
민감 {seg.sensitivity} @@ -98,8 +98,9 @@ function SegRow(
현황 {seg.status} @@ -160,7 +161,7 @@ function ScatLeftPanel({ {/* Filters */}
- + 해안 조사 구역
@@ -257,12 +258,10 @@ function ScatLeftPanel({
- + 해안 구간 목록 - - 총 {filtered.length}개 구간 - + 총 {filtered.length}개 구간
diff --git a/frontend/src/tabs/scat/components/ScatMap.tsx b/frontend/src/tabs/scat/components/ScatMap.tsx index 9fb5227..8dd2f4b 100644 --- a/frontend/src/tabs/scat/components/ScatMap.tsx +++ b/frontend/src/tabs/scat/components/ScatMap.tsx @@ -1,11 +1,8 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; -import { Map, useControl, useMap } from '@vis.gl/react-maplibre'; -import { MapboxOverlay } from '@deck.gl/mapbox'; +import { useMap } from '@vis.gl/react-maplibre'; import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'; -import 'maplibre-gl/dist/maplibre-gl.css'; -import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; -import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; -import { useMapStore } from '@common/store/mapStore'; +import { BaseMap } from '@common/components/map/BaseMap'; +import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay'; import type { ScatSegment } from './scatTypes'; import type { ApiZoneItem } from '../services/scatApi'; import { esiColor } from './scatConstants'; @@ -20,16 +17,9 @@ interface ScatMapProps { onOpenPopup: (idx: number) => void; } -// ── DeckGLOverlay ────────────────────────────────────── -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function DeckGLOverlay({ layers }: { layers: any[] }) { - const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); - overlay.setProps({ layers }); - return null; -} - -// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ───────── -function FlyToController({ +// ── FlyTo: 선택 구간·관할해경 변경 시 맵 이동 ────────────── +// 두 가지 트리거를 독립적으로 처리해 공통 FlyToController로 통합 불가 +function ScatFlyToController({ selectedSeg, zones, }: { @@ -40,7 +30,7 @@ function FlyToController({ const prevIdRef = useRef(undefined); const prevZonesLenRef = useRef(0); - // 선택 구간 변경 시 + // 선택 구간 변경 시 이동 (첫 렌더 제외) useEffect(() => { if (!map) return; if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) { @@ -49,7 +39,7 @@ function FlyToController({ prevIdRef.current = selectedSeg.id; }, [map, selectedSeg]); - // 관할해경(zones) 변경 시 지도 중심 이동 + // 관할해경(zones) 변경 시 중심 이동 useEffect(() => { if (!map || zones.length === 0) return; if (prevZonesLenRef.current === zones.length) return; @@ -72,13 +62,11 @@ function getZoomScale(zoom: number) { selPolyWidth: 2 + zScale * 5, glowWidth: 4 + zScale * 14, halfLenScale: 0.15 + zScale * 0.85, - markerRadius: Math.round(6 + zScale * 16), - showStatusMarker: zoom >= 11, + dotRadius: Math.round(4 + zScale * 10), }; } // ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ────────── -// 인접 구간 좌표로 해안선 방향을 동적 계산 function buildSegCoords( seg: ScatSegment, halfLenScale: number, @@ -100,7 +88,6 @@ function buildSegCoords( ]; } -// ── 툴팁 상태 ─────────────────────────────────────────── interface TooltipState { x: number; y: number; @@ -116,12 +103,19 @@ function ScatMap({ onSelectSeg, onOpenPopup, }: ScatMapProps) { - const currentMapStyle = useBaseMapStyle(); - const mapToggles = useMapStore((s) => s.mapToggles); - const [zoom, setZoom] = useState(10); const [tooltip, setTooltip] = useState(null); + // zones 첫 렌더 기준으로 초기 중심 좌표 결정 (이후 불변) + const [initialCenter] = useState<[number, number]>(() => + zones.length > 0 + ? [ + zones.reduce((a, z) => a + z.latCenter, 0) / zones.length, + zones.reduce((a, z) => a + z.lngCenter, 0) / zones.length, + ] + : [33.38, 126.55], + ); + const handleClick = useCallback( (seg: ScatSegment) => { onSelectSeg(seg); @@ -132,23 +126,6 @@ function ScatMap({ const zs = useMemo(() => getZoomScale(zoom), [zoom]); - // 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정 - // const coastlineLayer = useMemo( - // () => - // new PathLayer({ - // id: 'jeju-coastline', - // data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }], - // getPath: (d: { path: [number, number][] }) => d.path, - // getColor: [6, 182, 212, 46], - // getWidth: 1.5, - // getDashArray: [8, 6], - // dashJustified: true, - // widthMinPixels: 1, - // }), - // [], - // ) - - // 선택된 구간 글로우 레이어 const glowLayer = useMemo( () => new PathLayer({ @@ -168,7 +145,6 @@ function ScatMap({ [selectedSeg, segments, zs.glowWidth, zs.halfLenScale], ); - // ESI 색상 세그먼트 폴리라인 const segPathLayer = useMemo( () => new PathLayer({ @@ -183,14 +159,11 @@ function ScatMap({ getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth), capRounded: true, jointRounded: true, - widthMinPixels: 1, + widthMinPixels: 2, pickable: true, onHover: (info: { object?: ScatSegment; x: number; y: number }) => { - if (info.object) { - setTooltip({ x: info.x, y: info.y, seg: info.object }); - } else { - setTooltip(null); - } + if (info.object) setTooltip({ x: info.x, y: info.y, seg: info.object }); + else setTooltip(null); }, onClick: (info: { object?: ScatSegment }) => { if (info.object) handleClick(info.object); @@ -204,46 +177,58 @@ function ScatMap({ [segments, selectedSeg, zs, handleClick], ); - // 조사 상태 마커 (줌 >= 11 시 표시) - const markerLayer = useMemo(() => { - if (!zs.showStatusMarker) return null; - return new ScatterplotLayer({ - id: 'scat-status-markers', - data: segments, - getPosition: (d: ScatSegment) => [d.lng, d.lat], - getRadius: zs.markerRadius, - getFillColor: (d: ScatSegment) => { - if (d.status === '완료') return [34, 197, 94, 51]; - if (d.status === '진행중') return [234, 179, 8, 51]; - return [100, 116, 139, 51]; - }, - getLineColor: (d: ScatSegment) => { - if (d.status === '완료') return [34, 197, 94, 200]; - if (d.status === '진행중') return [234, 179, 8, 200]; - return [100, 116, 139, 200]; - }, - getLineWidth: 1, - stroked: true, - radiusMinPixels: 4, - radiusMaxPixels: 22, - radiusUnits: 'pixels', - pickable: true, - onClick: (info: { object?: ScatSegment }) => { - if (info.object) handleClick(info.object); - }, - updateTriggers: { - getRadius: [zs.markerRadius], - }, - }); - }, [segments, zs.showStatusMarker, zs.markerRadius, handleClick]); + const shadowDotLayer = useMemo( + () => + new ScatterplotLayer({ + id: 'scat-shadow-dots', + data: segments, + getPosition: (d) => [d.lng, d.lat], + getRadius: zs.dotRadius + 2, + getFillColor: [0, 0, 0, 70], + stroked: false, + radiusUnits: 'pixels', + radiusMinPixels: 7, + radiusMaxPixels: 18, + pickable: false, + updateTriggers: { getRadius: [zs.dotRadius] }, + }), + [segments, zs.dotRadius], + ); + + const dotLayer = useMemo( + () => + new ScatterplotLayer({ + id: 'scat-dots', + data: segments, + getPosition: (d) => [d.lng, d.lat], + getRadius: zs.dotRadius, + getFillColor: (d) => { + if (d.status === '완료') return [34, 197, 94, 210]; + if (d.status === '진행중') return [234, 179, 8, 210]; + return [148, 163, 184, 200]; + }, + stroked: false, + radiusUnits: 'pixels', + radiusMinPixels: 5, + radiusMaxPixels: 16, + pickable: true, + onHover: (info: { object?: ScatSegment; x: number; y: number }) => { + if (info.object) setTooltip({ x: info.x, y: info.y, seg: info.object }); + else setTooltip(null); + }, + onClick: (info: { object?: ScatSegment }) => { + if (info.object) handleClick(info.object); + }, + updateTriggers: { getRadius: [zs.dotRadius] }, + }), + [segments, zs.dotRadius, handleClick], + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const deckLayers: any[] = useMemo(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const layers: any[] = [glowLayer, segPathLayer]; - if (markerLayer) layers.push(markerLayer); - return layers; - }, [glowLayer, segPathLayer, markerLayer]); + const deckLayers: any[] = useMemo( + () => [glowLayer, segPathLayer, shadowDotLayer, dotLayer], + [glowLayer, segPathLayer, shadowDotLayer, dotLayer], + ); const totalLen = segments.reduce((a, s) => a + s.lengthM, 0); const doneLen = segments.filter((s) => s.status === '완료').reduce((a, s) => a + s.lengthM, 0); @@ -253,24 +238,10 @@ function ScatMap({ return (
- { - if (zones.length > 0) { - const avgLng = zones.reduce((a, z) => a + z.lngCenter, 0) / zones.length; - const avgLat = zones.reduce((a, z) => a + z.latCenter, 0) / zones.length; - return { longitude: avgLng, latitude: avgLat, zoom: 10 }; - } - return { longitude: 126.55, latitude: 33.38, zoom: 10 }; - })()} - mapStyle={currentMapStyle} - className="w-full h-full" - attributionControl={false} - onZoom={(e) => setZoom(e.viewState.zoom)} - > - + - - + + {/* 호버 툴팁 */} {tooltip && ( @@ -287,11 +258,9 @@ function ScatMap({ whiteSpace: 'nowrap', }} > -
- {tooltip.seg.code} {tooltip.seg.area} -
+
{tooltip.seg.name}
- ESI {tooltip.seg.esi} · {tooltip.seg.length} ·{' '} + {tooltip.seg.code} · ESI {tooltip.seg.esi} ·{' '} {tooltip.seg.status === '완료' ? '✓' : tooltip.seg.status === '진행중' ? '⏳' : '—'}{' '} {tooltip.seg.status}
@@ -301,7 +270,7 @@ function ScatMap({ {/* Status chips */}
- + Pre-SCAT 사전조사
@@ -342,25 +311,6 @@ function ScatMap({
조사 정보
- {/*
-
-
-
-
*/} - {/*
- 완료 {donePct}% - 진행 {progPct}% - 미조사 {notPct}% -
*/}
{[ ['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''], @@ -388,19 +338,6 @@ function ScatMap({
- - {/* Coordinates */} - {/*
- - 위도 {selectedSeg.lat.toFixed(4)}°N - - - 경도 {selectedSeg.lng.toFixed(4)}°E - - - 축척 1:25,000 - -
*/}
); } diff --git a/frontend/src/tabs/scat/components/ScatRightPanel.tsx b/frontend/src/tabs/scat/components/ScatRightPanel.tsx index f43a8d8..8b9af91 100644 --- a/frontend/src/tabs/scat/components/ScatRightPanel.tsx +++ b/frontend/src/tabs/scat/components/ScatRightPanel.tsx @@ -70,7 +70,8 @@ export default function ScatRightPanel({ : 'text-fg-disabled hover:text-fg-sub' }`} > - {tab.icon} {tab.label} + {/* {tab.icon} */} + {tab.label} ))}
diff --git a/frontend/src/tabs/weather/components/WeatherView.tsx b/frontend/src/tabs/weather/components/WeatherView.tsx index 783c0ee..b2edfd2 100755 --- a/frontend/src/tabs/weather/components/WeatherView.tsx +++ b/frontend/src/tabs/weather/components/WeatherView.tsx @@ -1,12 +1,8 @@ import { useState, useMemo, useCallback } from 'react'; -import { Map, Marker, useControl } from '@vis.gl/react-maplibre'; -import { MapboxOverlay } from '@deck.gl/mapbox'; -import type { Layer } from '@deck.gl/core'; -import type { MapLayerMouseEvent } from 'maplibre-gl'; +import { Marker } from '@vis.gl/react-maplibre'; import 'maplibre-gl/dist/maplibre-gl.css'; -import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; -import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; -import { useMapStore } from '@common/store/mapStore'; +import { BaseMap } from '@common/components/map/BaseMap'; +import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay'; import { WeatherRightPanel } from './WeatherRightPanel'; import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'; // import { OceanForecastOverlay } from './OceanForecastOverlay' @@ -16,7 +12,6 @@ import { WindParticleLayer } from './WindParticleLayer'; import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'; import { useWeatherData } from '../hooks/useWeatherData'; // import { useOceanForecast } from '../hooks/useOceanForecast' -import { WeatherMapControls } from './WeatherMapControls'; import { degreesToCardinal } from '../services/weatherUtils'; type TimeOffset = '0' | '3' | '6' | '9'; @@ -89,13 +84,6 @@ const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => { const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat] const WEATHER_MAP_ZOOM = 7; -// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록) -function DeckGLOverlay({ layers }: { layers: Layer[] }) { - const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); - overlay.setProps({ layers }); - return null; -} - /** * WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역) */ @@ -104,8 +92,6 @@ interface WeatherMapInnerProps { enabledLayers: Set; selectedStationId: string | null; onStationClick: (station: WeatherStation) => void; - mapCenter: [number, number]; - mapZoom: number; clickedLocation: { lat: number; lon: number } | null; } @@ -114,8 +100,6 @@ function WeatherMapInner({ enabledLayers, selectedStationId, onStationClick, - mapCenter, - mapZoom, clickedLocation, }: WeatherMapInnerProps) { // deck.gl layers 조합 @@ -183,17 +167,12 @@ function WeatherMapInner({
)} - - {/* 줌 컨트롤 */} - ); } export function WeatherView() { const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS); - const currentMapStyle = useBaseMapStyle(); - const mapToggles = useMapStore((s) => s.mapToggles); // const { // selectedForecast, @@ -220,8 +199,7 @@ export function WeatherView() { }, []); const handleMapClick = useCallback( - (e: MapLayerMouseEvent) => { - const { lat, lng } = e.lngLat; + (lng: number, lat: number) => { if (weatherStations.length === 0) return; // 가장 가까운 관측소 선택 @@ -331,28 +309,19 @@ export function WeatherView() { {/* Map */}
- - - + {/* 레이어 컨트롤 */}
From 388116aa889422a106f14223e91ba98abd86579d Mon Sep 17 00:00:00 2001 From: leedano Date: Tue, 14 Apr 2026 17:32:08 +0900 Subject: [PATCH 3/8] =?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 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 7415bfa..8dd07de 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 변경 +- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용 + ## [2026-04-14] ### 추가 From 679649ab8cf1802988aa7245973778fea7ec1c44 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 15 Apr 2026 08:16:09 +0900 Subject: [PATCH 4/8] =?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 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 8dd07de..1095133 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 추가 +- 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동) + ### 변경 - MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용 From 6b19d34e5b66a547898c9d45892aa48c38617b50 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 15 Apr 2026 08:22:57 +0900 Subject: [PATCH 5/8] =?UTF-8?q?chore:=20=ED=8C=80=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=84=A4=EC=A0=95=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings.json Bash 권한 항목 세분화 - workflow-version.json 적용일 갱신 (2026-04-14) --- .claude/settings.json | 25 ++++++++++++++++++++++++- .claude/workflow-version.json | 2 +- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 441dc35..c8c5d77 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,30 @@ }, "permissions": { "allow": [ - "Bash(*)" + "Bash(*)", + "Bash(npm run *)", + "Bash(npm install *)", + "Bash(npm test *)", + "Bash(npx *)", + "Bash(node *)", + "Bash(git status)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git branch *)", + "Bash(git checkout *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git pull *)", + "Bash(git fetch *)", + "Bash(git merge *)", + "Bash(git stash *)", + "Bash(git remote *)", + "Bash(git config *)", + "Bash(git rev-parse *)", + "Bash(git show *)", + "Bash(git tag *)", + "Bash(curl -s *)", + "Bash(fnm *)" ], "deny": [ "Bash(git push --force*)", diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 03381a9..ffa771a 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,6 +1,6 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-31", + "applied_date": "2026-04-14", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true From 29c5293ce7e1754ea56c2566f02ea16b5f39b183 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 15 Apr 2026 14:40:28 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat(vessels):=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=84=A0=EB=B0=95=20=EC=8B=A0=ED=98=B8=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=ED=91=9C=EC=B6=9C=20=EB=B0=8F=20=ED=8F=B4=EB=A7=81?= =?UTF-8?q?=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드 vessels 라우터/서비스/스케줄러 추가 (1분 주기 한국 해역 폴링) - 공통 useVesselSignals 훅 + vesselApi/vesselSignalClient 서비스 추가 - MapView에 VesselLayer/VesselInteraction/MapBoundsTracker 통합 (호버·팝업·상세 모달) - OilSpill/HNS/Rescue/Incidents 뷰에 선박 신호 연동 - vesselMockData 정리, aerial IMAGE_API_URL 기본값 변경 --- .../hooks/scripts/recordToolUse.ps1 | 17 + .../hooks/scripts/recordToolUse.sh | 27 + backend/src/aerial/aerialService.ts | 2 +- backend/src/server.ts | 6 + backend/src/vessels/vesselRouter.ts | 33 + backend/src/vessels/vesselScheduler.ts | 96 +++ backend/src/vessels/vesselService.ts | 55 ++ backend/src/vessels/vesselTypes.ts | 26 + .../components/map/MapBoundsTracker.tsx | 42 ++ .../src/common/components/map/MapView.tsx | 62 ++ .../components/map/VesselInteraction.tsx | 647 ++++++++++++++++++ .../src/common/components/map/VesselLayer.ts | 133 ++++ frontend/src/common/hooks/useVesselSignals.ts | 79 +++ frontend/src/common/mock/vesselMockData.ts | 617 +---------------- frontend/src/common/services/vesselApi.ts | 35 + .../src/common/services/vesselSignalClient.ts | 125 ++++ frontend/src/common/types/vessel.ts | 26 + frontend/src/tabs/hns/components/HNSView.tsx | 6 + .../incidents/components/IncidentsView.tsx | 305 +++++---- .../prediction/components/OilSpillView.tsx | 6 + .../src/tabs/rescue/components/RescueView.tsx | 6 + 21 files changed, 1596 insertions(+), 755 deletions(-) create mode 100644 .github/java-upgrade/hooks/scripts/recordToolUse.ps1 create mode 100644 .github/java-upgrade/hooks/scripts/recordToolUse.sh create mode 100644 backend/src/vessels/vesselRouter.ts create mode 100644 backend/src/vessels/vesselScheduler.ts create mode 100644 backend/src/vessels/vesselService.ts create mode 100644 backend/src/vessels/vesselTypes.ts create mode 100644 frontend/src/common/components/map/MapBoundsTracker.tsx create mode 100644 frontend/src/common/components/map/VesselInteraction.tsx create mode 100644 frontend/src/common/components/map/VesselLayer.ts create mode 100644 frontend/src/common/hooks/useVesselSignals.ts create mode 100644 frontend/src/common/services/vesselApi.ts create mode 100644 frontend/src/common/services/vesselSignalClient.ts create mode 100644 frontend/src/common/types/vessel.ts diff --git a/.github/java-upgrade/hooks/scripts/recordToolUse.ps1 b/.github/java-upgrade/hooks/scripts/recordToolUse.ps1 new file mode 100644 index 0000000..2d24329 --- /dev/null +++ b/.github/java-upgrade/hooks/scripts/recordToolUse.ps1 @@ -0,0 +1,17 @@ +# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process. + +$raw = [Console]::In.ReadToEnd() + +if ($raw -notmatch '"tool_name"\s*:\s*"([^"]+)"') { exit 0 } +$toolName = $Matches[1] + +if ($toolName -ne 'run_in_terminal' -and $toolName -notlike 'appmod-*') { exit 0 } + +if ($raw -notmatch '"session_id"\s*:\s*"([^"]+)"') { exit 0 } +$sessionId = $Matches[1] + +$hooksDir = '.github\java-upgrade\hooks' +if (-not (Test-Path $hooksDir)) { New-Item -ItemType Directory -Path $hooksDir -Force | Out-Null } + +$line = ($raw -replace '[\r\n]+', ' ').Trim() + "`n" +[System.IO.File]::AppendAllText("$hooksDir\$sessionId.json", $line, [System.Text.UTF8Encoding]::new($false)) diff --git a/.github/java-upgrade/hooks/scripts/recordToolUse.sh b/.github/java-upgrade/hooks/scripts/recordToolUse.sh new file mode 100644 index 0000000..36b2043 --- /dev/null +++ b/.github/java-upgrade/hooks/scripts/recordToolUse.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process. + +INPUT=$(cat) + +TOOL_NAME="${INPUT#*\"tool_name\":\"}" +TOOL_NAME="${TOOL_NAME%%\"*}" + +case "$TOOL_NAME" in + run_in_terminal|appmod-*) ;; + *) exit 0 ;; +esac + +case "$INPUT" in + *'"session_id":"'*) ;; + *) exit 0 ;; +esac + +SESSION_ID="${INPUT#*\"session_id\":\"}" +SESSION_ID="${SESSION_ID%%\"*}" +[ -z "$SESSION_ID" ] && exit 0 + +HOOKS_DIR=".github/java-upgrade/hooks" +mkdir -p "$HOOKS_DIR" + +LINE=$(printf '%s' "$INPUT" | tr -d '\r\n') +printf '%s\n' "$LINE" >> "$HOOKS_DIR/${SESSION_ID}.json" diff --git a/backend/src/aerial/aerialService.ts b/backend/src/aerial/aerialService.ts index c3ec980..f8c907c 100644 --- a/backend/src/aerial/aerialService.ts +++ b/backend/src/aerial/aerialService.ts @@ -368,7 +368,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis // OIL INFERENCE (GPU 서버 프록시) // ============================================================ -const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001'; +const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://211.208.115.83:5001'; const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090'; const INFERENCE_TIMEOUT_MS = 10_000; diff --git a/backend/src/server.ts b/backend/src/server.ts index 09999dc..22378ae 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -26,6 +26,8 @@ import aerialRouter from './aerial/aerialRouter.js' import rescueRouter from './rescue/rescueRouter.js' import mapBaseRouter from './map-base/mapBaseRouter.js' import monitorRouter from './monitor/monitorRouter.js' +import vesselRouter from './vessels/vesselRouter.js' +import { startVesselScheduler } from './vessels/vesselScheduler.js' import { sanitizeBody, sanitizeQuery, @@ -177,6 +179,7 @@ app.use('/api/rescue', rescueRouter) app.use('/api/map-base', mapBaseRouter) app.use('/api/monitor', monitorRouter) app.use('/api/tiles', tilesRouter) +app.use('/api/vessels', vesselRouter) // 헬스 체크 app.get('/health', (_req, res) => { @@ -212,6 +215,9 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres app.listen(PORT, async () => { console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) + // 선박 신호 스케줄러 시작 (한국 전 해역 1분 폴링) + startVesselScheduler() + // wing DB 연결 확인 (wing + auth 스키마 통합) const connected = await testWingDbConnection() if (connected) { diff --git a/backend/src/vessels/vesselRouter.ts b/backend/src/vessels/vesselRouter.ts new file mode 100644 index 0000000..601c970 --- /dev/null +++ b/backend/src/vessels/vesselRouter.ts @@ -0,0 +1,33 @@ +import { Router } from 'express'; +import { getVesselsInBounds, getCacheStatus } from './vesselService.js'; +import type { BoundingBox } from './vesselTypes.js'; + +const vesselRouter = Router(); + +// POST /api/vessels/in-area +// 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링) +vesselRouter.post('/in-area', (req, res) => { + const { bounds } = req.body as { bounds?: BoundingBox }; + + if ( + !bounds || + typeof bounds.minLon !== 'number' || + typeof bounds.minLat !== 'number' || + typeof bounds.maxLon !== 'number' || + typeof bounds.maxLat !== 'number' + ) { + res.status(400).json({ error: '유효한 bounds 정보가 필요합니다.' }); + return; + } + + const vessels = getVesselsInBounds(bounds); + res.json(vessels); +}); + +// GET /api/vessels/status — 캐시 상태 확인 (디버그용) +vesselRouter.get('/status', (_req, res) => { + const status = getCacheStatus(); + res.json(status); +}); + +export default vesselRouter; diff --git a/backend/src/vessels/vesselScheduler.ts b/backend/src/vessels/vesselScheduler.ts new file mode 100644 index 0000000..fbcd2c1 --- /dev/null +++ b/backend/src/vessels/vesselScheduler.ts @@ -0,0 +1,96 @@ +import { updateVesselCache } from './vesselService.js'; +import type { VesselPosition } from './vesselTypes.js'; + +const VESSEL_TRACK_API_URL = + process.env.VESSEL_TRACK_API_URL ?? 'https://guide.gc-si.dev/signal-batch'; +const POLL_INTERVAL_MS = 60_000; + +// 개별 쿠키 환경변수를 조합하여 Cookie 헤더 문자열 생성 +function buildVesselCookie(): string { + const entries: [string, string | undefined][] = [ + ['apt.uid', process.env.VESSEL_COOKIE_APT_UID], + ['g_state', process.env.VESSEL_COOKIE_G_STATE], + ['gc_proxy_auth', process.env.VESSEL_COOKIE_GC_PROXY_AUTH], + ['GC_SESSION', process.env.VESSEL_COOKIE_GC_SESSION], + // 기존 단일 쿠키 변수 폴백 (레거시 지원) + ]; + const parts = entries + .filter(([, v]) => v) + .map(([k, v]) => `${k}=${v}`); + + // 기존 VESSEL_TRACK_COOKIE 폴백 (단일 문자열로 설정된 경우) + if (parts.length === 0 && process.env.VESSEL_TRACK_COOKIE) { + return process.env.VESSEL_TRACK_COOKIE; + } + return parts.join('; '); +} + +// 한국 전 해역 고정 폴리곤 (124~132°E, 32~38°N) +const KOREA_WATERS_POLYGON = [ + [120, 31], + [132, 31], + [132, 41], + [120, 41], + [120, 31], +]; + +let intervalId: ReturnType | null = null; + +async function pollVesselSignals(): Promise { + const url = `${VESSEL_TRACK_API_URL}/api/v1/vessels/recent-positions-detail`; + const body = { + minutes: 5, + coordinates: KOREA_WATERS_POLYGON, + polygonFilter: true, + }; + + const cookie = buildVesselCookie(); + const requestHeaders: Record = { + 'Content-Type': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + }; + + try { + const res = await fetch(url, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + console.error(`[vesselScheduler] 선박 신호 API 오류: ${res.status}`, text.substring(0, 200)); + return; + } + + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) { + const text = await res.text().catch(() => ''); + console.error('[vesselScheduler] 선박 신호 API가 JSON이 아닌 응답 반환:', text); + return; + } + + const data = (await res.json()) as VesselPosition[]; + updateVesselCache(data); + } catch (err) { + console.error('[vesselScheduler] 선박 신호 폴링 실패:', err); + } +} + +export function startVesselScheduler(): void { + if (intervalId !== null) return; + + // 서버 시작 시 즉시 1회 실행 후 주기적 폴링 + pollVesselSignals(); + intervalId = setInterval(pollVesselSignals, POLL_INTERVAL_MS); + console.log('[vesselScheduler] 선박 신호 스케줄러 시작 (1분 간격)'); +} + +export function stopVesselScheduler(): void { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + console.log('[vesselScheduler] 선박 신호 스케줄러 중지'); + } +} diff --git a/backend/src/vessels/vesselService.ts b/backend/src/vessels/vesselService.ts new file mode 100644 index 0000000..5b2640c --- /dev/null +++ b/backend/src/vessels/vesselService.ts @@ -0,0 +1,55 @@ +import type { VesselPosition, BoundingBox } from './vesselTypes.js'; + +const VESSEL_TTL_MS = 10 * 60 * 1000; // 10분 + +const cachedVessels = new Map(); +let lastUpdated: Date | null = null; + +// lastUpdate가 TTL을 초과한 선박을 캐시에서 제거. +// lastUpdate 파싱이 불가능한 경우 보수적으로 유지한다. +function evictStale(): void { + const now = Date.now(); + for (const [mmsi, vessel] of cachedVessels) { + const ts = Date.parse(vessel.lastUpdate); + if (Number.isNaN(ts)) continue; + if (now - ts > VESSEL_TTL_MS) { + cachedVessels.delete(mmsi); + } + } +} + +export function updateVesselCache(vessels: VesselPosition[]): void { + for (const vessel of vessels) { + if (!vessel.mmsi) continue; + cachedVessels.set(vessel.mmsi, vessel); + } + evictStale(); + lastUpdated = new Date(); +} + +export function getVesselsInBounds(bounds: BoundingBox): VesselPosition[] { + const result: VesselPosition[] = []; + for (const v of cachedVessels.values()) { + if ( + v.lon >= bounds.minLon && + v.lon <= bounds.maxLon && + v.lat >= bounds.minLat && + v.lat <= bounds.maxLat + ) { + result.push(v); + } + } + return result; +} + +export function getCacheStatus(): { + count: number; + bangjeCount: number; + lastUpdated: Date | null; +} { + let bangjeCount = 0; + for (const v of cachedVessels.values()) { + if (v.shipNm && v.shipNm.toUpperCase().includes('BANGJE')) bangjeCount++; + } + return { count: cachedVessels.size, bangjeCount, lastUpdated }; +} diff --git a/backend/src/vessels/vesselTypes.ts b/backend/src/vessels/vesselTypes.ts new file mode 100644 index 0000000..8b5ed00 --- /dev/null +++ b/backend/src/vessels/vesselTypes.ts @@ -0,0 +1,26 @@ +export interface VesselPosition { + mmsi: string; + imo?: number; + lon: number; + lat: number; + sog?: number; + cog?: number; + heading?: number; + shipNm?: string; + shipTy?: string; + shipKindCode?: string; + nationalCode?: string; + lastUpdate: string; + status?: string; + destination?: string; + length?: number; + width?: number; + draught?: number; +} + +export interface BoundingBox { + minLon: number; + minLat: number; + maxLon: number; + maxLat: number; +} diff --git a/frontend/src/common/components/map/MapBoundsTracker.tsx b/frontend/src/common/components/map/MapBoundsTracker.tsx new file mode 100644 index 0000000..722bd4d --- /dev/null +++ b/frontend/src/common/components/map/MapBoundsTracker.tsx @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { useMap } from '@vis.gl/react-maplibre'; +import type { MapBounds } from '@common/types/vessel'; + +interface MapBoundsTrackerProps { + onBoundsChange?: (bounds: MapBounds) => void; + onZoomChange?: (zoom: number) => void; +} + +export function MapBoundsTracker({ onBoundsChange, onZoomChange }: MapBoundsTrackerProps) { + const { current: map } = useMap(); + + useEffect(() => { + if (!map) return; + + const update = () => { + if (onBoundsChange) { + const b = map.getBounds(); + onBoundsChange({ + minLon: b.getWest(), + minLat: b.getSouth(), + maxLon: b.getEast(), + maxLat: b.getNorth(), + }); + } + if (onZoomChange) { + onZoomChange(map.getZoom()); + } + }; + + update(); + map.on('moveend', update); + map.on('zoomend', update); + + return () => { + map.off('moveend', update); + map.off('zoomend', update); + }; + }, [map, onBoundsChange, onZoomChange]); + + return null; +} diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 98fa5d1..7475d65 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -31,6 +31,15 @@ import { DeckGLOverlay } from './DeckGLOverlay'; import { FlyToController } from './FlyToController'; import { useMapStore } from '@common/store/mapStore'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; +import { buildVesselLayers } from './VesselLayer'; +import { MapBoundsTracker } from './MapBoundsTracker'; +import { + VesselHoverTooltip, + VesselPopupPanel, + VesselDetailModal, + type VesselHoverInfo, +} from './VesselInteraction'; +import type { VesselPosition, MapBounds } from '@common/types/vessel'; const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'; @@ -165,10 +174,16 @@ interface MapViewProps { analysisCircleRadiusM?: number; /** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */ showOverlays?: boolean; + /** 선박 신호 목록 (실시간 표출) */ + vessels?: VesselPosition[]; + /** 지도 뷰포트 bounds 변경 콜백 (선박 신호 필터링에 사용) */ + onBoundsChange?: (bounds: MapBounds) => void; } // DeckGLOverlay, FlyToController → @common/components/map/DeckGLOverlay, FlyToController 에서 import +// MapBoundsTracker는 './MapBoundsTracker' 모듈로 추출됨 (IncidentsView 등 BaseMap 직접 사용처에서도 재사용) + // fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용) function FitBoundsController({ fitBoundsTarget, @@ -347,6 +362,8 @@ export function MapView({ analysisCircleCenter, analysisCircleRadiusM = 0, showOverlays = true, + vessels = [], + onBoundsChange, }: MapViewProps) { const lightMode = true; const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore(); @@ -365,6 +382,10 @@ export function MapView({ const persistentPopupRef = useRef(false); // 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용) const hoveredSensitiveRef = useRef | null>(null); + // 선박 호버/클릭 상호작용 상태 + const [vesselHover, setVesselHover] = useState(null); + const [selectedVessel, setSelectedVessel] = useState(null); + const [detailVessel, setDetailVessel] = useState(null); const currentTime = isControlled ? externalCurrentTime : internalCurrentTime; const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => { @@ -1216,6 +1237,23 @@ export function MapView({ // 거리/면적 측정 레이어 result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements)); + // 선박 신호 레이어 + result.push( + ...buildVesselLayers( + vessels, + { + onClick: (vessel) => { + setSelectedVessel(vessel); + setDetailVessel(null); + }, + onHover: (vessel, x, y) => { + setVesselHover(vessel ? { x, y, vessel } : null); + }, + }, + mapZoom, + ), + ); + return result.filter(Boolean); }, [ oilTrajectory, @@ -1241,6 +1279,8 @@ export function MapView({ analysisCircleCenter, analysisCircleRadiusM, lightMode, + vessels, + mapZoom, ]); // 3D 모드 / 테마에 따른 지도 스타일 전환 @@ -1278,6 +1318,8 @@ export function MapView({ {/* 예측 완료 시 궤적 전체 범위로 fitBounds */} + {/* 선박 신호 뷰포트 bounds 추적 */} + {/* S-57 전자해도 오버레이 (공식 style.json 기반) */} @@ -1428,6 +1470,26 @@ export function MapView({ ships={backtrackReplay.ships} /> )} + + {/* 선박 호버 툴팁 */} + {vesselHover && !selectedVessel && } + + {/* 선박 클릭 팝업 */} + {selectedVessel && !detailVessel && ( + setSelectedVessel(null)} + onDetail={() => { + setDetailVessel(selectedVessel); + setSelectedVessel(null); + }} + /> + )} + + {/* 선박 상세 모달 */} + {detailVessel && ( + setDetailVessel(null)} /> + )}
); } diff --git a/frontend/src/common/components/map/VesselInteraction.tsx b/frontend/src/common/components/map/VesselInteraction.tsx new file mode 100644 index 0000000..c350fbf --- /dev/null +++ b/frontend/src/common/components/map/VesselInteraction.tsx @@ -0,0 +1,647 @@ +import { useState } from 'react'; +import type { VesselPosition } from '@common/types/vessel'; +import { getShipKindLabel } from './VesselLayer'; + +export interface VesselHoverInfo { + x: number; + y: number; + vessel: VesselPosition; +} + +function formatDateTime(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '-'; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function displayVal(v: unknown): string { + if (v === undefined || v === null || v === '') return '-'; + return String(v); +} + +export function VesselHoverTooltip({ hover }: { hover: VesselHoverInfo }) { + const v = hover.vessel; + const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '- kn'; + const heading = v.heading ?? v.cog; + const headingText = heading !== undefined ? `HDG ${Math.round(heading)}°` : 'HDG -'; + const typeText = [getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-', v.nationalCode] + .filter(Boolean) + .join(' · '); + return ( +
+
+ {v.shipNm ?? '(이름 없음)'} +
+
+ {typeText} +
+
+ {speed} + {headingText} +
+
+ ); +} + +export function VesselPopupPanel({ + vessel: v, + onClose, + onDetail, +}: { + vessel: VesselPosition; + onClose: () => void; + onDetail: () => void; +}) { + const statusText = v.status ?? '-'; + const isAccident = (v.status ?? '').includes('사고'); + const statusColor = isAccident ? 'var(--color-danger)' : 'var(--color-success)'; + const statusBg = isAccident + ? 'color-mix(in srgb, var(--color-danger) 15%, transparent)' + : 'color-mix(in srgb, var(--color-success) 10%, transparent)'; + const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-'; + const heading = v.heading ?? v.cog; + const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-'; + const receivedAt = v.lastUpdate ? formatDateTime(v.lastUpdate) : '-'; + + return ( +
+
+
+ {v.nationalCode ?? '🚢'} +
+
+
+ {v.shipNm ?? '(이름 없음)'} +
+
+ MMSI: {v.mmsi} +
+
+ + ✕ + +
+ +
+ 🚢 +
+ +
+ + {getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'} + + + {statusText} + +
+ +
+ + +
+
+ + 출항지 + + + - + +
+
+ + 입항지 + + + {v.destination ?? '-'} + +
+
+ +
+ +
+ + + +
+
+ ); +} + +function PopupRow({ + label, + value, + accent, + muted, +}: { + label: string; + value: string; + accent?: boolean; + muted?: boolean; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} + +type DetTab = 'info' | 'nav' | 'spec' | 'ins' | 'dg'; +const TAB_LABELS: { key: DetTab; label: string }[] = [ + { key: 'info', label: '상세정보' }, + { key: 'nav', label: '항해정보' }, + { key: 'spec', label: '선박제원' }, + { key: 'ins', label: '보험정보' }, + { key: 'dg', label: '위험물정보' }, +]; + +export function VesselDetailModal({ + vessel: v, + onClose, +}: { + vessel: VesselPosition; + onClose: () => void; +}) { + const [tab, setTab] = useState('info'); + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + className="fixed inset-0 z-[10000] flex items-center justify-center" + style={{ + background: 'rgba(0,0,0,0.65)', + backdropFilter: 'blur(6px)', + }} + > +
+
+
+ {v.nationalCode ?? '🚢'} +
+
{v.shipNm ?? '(이름 없음)'}
+
+ MMSI: {v.mmsi} · IMO: {displayVal(v.imo)} +
+
+
+ + ✕ + +
+ +
+ {TAB_LABELS.map((t) => ( + + ))} +
+ +
+ {tab === 'info' && } + {tab === 'nav' && } + {tab === 'spec' && } + {tab === 'ins' && } + {tab === 'dg' && } +
+
+
+ ); +} + +function Sec({ + title, + borderColor, + bgColor, + badge, + children, +}: { + title: string; + borderColor?: string; + bgColor?: string; + badge?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+ {title} + {badge} +
+ {children} +
+ ); +} + +function Grid({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function Cell({ + label, + value, + span, + color, +}: { + label: string; + value: string; + span?: boolean; + color?: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function StatusBadge({ label, color }: { label: string; color: string }) { + return ( + + {label} + + ); +} + +function TabInfo({ v }: { v: VesselPosition }) { + const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-'; + const heading = v.heading ?? v.cog; + const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-'; + return ( + <> +
+ 🚢 +
+ + + + + + + + + + + + + + + + + + + + + + ); +} + +function TabNav() { + const hours = ['08', '09', '10', '11', '12', '13', '14']; + const heights = [45, 60, 78, 82, 70, 85, 75]; + const colors = [ + 'color-mix(in srgb, var(--color-success) 30%, transparent)', + 'color-mix(in srgb, var(--color-success) 40%, transparent)', + 'color-mix(in srgb, var(--color-info) 40%, transparent)', + 'color-mix(in srgb, var(--color-info) 50%, transparent)', + 'color-mix(in srgb, var(--color-info) 50%, transparent)', + 'color-mix(in srgb, var(--color-info) 60%, transparent)', + 'color-mix(in srgb, var(--color-accent) 50%, transparent)', + ]; + + return ( + <> + +
+ + + + + + + +
+
+ + +
+
+ {hours.map((h, i) => ( +
+
+ {h} +
+ ))} +
+
+ 평균: 8.4 kn · 최대:{' '} + 11.2 kn +
+
+ + + ); +} + +function TabSpec({ v }: { v: VesselPosition }) { + const loa = v.length !== undefined ? `${v.length} m` : '-'; + const beam = v.width !== undefined ? `${v.width} m` : '-'; + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function TabInsurance() { + return ( + <> + + + + + + + + + } + > + + + + + + + + + ); +} + +function TabDangerous() { + return ( + + + + + + + + + ); +} diff --git a/frontend/src/common/components/map/VesselLayer.ts b/frontend/src/common/components/map/VesselLayer.ts new file mode 100644 index 0000000..8337729 --- /dev/null +++ b/frontend/src/common/components/map/VesselLayer.ts @@ -0,0 +1,133 @@ +import { IconLayer, TextLayer } from '@deck.gl/layers'; +import type { VesselPosition } from '@common/types/vessel'; + +export interface VesselLegendItem { + code: string; + type: string; + color: string; +} + +export const VESSEL_LEGEND: VesselLegendItem[] = [ + { code: '000020', type: '어선', color: '#f97316' }, + { code: '000021', type: '함정', color: '#64748b' }, + { code: '000022', type: '여객선', color: '#a855f7' }, + { code: '000023', type: '화물선', color: '#22c55e' }, + { code: '000024', type: '유조선', color: '#ef4444' }, + { code: '000025', type: '관공선', color: '#3b82f6' }, + { code: '000027', type: '기타', color: '#94a3b8' }, + { code: '000028', type: '부이', color: '#eab308' }, +]; + +const DEFAULT_VESSEL_COLOR = '#94a3b8'; + +const SHIP_KIND_COLORS: Record = VESSEL_LEGEND.reduce( + (acc, { code, color }) => { + acc[code] = color; + return acc; + }, + {} as Record, +); + +const SHIP_KIND_LABELS: Record = VESSEL_LEGEND.reduce( + (acc, { code, type }) => { + acc[code] = type; + return acc; + }, + {} as Record, +); + +export function getShipKindColor(shipKindCode?: string): string { + if (!shipKindCode) return DEFAULT_VESSEL_COLOR; + return SHIP_KIND_COLORS[shipKindCode] ?? DEFAULT_VESSEL_COLOR; +} + +export function getShipKindLabel(shipKindCode?: string): string | undefined { + if (!shipKindCode) return undefined; + return SHIP_KIND_LABELS[shipKindCode]; +} + +function makeTriangleSvg(color: string, isAccident: boolean): string { + const opacity = isAccident ? '1' : '0.85'; + const glowOpacity = isAccident ? '0.9' : '0.75'; + const svgStr = [ + '', + '', + '', + ``, + ``, + '', + ].join(''); + return `data:image/svg+xml;base64,${btoa(svgStr)}`; +} + +export interface VesselLayerHandlers { + onClick?: (vessel: VesselPosition, coordinate: [number, number]) => void; + onHover?: (vessel: VesselPosition | null, x: number, y: number) => void; +} + +export function buildVesselLayers( + vessels: VesselPosition[], + handlers: VesselLayerHandlers = {}, + zoom?: number, +) { + if (!vessels.length) return []; + const showLabels = zoom === undefined || zoom > 9; + + const iconLayer = new IconLayer({ + id: 'vessel-icons', + data: vessels, + getPosition: (d: VesselPosition) => [d.lon, d.lat], + getIcon: (d: VesselPosition) => { + const color = getShipKindColor(d.shipKindCode); + const isAccident = (d.status ?? '').includes('사고'); + return { + url: makeTriangleSvg(color, isAccident), + width: 16, + height: 20, + anchorX: 8, + anchorY: 10, + }; + }, + getSize: 16, + getAngle: (d: VesselPosition) => -(d.heading ?? d.cog ?? 0), + sizeUnits: 'pixels', + sizeScale: 1, + pickable: true, + onClick: (info: { object?: VesselPosition; coordinate?: number[] }) => { + if (info.object && info.coordinate && handlers.onClick) { + handlers.onClick(info.object, [info.coordinate[0], info.coordinate[1]]); + } + }, + onHover: (info: { object?: VesselPosition; x?: number; y?: number }) => { + if (!handlers.onHover) return; + if (info.object && info.x !== undefined && info.y !== undefined) { + handlers.onHover(info.object, info.x, info.y); + } else { + handlers.onHover(null, 0, 0); + } + }, + updateTriggers: { + getIcon: [vessels], + getAngle: [vessels], + }, + }); + + const labelLayer = new TextLayer({ + id: 'vessel-labels', + data: vessels.filter((v) => v.shipNm), + visible: showLabels, + getPosition: (d: VesselPosition) => [d.lon, d.lat], + getText: (d: VesselPosition) => d.shipNm ?? '', + getSize: 11, + getColor: [255, 255, 255, 240], + getPixelOffset: [0, -14], + billboard: true, + sizeUnits: 'pixels' as const, + characterSet: 'auto', + fontSettings: { sdf: true }, + outlineColor: [0, 0, 0, 230], + outlineWidth: 2, + }); + + return [iconLayer, labelLayer]; +} diff --git a/frontend/src/common/hooks/useVesselSignals.ts b/frontend/src/common/hooks/useVesselSignals.ts new file mode 100644 index 0000000..734c35f --- /dev/null +++ b/frontend/src/common/hooks/useVesselSignals.ts @@ -0,0 +1,79 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + createVesselSignalClient, + type VesselSignalClient, +} from '@common/services/vesselSignalClient'; +import { + getInitialVesselSnapshot, + isVesselInitEnabled, +} from '@common/services/vesselApi'; +import type { VesselPosition, MapBounds } from '@common/types/vessel'; + +/** + * 선박 신호 실시간 수신 훅 + * + * 개발환경(VITE_VESSEL_SIGNAL_MODE=polling): + * - 60초마다 백엔드 REST API(/api/vessels/in-area)를 현재 뷰포트 bbox로 호출 + * + * 운영환경(VITE_VESSEL_SIGNAL_MODE=websocket): + * - 운영 WebSocket 서버(VITE_VESSEL_WS_URL)에 직접 연결하여 실시간 수신 + * - 수신된 전체 데이터를 현재 뷰포트 bbox로 프론트에서 필터링 + * + * @param mapBounds MapView의 onBoundsChange로 전달받은 현재 뷰포트 bbox + * @returns 현재 뷰포트 내 선박 목록 + */ +export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[] { + const [vessels, setVessels] = useState([]); + const boundsRef = useRef(mapBounds); + const clientRef = useRef(null); + + useEffect(() => { + boundsRef.current = mapBounds; + }, [mapBounds]); + + const getViewportBounds = useCallback(() => boundsRef.current, []); + + useEffect(() => { + const client = createVesselSignalClient(); + clientRef.current = client; + + // 운영 환경: 로그인/새로고침 직후 최근 10분치 스냅샷을 먼저 1회 로드. + // 이후 WebSocket 수신이 시작되면 최신 신호로 갱신된다. + // VITE_VESSEL_INIT_ENABLED=true 일 때만 활성화(기본 비활성). + if (isVesselInitEnabled()) { + getInitialVesselSnapshot() + .then((initial) => { + const bounds = boundsRef.current; + const filtered = bounds + ? initial.filter( + (v) => + v.lon >= bounds.minLon && + v.lon <= bounds.maxLon && + v.lat >= bounds.minLat && + v.lat <= bounds.maxLat, + ) + : initial; + // WS 첫 메시지가 먼저 도착해 이미 채워졌다면 덮어쓰지 않음 + setVessels((prev) => (prev.length === 0 ? filtered : prev)); + }) + .catch((e) => console.warn('[useVesselSignals] 초기 스냅샷 실패', e)); + } + + client.start(setVessels, getViewportBounds); + return () => { + client.stop(); + clientRef.current = null; + }; + }, [getViewportBounds]); + + // mapBounds가 바뀔 때마다(최초 채워질 때 + 이후 뷰포트 이동/줌마다) 즉시 1회 새로고침. + // MapView의 onBoundsChange는 moveend/zoomend에서만 호출되므로 드래그 중 스팸은 없다. + // 이후에도 60초 인터벌 폴링은 백그라운드에서 계속 동작. + useEffect(() => { + if (mapBounds && clientRef.current) { + clientRef.current.refresh(); + } + }, [mapBounds]); + + return vessels; +} diff --git a/frontend/src/common/mock/vesselMockData.ts b/frontend/src/common/mock/vesselMockData.ts index 48d5604..d49e83d 100755 --- a/frontend/src/common/mock/vesselMockData.ts +++ b/frontend/src/common/mock/vesselMockData.ts @@ -1,613 +1,4 @@ -export interface Vessel { - mmsi: number; - imo: string; - name: string; - typS: string; - flag: string; - status: string; - speed: number; - heading: number; - lat: number; - lng: number; - draft: number; - depart: string; - arrive: string; - etd: string; - eta: string; - gt: string; - dwt: string; - loa: string; - beam: string; - built: string; - yard: string; - callSign: string; - cls: string; - cargo: string; - color: string; - markerType: string; -} - -export const VESSEL_TYPE_COLORS: Record = { - Tanker: '#ef4444', - Chemical: '#ef4444', - Cargo: '#22c55e', - Bulk: '#22c55e', - Container: '#3b82f6', - Passenger: '#a855f7', - Fishing: '#f97316', - Tug: '#06b6d4', - Navy: '#6b7280', - Sailing: '#fbbf24', -}; - -export const VESSEL_LEGEND = [ - { type: 'Tanker', color: '#ef4444' }, - { type: 'Cargo', color: '#22c55e' }, - { type: 'Container', color: '#3b82f6' }, - { type: 'Fishing', color: '#f97316' }, - { type: 'Passenger', color: '#a855f7' }, - { type: 'Tug', color: '#06b6d4' }, -]; - -export const mockVessels: Vessel[] = [ - { - mmsi: 440123456, - imo: '9812345', - name: 'HANKUK CHEMI', - typS: 'Tanker', - flag: '🇰🇷', - status: '항해중', - speed: 8.2, - heading: 330, - lat: 34.6, - lng: 127.5, - draft: 5.8, - depart: '여수항', - arrive: '부산항', - etd: '2026-02-25 08:00', - eta: '2026-02-25 18:30', - gt: '29,246', - dwt: '49,999', - loa: '183.0m', - beam: '32.2m', - built: '2018', - yard: '현대미포조선', - callSign: 'HLKC', - cls: '한국선급(KR)', - cargo: 'BUNKER-C · 1,200kL · IMO Class 3', - color: '#ef4444', - markerType: 'tanker', - }, - { - mmsi: 440234567, - imo: '9823456', - name: 'DONG-A GLAUCOS', - typS: 'Cargo', - flag: '🇰🇷', - status: '항해중', - speed: 11.4, - heading: 245, - lat: 34.78, - lng: 127.8, - draft: 7.2, - depart: '울산항', - arrive: '광양항', - etd: '2026-02-25 06:30', - eta: '2026-02-25 16:00', - gt: '12,450', - dwt: '18,800', - loa: '144.0m', - beam: '22.6m', - built: '2015', - yard: 'STX조선', - callSign: 'HLDG', - cls: '한국선급(KR)', - cargo: '철강재 · 4,500t', - color: '#22c55e', - markerType: 'cargo', - }, - { - mmsi: 440345678, - imo: '9834567', - name: 'HMM ALGECIRAS', - typS: 'Container', - flag: '🇰🇷', - status: '항해중', - speed: 18.5, - heading: 195, - lat: 35.0, - lng: 128.8, - draft: 14.5, - depart: '부산항', - arrive: '싱가포르', - etd: '2026-02-25 04:00', - eta: '2026-03-02 08:00', - gt: '228,283', - dwt: '223,092', - loa: '399.9m', - beam: '61.0m', - built: '2020', - yard: '대우조선해양', - callSign: 'HLHM', - cls: "Lloyd's Register", - cargo: '컨테이너 · 16,420 TEU', - color: '#3b82f6', - markerType: 'container', - }, - { - mmsi: 355678901, - imo: '9756789', - name: 'STELLAR DAISY', - typS: 'Tanker', - flag: '🇵🇦', - status: '⚠ 사고(좌초)', - speed: 0.0, - heading: 0, - lat: 34.72, - lng: 127.72, - draft: 8.1, - depart: '여수항', - arrive: '—', - etd: '2026-01-18 12:00', - eta: '—', - gt: '35,120', - dwt: '58,000', - loa: '190.0m', - beam: '34.0m', - built: '2012', - yard: 'CSBC Taiwan', - callSign: '3FZA7', - cls: 'NK', - cargo: 'BUNKER-C · 150kL 유출 · ⚠ 사고선박', - color: '#ef4444', - markerType: 'tanker', - }, - { - mmsi: 440456789, - imo: '—', - name: '제72 금양호', - typS: 'Fishing', - flag: '🇰🇷', - status: '조업중', - speed: 4.1, - heading: 120, - lat: 34.55, - lng: 127.35, - draft: 2.1, - depart: '여수 국동항', - arrive: '여수 국동항', - etd: '2026-02-25 04:30', - eta: '2026-02-25 18:00', - gt: '78', - dwt: '—', - loa: '24.5m', - beam: '6.2m', - built: '2008', - yard: '통영조선', - callSign: '—', - cls: '한국선급', - cargo: '어획물', - color: '#f97316', - markerType: 'fishing', - }, - { - mmsi: 440567890, - imo: '9867890', - name: 'PAN OCEAN GLORY', - typS: 'Bulk', - flag: '🇰🇷', - status: '항해중', - speed: 12.8, - heading: 170, - lat: 35.6, - lng: 126.4, - draft: 10.3, - depart: '군산항', - arrive: '포항항', - etd: '2026-02-25 07:00', - eta: '2026-02-26 04:00', - gt: '43,800', - dwt: '76,500', - loa: '229.0m', - beam: '32.3m', - built: '2019', - yard: '현대삼호중공업', - callSign: 'HLPO', - cls: '한국선급(KR)', - cargo: '석탄 · 65,000t', - color: '#22c55e', - markerType: 'cargo', - }, - { - mmsi: 440678901, - imo: '—', - name: '여수예인1호', - typS: 'Tug', - flag: '🇰🇷', - status: '방제지원', - speed: 6.3, - heading: 355, - lat: 34.68, - lng: 127.6, - draft: 3.2, - depart: '여수항', - arrive: '사고현장', - etd: '2026-01-18 16:30', - eta: '—', - gt: '280', - dwt: '—', - loa: '32.0m', - beam: '9.5m', - built: '2016', - yard: '삼성중공업', - callSign: 'HLYT', - cls: '한국선급', - cargo: '방제장비 · 오일붐 500m', - color: '#06b6d4', - markerType: 'tug', - }, - { - mmsi: 235012345, - imo: '9456789', - name: 'QUEEN MARY', - typS: 'Passenger', - flag: '🇬🇧', - status: '항해중', - speed: 15.2, - heading: 10, - lat: 33.8, - lng: 127.0, - draft: 8.5, - depart: '상하이', - arrive: '부산항', - etd: '2026-02-24 18:00', - eta: '2026-02-26 06:00', - gt: '148,528', - dwt: '18,000', - loa: '345.0m', - beam: '41.0m', - built: '2004', - yard: "Chantiers de l'Atlantique", - callSign: 'GBQM2', - cls: "Lloyd's Register", - cargo: '승객 2,620명', - color: '#a855f7', - markerType: 'passenger', - }, - { - mmsi: 353012345, - imo: '9811000', - name: 'EVER GIVEN', - typS: 'Container', - flag: '🇹🇼', - status: '항해중', - speed: 14.7, - heading: 220, - lat: 35.2, - lng: 129.2, - draft: 15.7, - depart: '부산항', - arrive: '카오슝', - etd: '2026-02-25 02:00', - eta: '2026-02-28 14:00', - gt: '220,940', - dwt: '199,629', - loa: '400.0m', - beam: '59.0m', - built: '2018', - yard: '今治造船', - callSign: 'BIXE9', - cls: 'ABS', - cargo: '컨테이너 · 14,800 TEU', - color: '#3b82f6', - markerType: 'container', - }, - { - mmsi: 440789012, - imo: '—', - name: '제85 대성호', - typS: 'Fishing', - flag: '🇰🇷', - status: '조업중', - speed: 3.8, - heading: 85, - lat: 34.4, - lng: 126.3, - draft: 1.8, - depart: '목포항', - arrive: '목포항', - etd: '2026-02-25 03:00', - eta: '2026-02-25 17:00', - gt: '65', - dwt: '—', - loa: '22.0m', - beam: '5.8m', - built: '2010', - yard: '목포조선', - callSign: '—', - cls: '한국선급', - cargo: '어획물', - color: '#f97316', - markerType: 'fishing', - }, - { - mmsi: 440890123, - imo: '9878901', - name: 'SK INNOVATION', - typS: 'Chemical', - flag: '🇰🇷', - status: '항해중', - speed: 9.6, - heading: 340, - lat: 35.8, - lng: 126.6, - draft: 6.5, - depart: '대산항', - arrive: '여수항', - etd: '2026-02-25 10:00', - eta: '2026-02-26 02:00', - gt: '11,200', - dwt: '16,800', - loa: '132.0m', - beam: '20.4m', - built: '2020', - yard: '현대미포조선', - callSign: 'HLSK', - cls: '한국선급(KR)', - cargo: '톨루엔 · 8,500kL · IMO Class 3', - color: '#ef4444', - markerType: 'tanker', - }, - { - mmsi: 440901234, - imo: '9889012', - name: 'KOREA EXPRESS', - typS: 'Cargo', - flag: '🇰🇷', - status: '항해중', - speed: 10.1, - heading: 190, - lat: 36.2, - lng: 128.5, - draft: 6.8, - depart: '동해항', - arrive: '포항항', - etd: '2026-02-25 09:00', - eta: '2026-02-25 15:00', - gt: '8,500', - dwt: '12,000', - loa: '118.0m', - beam: '18.2m', - built: '2014', - yard: '대한조선', - callSign: 'HLKE', - cls: '한국선급', - cargo: '일반화물', - color: '#22c55e', - markerType: 'cargo', - }, - { - mmsi: 440012345, - imo: '—', - name: 'ROKS SEJONG', - typS: 'Navy', - flag: '🇰🇷', - status: '작전중', - speed: 16.0, - heading: 270, - lat: 35.3, - lng: 129.5, - draft: 6.3, - depart: '부산 해군기지', - arrive: '—', - etd: '—', - eta: '—', - gt: '7,600', - dwt: '—', - loa: '165.9m', - beam: '21.4m', - built: '2008', - yard: '현대중공업', - callSign: 'HLNS', - cls: '군용', - cargo: '군사작전', - color: '#6b7280', - markerType: 'military', - }, - { - mmsi: 440023456, - imo: '—', - name: '군산예인3호', - typS: 'Tug', - flag: '🇰🇷', - status: '대기중', - speed: 5.5, - heading: 140, - lat: 35.9, - lng: 126.9, - draft: 2.8, - depart: '군산항', - arrive: '군산항', - etd: '—', - eta: '—', - gt: '180', - dwt: '—', - loa: '28.0m', - beam: '8.2m', - built: '2019', - yard: '통영조선', - callSign: 'HLGS', - cls: '한국선급', - cargo: '—', - color: '#06b6d4', - markerType: 'tug', - }, - { - mmsi: 440034567, - imo: '—', - name: 'JEJU WIND', - typS: 'Sailing', - flag: '🇰🇷', - status: '항해중', - speed: 6.8, - heading: 290, - lat: 33.35, - lng: 126.65, - draft: 2.5, - depart: '제주항', - arrive: '제주항', - etd: '2026-02-25 10:00', - eta: '2026-02-25 16:00', - gt: '45', - dwt: '—', - loa: '18.0m', - beam: '5.0m', - built: '2022', - yard: '제주요트', - callSign: '—', - cls: '—', - cargo: '—', - color: '#fbbf24', - markerType: 'sail', - }, - { - mmsi: 440045678, - imo: '—', - name: '제33 삼양호', - typS: 'Fishing', - flag: '🇰🇷', - status: '조업중', - speed: 2.4, - heading: 55, - lat: 35.1, - lng: 127.4, - draft: 1.6, - depart: '통영항', - arrive: '통영항', - etd: '2026-02-25 05:00', - eta: '2026-02-25 19:00', - gt: '52', - dwt: '—', - loa: '20.0m', - beam: '5.4m', - built: '2006', - yard: '거제조선', - callSign: '—', - cls: '한국선급', - cargo: '어획물', - color: '#f97316', - markerType: 'fishing', - }, - { - mmsi: 255012345, - imo: '9703291', - name: 'MSC OSCAR', - typS: 'Container', - flag: '🇨🇭', - status: '항해중', - speed: 17.3, - heading: 355, - lat: 34.1, - lng: 128.1, - draft: 14.0, - depart: '카오슝', - arrive: '부산항', - etd: '2026-02-23 08:00', - eta: '2026-02-25 22:00', - gt: '197,362', - dwt: '199,272', - loa: '395.4m', - beam: '59.0m', - built: '2015', - yard: '대우조선해양', - callSign: '9HA4713', - cls: 'DNV', - cargo: '컨테이너 · 18,200 TEU', - color: '#3b82f6', - markerType: 'container', - }, - { - mmsi: 440056789, - imo: '9890567', - name: 'SAEHAN PIONEER', - typS: 'Tanker', - flag: '🇰🇷', - status: '항해중', - speed: 7.9, - heading: 310, - lat: 34.9, - lng: 127.1, - draft: 5.2, - depart: '여수항', - arrive: '대산항', - etd: '2026-02-25 11:00', - eta: '2026-02-26 08:00', - gt: '8,900', - dwt: '14,200', - loa: '120.0m', - beam: '18.0m', - built: '2017', - yard: '현대미포조선', - callSign: 'HLSP', - cls: '한국선급(KR)', - cargo: '경유 · 10,000kL', - color: '#ef4444', - markerType: 'tanker', - }, - { - mmsi: 440067890, - imo: '9891678', - name: 'DONGHAE STAR', - typS: 'Cargo', - flag: '🇰🇷', - status: '항해중', - speed: 11.0, - heading: 155, - lat: 37.55, - lng: 129.3, - draft: 6.0, - depart: '속초항', - arrive: '동해항', - etd: '2026-02-25 12:00', - eta: '2026-02-25 16:30', - gt: '6,200', - dwt: '8,500', - loa: '105.0m', - beam: '16.5m', - built: '2013', - yard: '대한조선', - callSign: 'HLDS', - cls: '한국선급', - cargo: '일반화물 · 목재', - color: '#22c55e', - markerType: 'cargo', - }, - { - mmsi: 440078901, - imo: '—', - name: '제18 한라호', - typS: 'Fishing', - flag: '🇰🇷', - status: '귀항중', - speed: 3.2, - heading: 70, - lat: 33.3, - lng: 126.3, - draft: 1.9, - depart: '서귀포항', - arrive: '서귀포항', - etd: '2026-02-25 04:00', - eta: '2026-02-25 15:00', - gt: '58', - dwt: '—', - loa: '21.0m', - beam: '5.6m', - built: '2011', - yard: '제주조선', - callSign: '—', - cls: '한국선급', - cargo: '어획물 · 갈치/고등어', - color: '#f97316', - markerType: 'fishing', - }, -]; +// Deprecated: Mock 선박 데이터는 제거되었습니다. +// 실제 선박 신호는 @common/hooks/useVesselSignals + @common/components/map/VesselLayer 를 사용합니다. +// 범례는 @common/components/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요. +export {}; diff --git a/frontend/src/common/services/vesselApi.ts b/frontend/src/common/services/vesselApi.ts new file mode 100644 index 0000000..de3a2f2 --- /dev/null +++ b/frontend/src/common/services/vesselApi.ts @@ -0,0 +1,35 @@ +import { api } from './api'; +import type { VesselPosition, MapBounds } from '@common/types/vessel'; + +export async function getVesselsInArea(bounds: MapBounds): Promise { + const res = await api.post('/vessels/in-area', { bounds }); + return res.data; +} + +/** + * 로그인/새로고침 직후 1회 호출하는 초기 스냅샷 API. + * 운영 환경의 별도 REST 서버가 현재 시각 기준 최근 10분치 선박 신호를 반환한다. + * URL은 VITE_VESSEL_INIT_API_URL 로 주입(운영에서 실제 URL로 교체). + */ +export async function getInitialVesselSnapshot(): Promise { + const url = import.meta.env.VITE_VESSEL_INIT_API_URL as string | undefined; + if (!url) return []; + const res = await fetch(url, { method: 'GET' }); + if (!res.ok) throw new Error(`vessel init snapshot ${res.status}`); + return (await res.json()) as VesselPosition[]; +} + +export function isVesselInitEnabled(): boolean { + return import.meta.env.VITE_VESSEL_INIT_ENABLED === 'true'; +} + +export interface VesselCacheStatus { + count: number; + bangjeCount: number; + lastUpdated: string | null; +} + +export async function getVesselCacheStatus(): Promise { + const res = await api.get('/vessels/status'); + return res.data; +} diff --git a/frontend/src/common/services/vesselSignalClient.ts b/frontend/src/common/services/vesselSignalClient.ts new file mode 100644 index 0000000..201b4bc --- /dev/null +++ b/frontend/src/common/services/vesselSignalClient.ts @@ -0,0 +1,125 @@ +import type { VesselPosition, MapBounds } from '@common/types/vessel'; +import { getVesselsInArea } from './vesselApi'; + +export interface VesselSignalClient { + start( + onVessels: (vessels: VesselPosition[]) => void, + getViewportBounds: () => MapBounds | null, + ): void; + stop(): void; + /** + * 즉시 1회 새로고침. 폴링 모드에선 현재 bbox로 REST 호출, + * WebSocket 모드에선 no-op(서버 push에 의존). + */ + refresh(): void; +} + +// 개발환경: setInterval(60s) → 백엔드 REST API 호출 +class PollingVesselClient implements VesselSignalClient { + private intervalId: ReturnType | null = null; + private onVessels: ((vessels: VesselPosition[]) => void) | null = null; + private getViewportBounds: (() => MapBounds | null) | null = null; + + private async poll(): Promise { + const bounds = this.getViewportBounds?.(); + if (!bounds || !this.onVessels) return; + try { + const vessels = await getVesselsInArea(bounds); + this.onVessels(vessels); + } catch { + // 폴링 실패 시 무시 (다음 인터벌에 재시도) + } + } + + start( + onVessels: (vessels: VesselPosition[]) => void, + getViewportBounds: () => MapBounds | null, + ): void { + this.onVessels = onVessels; + this.getViewportBounds = getViewportBounds; + + // 즉시 1회 실행 후 60초 간격으로 반복 + this.poll(); + this.intervalId = setInterval(() => this.poll(), 60_000); + } + + stop(): void { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + this.onVessels = null; + this.getViewportBounds = null; + } + + refresh(): void { + this.poll(); + } +} + +// 운영환경: 실시간 WebSocket 서버에 직접 연결 +class DirectWebSocketVesselClient implements VesselSignalClient { + private ws: WebSocket | null = null; + private readonly wsUrl: string; + + constructor(wsUrl: string) { + this.wsUrl = wsUrl; + } + + start( + onVessels: (vessels: VesselPosition[]) => void, + getViewportBounds: () => MapBounds | null, + ): void { + this.ws = new WebSocket(this.wsUrl); + + this.ws.onmessage = (event) => { + try { + const allVessels = JSON.parse(event.data as string) as VesselPosition[]; + const bounds = getViewportBounds(); + + if (!bounds) { + onVessels(allVessels); + return; + } + + const filtered = allVessels.filter( + (v) => + v.lon >= bounds.minLon && + v.lon <= bounds.maxLon && + v.lat >= bounds.minLat && + v.lat <= bounds.maxLat, + ); + onVessels(filtered); + } catch { + // 파싱 실패 무시 + } + }; + + this.ws.onerror = () => { + console.error('[vesselSignalClient] WebSocket 연결 오류'); + }; + + this.ws.onclose = () => { + console.warn('[vesselSignalClient] WebSocket 연결 종료'); + }; + } + + stop(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + refresh(): void { + // 운영 WS 모드에선 서버 push에 의존하므로 별도 새로고침 동작 없음 + } +} + +export function createVesselSignalClient(): VesselSignalClient { + if (import.meta.env.VITE_VESSEL_SIGNAL_MODE === 'websocket') { + const wsUrl = import.meta.env.VITE_VESSEL_WS_URL as string; + return new DirectWebSocketVesselClient(wsUrl); + } + return new PollingVesselClient(); +} diff --git a/frontend/src/common/types/vessel.ts b/frontend/src/common/types/vessel.ts new file mode 100644 index 0000000..4291ca1 --- /dev/null +++ b/frontend/src/common/types/vessel.ts @@ -0,0 +1,26 @@ +export interface VesselPosition { + mmsi: string; + imo?: number; + lon: number; + lat: number; + sog?: number; + cog?: number; + heading?: number; + shipNm?: string; + shipTy?: string; + shipKindCode?: string; + nationalCode?: string; + lastUpdate: string; + status?: string; + destination?: string; + length?: number; + width?: number; + draught?: number; +} + +export interface MapBounds { + minLon: number; + minLat: number; + maxLon: number; + maxLat: number; +} diff --git a/frontend/src/tabs/hns/components/HNSView.tsx b/frontend/src/tabs/hns/components/HNSView.tsx index 4fc86e4..625abf8 100755 --- a/frontend/src/tabs/hns/components/HNSView.tsx +++ b/frontend/src/tabs/hns/components/HNSView.tsx @@ -1,4 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import { useVesselSignals } from '@common/hooks/useVesselSignals'; +import type { MapBounds } from '@common/types/vessel'; import { HNSLeftPanel } from './HNSLeftPanel'; import type { HNSInputParams } from './HNSLeftPanel'; import { HNSRightPanel } from './HNSRightPanel'; @@ -267,6 +269,8 @@ export function HNSView() { const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null); const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined); const [isSelectingLocation, setIsSelectingLocation] = useState(false); + const [mapBounds, setMapBounds] = useState(null); + const vessels = useVesselSignals(mapBounds); const [isRunningPrediction, setIsRunningPrediction] = useState(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any const [dispersionResult, setDispersionResult] = useState(null); @@ -974,6 +978,8 @@ export function HNSView() { dispersionResult={dispersionResult} dispersionHeatmap={heatmapData} mapCaptureRef={mapCaptureRef} + vessels={vessels} + onBoundsChange={setMapBounds} /> {/* 시간 슬라이더 (puff/dense_gas 모델용) */} {allTimeFrames.length > 1 && ( diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx index 91b89b0..52dc6e5 100755 --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -1,13 +1,17 @@ import { useState, useEffect, useMemo, useRef } from 'react'; import { Popup, useMap } from '@vis.gl/react-maplibre'; -import { ScatterplotLayer, IconLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers'; +import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers'; import { PathStyleExtension } from '@deck.gl/extensions'; import 'maplibre-gl/dist/maplibre-gl.css'; import { BaseMap } from '@common/components/map/BaseMap'; import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay'; +import { MapBoundsTracker } from '@common/components/map/MapBoundsTracker'; +import { buildVesselLayers, VESSEL_LEGEND, getShipKindLabel } from '@common/components/map/VesselLayer'; +import { useVesselSignals } from '@common/hooks/useVesselSignals'; +import type { MapBounds, VesselPosition } from '@common/types/vessel'; +import { getVesselCacheStatus, type VesselCacheStatus } from '@common/services/vesselApi'; import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'; import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'; -import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'; import { fetchIncidents } from '../services/incidentsApi'; import type { IncidentCompat } from '../services/incidentsApi'; import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi'; @@ -86,16 +90,11 @@ function getMarkerStroke(s: string): [number, number, number, number] { const getStatusLabel = (s: string) => s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : ''; -// ── 선박 아이콘 SVG (삼각형) ──────────────────────────── -// deck.gl IconLayer용 atlas 없이 SVGOverlay 방식 대신 -// ScatterplotLayer로 단순화하여 선박을 원으로 표현 (heading 정보 별도 레이어) -// → 원 마커 + 방향 지시선을 deck.gl ScatterplotLayer로 표현 - // 팝업 정보 interface VesselPopupInfo { longitude: number; latitude: number; - vessel: Vessel; + vessel: VesselPosition; } interface IncidentPopupInfo { @@ -108,7 +107,7 @@ interface IncidentPopupInfo { interface HoverInfo { x: number; y: number; - object: Vessel | IncidentCompat; + object: VesselPosition | IncidentCompat; type: 'vessel' | 'incident'; } @@ -119,12 +118,35 @@ export function IncidentsView() { const [incidents, setIncidents] = useState([]); const [filteredIncidents, setFilteredIncidents] = useState([]); const [selectedIncidentId, setSelectedIncidentId] = useState(null); - const [selectedVessel, setSelectedVessel] = useState(null); - const [detailVessel, setDetailVessel] = useState(null); + const [selectedVessel, setSelectedVessel] = useState(null); + const [detailVessel, setDetailVessel] = useState(null); const [vesselPopup, setVesselPopup] = useState(null); const [incidentPopup, setIncidentPopup] = useState(null); const [hoverInfo, setHoverInfo] = useState(null); + const [mapBounds, setMapBounds] = useState(null); + const [mapZoom, setMapZoom] = useState(10); + const realVessels = useVesselSignals(mapBounds); + + const [vesselStatus, setVesselStatus] = useState(null); + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const status = await getVesselCacheStatus(); + if (!cancelled) setVesselStatus(status); + } catch { + // 무시 — 다음 폴링에서 재시도 + } + }; + load(); + const id = setInterval(load, 30_000); + return () => { + cancelled = true; + clearInterval(id); + }; + }, []); + const [dischargeMode, setDischargeMode] = useState(false); const [leftCollapsed, setLeftCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(false); @@ -283,60 +305,6 @@ export function IncidentsView() { [filteredIncidents, selectedIncidentId], ); - // ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ────── - // 글로우 효과 + 드롭쉐도우가 있는 개선된 SVG 삼각형 - const vesselIconLayer = useMemo(() => { - const makeTriangleSvg = (color: string, isAccident: boolean) => { - const opacity = isAccident ? '1' : '0.85'; - const glowOpacity = isAccident ? '0.9' : '0.75'; - const svgStr = [ - '', - '', - '', - ``, - ``, - '', - ].join(''); - return `data:image/svg+xml;base64,${btoa(svgStr)}`; - }; - - return new IconLayer({ - id: 'vessel-icons', - data: mockVessels, - getPosition: (d: Vessel) => [d.lng, d.lat], - getIcon: (d: Vessel) => ({ - url: makeTriangleSvg(d.color, d.status.includes('사고')), - width: 16, - height: 20, - anchorX: 8, - anchorY: 10, - }), - getSize: 16, - getAngle: (d: Vessel) => -d.heading, - sizeUnits: 'pixels', - sizeScale: 1, - pickable: true, - onClick: (info: { object?: Vessel; coordinate?: number[] }) => { - if (info.object && info.coordinate) { - setSelectedVessel(info.object); - setVesselPopup({ - longitude: info.coordinate[0], - latitude: info.coordinate[1], - vessel: info.object, - }); - setIncidentPopup(null); - setDetailVessel(null); - } - }, - onHover: (info: { object?: Vessel; x?: number; y?: number }) => { - if (info.object && info.x !== undefined && info.y !== undefined) { - setHoverInfo({ x: info.x, y: info.y, object: info.object, type: 'vessel' }); - } else { - setHoverInfo((h) => (h?.type === 'vessel' ? null : h)); - } - }, - }); - }, []); // ── 배출 구역 경계선 레이어 ── const dischargeZoneLayers = useMemo(() => { @@ -535,16 +503,44 @@ export function IncidentsView() { }); }, [sensitiveGeojson, sensCheckedCategories, sensColorMap]); + const realVesselLayers = useMemo( + () => + buildVesselLayers( + realVessels, + { + onClick: (vessel, coordinate) => { + setSelectedVessel(vessel); + setVesselPopup({ + longitude: coordinate[0], + latitude: coordinate[1], + vessel, + }); + setIncidentPopup(null); + setDetailVessel(null); + }, + onHover: (vessel, x, y) => { + if (vessel) { + setHoverInfo({ x, y, object: vessel, type: 'vessel' }); + } else { + setHoverInfo((h) => (h?.type === 'vessel' ? null : h)); + } + }, + }, + mapZoom, + ), + [realVessels, mapZoom], + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const deckLayers: any[] = useMemo( () => [ incidentLayer, - vesselIconLayer, + ...realVesselLayers, ...dischargeZoneLayers, ...trajectoryLayers, ...(sensLayer ? [sensLayer] : []), ], - [incidentLayer, vesselIconLayer, dischargeZoneLayers, trajectoryLayers, sensLayer], + [incidentLayer, realVesselLayers, dischargeZoneLayers, trajectoryLayers, sensLayer], ); return ( @@ -692,6 +688,7 @@ export function IncidentsView() { }} > + {/* 사고 팝업 */} @@ -729,7 +726,7 @@ export function IncidentsView() { }} > {hoverInfo.type === 'vessel' ? ( - + ) : ( )} @@ -859,12 +856,11 @@ export function IncidentsView() { }} /> */} AIS Live - MarineTraffic
-
선박 20
-
사고 6
-
방제선 2
+
선박 {vesselStatus?.count ?? 0}
+
사고 {filteredIncidents.length}
+
방제선 {vesselStatus?.bangjeCount ?? 0}
@@ -1108,7 +1104,15 @@ export function IncidentsView() { onCloseAnalysis={handleCloseAnalysis} onCheckedPredsChange={handleCheckedPredsChange} onSensitiveDataChange={handleSensitiveDataChange} - selectedVessel={selectedVessel} + selectedVessel={ + selectedVessel + ? { + lat: selectedVessel.lat, + lng: selectedVessel.lon, + name: selectedVessel.shipNm, + } + : null + } />
@@ -1253,21 +1257,40 @@ function SplitPanelContent({ } /* ════════════════════════════════════════════════════ - VesselPopupPanel + VesselPopupPanel / VesselDetailModal 공용 유틸 ════════════════════════════════════════════════════ */ +function formatDateTime(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '-'; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function displayVal(v: unknown): string { + if (v === undefined || v === null || v === '') return '-'; + return String(v); +} + + function VesselPopupPanel({ vessel: v, onClose, onDetail, }: { - vessel: Vessel; + vessel: VesselPosition; onClose: () => void; onDetail: () => void; }) { - const statusColor = v.status.includes('사고') ? 'var(--color-danger)' : 'var(--color-success)'; - const statusBg = v.status.includes('사고') + const statusText = v.status ?? '-'; + const isAccident = (v.status ?? '').includes('사고'); + const statusColor = isAccident ? 'var(--color-danger)' : 'var(--color-success)'; + const statusBg = isAccident ? 'color-mix(in srgb, var(--color-danger) 15%, transparent)' : 'color-mix(in srgb, var(--color-success) 10%, transparent)'; + const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-'; + const heading = v.heading ?? v.cog; + const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-'; + const receivedAt = v.lastUpdate ? formatDateTime(v.lastUpdate) : '-'; return (
- {v.flag} + {v.nationalCode ?? '🚢'}
- {v.name} + {v.shipNm ?? '(이름 없음)'}
MMSI: {v.mmsi} @@ -1348,7 +1371,7 @@ function VesselPopupPanel({ border: '1px solid color-mix(in srgb, var(--color-info) 25%, transparent)', }} > - {v.typS} + {getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'} - {v.status} + {statusText}
{/* Data rows */}
- - + +
- {v.depart} + -
@@ -1387,11 +1410,11 @@ function VesselPopupPanel({ 입항지 - {v.arrive} + {v.destination ?? '-'}
- +
{/* Buttons */} @@ -1636,7 +1659,13 @@ const TAB_LABELS: { key: DetTab; label: string }[] = [ { key: 'dg', label: '위험물정보' }, ]; -function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: () => void }) { +function VesselDetailModal({ + vessel: v, + onClose, +}: { + vessel: VesselPosition; + onClose: () => void; +}) { const [tab, setTab] = useState('info'); return ( @@ -1665,11 +1694,13 @@ function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: () style={{ padding: '14px 18px' }} >
- {v.flag} + {v.nationalCode ?? '🚢'}
-
{v.name}
+
+ {v.shipNm ?? '(이름 없음)'} +
- MMSI: {v.mmsi} · IMO: {v.imo} + MMSI: {v.mmsi} · IMO: {displayVal(v.imo)}
@@ -1817,7 +1848,10 @@ function StatusBadge({ label, color }: { label: string; color: string }) { } /* ── Tab 0: 상세정보 ─────────────────────────────── */ -function TabInfo({ v }: { v: Vessel }) { +function TabInfo({ v }: { v: VesselPosition }) { + const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-'; + const heading = v.heading ?? v.cog; + const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-'; return ( <>
- + - - - + + + - - - - + + + + @@ -1856,7 +1890,7 @@ function TabInfo({ v }: { v: Vessel }) { /* ── Tab 1: 항해정보 ─────────────────────────────── */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -function TabNav(_props: { v: Vessel }) { +function TabNav(_props: { v: VesselPosition }) { const hours = ['08', '09', '10', '11', '12', '13', '14']; const heights = [45, 60, 78, 82, 70, 85, 75]; const colors = [ @@ -1979,28 +2013,30 @@ function TabNav(_props: { v: Vessel }) { } /* ── Tab 2: 선박제원 ─────────────────────────────── */ -function TabSpec({ v }: { v: Vessel }) { +function TabSpec({ v }: { v: VesselPosition }) { + const loa = v.length !== undefined ? `${v.length} m` : '-'; + const beam = v.width !== undefined ? `${v.width} m` : '-'; return ( <> - - - - - - - - + + + + + + + + - - - + + + @@ -2016,23 +2052,9 @@ function TabSpec({ v }: { v: Vessel }) { > 🛢
-
- {v.cargo.split('·')[0].trim()} -
-
{v.cargo}
+
-
+
정보 없음
- {v.cargo.includes('IMO') && ( - - 위험 - - )}
@@ -2042,7 +2064,7 @@ function TabSpec({ v }: { v: Vessel }) { /* ── Tab 3: 보험정보 ─────────────────────────────── */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -function TabInsurance(_props: { v: Vessel }) { +function TabInsurance(_props: { v: VesselPosition }) { return ( <> @@ -2114,7 +2136,8 @@ function TabInsurance(_props: { v: Vessel }) { } /* ── Tab 4: 위험물정보 ───────────────────────────── */ -function TabDangerous({ v }: { v: Vessel }) { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function TabDangerous(_props: { v: VesselPosition }) { return ( <> - + @@ -2308,18 +2327,22 @@ function ActionBtn({ /* ════════════════════════════════════════════════════ 호버 툴팁 컴포넌트 ════════════════════════════════════════════════════ */ -function VesselTooltipContent({ vessel: v }: { vessel: Vessel }) { +function VesselTooltipContent({ vessel: v }: { vessel: VesselPosition }) { + const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '- kn'; + const heading = v.heading ?? v.cog; + const headingText = heading !== undefined ? `HDG ${Math.round(heading)}°` : 'HDG -'; + const typeText = [getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-', v.nationalCode].filter(Boolean).join(' · '); return ( <>
- {v.name} + {v.shipNm ?? '(이름 없음)'}
- {v.typS} · {v.flag} + {typeText}
- {v.speed} kn - HDG {v.heading}° + {speed} + {headingText}
); diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index b3f1c14..8b69fc6 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -1,4 +1,6 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useVesselSignals } from '@common/hooks/useVesselSignals'; +import type { MapBounds } from '@common/types/vessel'; import { LeftPanel } from './LeftPanel'; import { RightPanel } from './RightPanel'; import { MapView } from '@common/components/map/MapView'; @@ -173,6 +175,8 @@ export function OilSpillView() { const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined); const flyToTarget = null; const fitBoundsTarget = null; + const [mapBounds, setMapBounds] = useState(null); + const vessels = useVesselSignals(mapBounds); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [oilTrajectory, setOilTrajectory] = useState([]); const [centerPoints, setCenterPoints] = useState([]); @@ -1328,6 +1332,8 @@ export function OilSpillView() { showBeached={displayControls.showBeached} showTimeLabel={displayControls.showTimeLabel} simulationStartTime={accidentTime || undefined} + vessels={vessels} + onBoundsChange={setMapBounds} /> {/* 타임라인 플레이어 (리플레이 비활성 시) */} diff --git a/frontend/src/tabs/rescue/components/RescueView.tsx b/frontend/src/tabs/rescue/components/RescueView.tsx index 1244f38..fde68d9 100755 --- a/frontend/src/tabs/rescue/components/RescueView.tsx +++ b/frontend/src/tabs/rescue/components/RescueView.tsx @@ -1,4 +1,6 @@ import { Fragment, useState, useEffect, useCallback } from 'react'; +import { useVesselSignals } from '@common/hooks/useVesselSignals'; +import type { MapBounds } from '@common/types/vessel'; import { useSubMenu } from '@common/hooks/useSubMenu'; import { MapView } from '@common/components/map/MapView'; import { RescueTheoryView } from './RescueTheoryView'; @@ -1535,6 +1537,8 @@ export function RescueView() { const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null); const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined); const [isSelectingLocation, setIsSelectingLocation] = useState(false); + const [mapBounds, setMapBounds] = useState(null); + const vessels = useVesselSignals(mapBounds); useEffect(() => { fetchGscAccidents() @@ -1600,6 +1604,8 @@ export function RescueView() { oilTrajectory={[]} enabledLayers={new Set()} showOverlays={false} + vessels={vessels} + onBoundsChange={setMapBounds} />
Date: Wed, 15 Apr 2026 14:43:28 +0900 Subject: [PATCH 7/8] =?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 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 1095133..60baccd 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -6,9 +6,11 @@ ### 추가 - 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동) +- 실시간 선박 신호 지도 표출: 한국 해역 1분 주기 폴링 스케줄러, 호버 툴팁·클릭 팝업·상세 모달 제공 (확산예측·HNS·긴급구난·사건사고 탭 연동) ### 변경 - MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용 +- aerial 이미지 분석 API 기본 URL 변경 ## [2026-04-14] From fa5c7f518fde955b35a53546f00ce1d8ea4ff5ff Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 15 Apr 2026 14:47:13 +0900 Subject: [PATCH 8/8] =?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-04-15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 60baccd..d22c636 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-04-15] + ### 추가 - 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동) - 실시간 선박 신호 지도 표출: 한국 해역 1분 주기 폴링 스케줄러, 호버 툴팁·클릭 팝업·상세 모달 제공 (확산예측·HNS·긴급구난·사건사고 탭 연동)