diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5bb6047..d88decc 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-04-09] + ### 추가 - 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast) - 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather) diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 9ab297a..578ed71 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -1458,19 +1458,19 @@ function MapControls({ center, zoom }: { center: [number, number]; zoom: number
diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 0bd81c1..38faee2 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -30,6 +30,22 @@ background: transparent; } + /* ═══ Incidents 사고 팝업 ✕ 버튼 — 라이트 지도 기준 검은색 고정 ═══ */ + .incident-popup .maplibregl-popup-close-button { + color: #1a1d21; + background: transparent; + width: 16px; + height: 16px; + font-size: 16px; + line-height: 16px; + top: 6px; + right: 6px; + } + .incident-popup .maplibregl-popup-close-button:hover { + color: #000; + background: transparent; + } + /* ═══ Scrollbar ═══ */ .scrollbar-thin { scrollbar-width: thin; diff --git a/frontend/src/tabs/hns/components/HNSLeftPanel.tsx b/frontend/src/tabs/hns/components/HNSLeftPanel.tsx index 97d3094..82b7a9d 100755 --- a/frontend/src/tabs/hns/components/HNSLeftPanel.tsx +++ b/frontend/src/tabs/hns/components/HNSLeftPanel.tsx @@ -219,7 +219,7 @@ export function HNSLeftPanel({ }; return ( -
+
{/* Scrollable Content */}
-
+
+
📊
예측 실행 후 결과가 표시됩니다
@@ -58,7 +58,7 @@ export function HNSRightPanel({ : 'ALOHA'; return ( -
+
{/* Header */}
diff --git a/frontend/src/tabs/hns/components/HNSView.tsx b/frontend/src/tabs/hns/components/HNSView.tsx index bfe59fb..4a49115 100755 --- a/frontend/src/tabs/hns/components/HNSView.tsx +++ b/frontend/src/tabs/hns/components/HNSView.tsx @@ -262,6 +262,8 @@ function DispersionTimeSlider({ export function HNSView() { const { activeSubTab, setActiveSubTab } = useSubMenu('hns'); const { user } = useAuthStore(); + const [leftCollapsed, setLeftCollapsed] = useState(false); + const [rightCollapsed, setRightCollapsed] = useState(false); const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [isRunningPrediction, setIsRunningPrediction] = useState(false); @@ -890,22 +892,66 @@ export function HNSView() {
{/* Left Panel - 분석 목록일 때는 숨김 */} {activeSubTab === 'analysis' && ( - setIsSelectingLocation(true)} - onRunPrediction={handleRunPrediction} - isRunningPrediction={isRunningPrediction} - onParamsChange={handleParamsChange} - onReset={handleReset} - loadedParams={loadedParams} - /> +
+ setIsSelectingLocation(true)} + onRunPrediction={handleRunPrediction} + isRunningPrediction={isRunningPrediction} + onParamsChange={handleParamsChange} + onReset={handleReset} + loadedParams={loadedParams} + /> +
)} {/* Center - Map/Content Area */}
+ {/* Left panel toggle button */} + {activeSubTab === 'analysis' && ( + + )} + + {/* Right panel toggle button */} + {activeSubTab === 'analysis' && ( + + )} + {activeSubTab === 'list' ? ( @@ -942,14 +988,16 @@ export function HNSView() { {/* Right Panel - 분석 목록일 때는 숨김 */} {activeSubTab === 'analysis' && ( - setRecalcModalOpen(true)} - onOpenReport={handleOpenReport} - onSave={handleSave} - /> +
+ setRecalcModalOpen(true)} + onOpenReport={handleOpenReport} + onSave={handleSave} + /> +
)} {/* HNS 재계산 모달 */} diff --git a/frontend/src/tabs/incidents/components/DischargeZonePanel.tsx b/frontend/src/tabs/incidents/components/DischargeZonePanel.tsx index d47f56a..6afccdd 100644 --- a/frontend/src/tabs/incidents/components/DischargeZonePanel.tsx +++ b/frontend/src/tabs/incidents/components/DischargeZonePanel.tsx @@ -1,4 +1,7 @@ import { useState } from 'react'; +import { DndContext } from '@dnd-kit/core'; +import { useDraggable } from '@dnd-kit/core'; +import type { DragEndEvent } from '@dnd-kit/core'; /** * 해양환경관리법 제22조 기반 선박 발생 오염물 배출 규정 @@ -100,33 +103,30 @@ const RULES: DischargeRule[] = [ ]; const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25~50해리', '50해리+']; -const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#64748b']; +const ZONE_COLORS = [ + 'var(--color-danger)', + 'var(--color-warning)', + 'var(--color-caution)', + 'var(--color-success)', + 'var(--fg-disabled)', +]; function StatusBadge({ status }: { status: Status }) { if (status === 'forbidden') return ( 배출불가 ); - if (status === 'allowed') - return ( - - 배출가능 - - ); return ( - - 조건부 + + {status === 'allowed' ? '배출가능' : '조건부'} ); } @@ -139,15 +139,36 @@ interface DischargeZonePanelProps { onClose: () => void; } -export function DischargeZonePanel({ +export function DischargeZonePanel(props: DischargeZonePanelProps) { + const [offset, setOffset] = useState({ x: 0, y: 0 }); + + function handleDragEnd(event: DragEndEvent) { + setOffset((prev) => ({ x: prev.x + event.delta.x, y: prev.y + event.delta.y })); + } + + return ( + + + + ); +} + +function DraggablePanel({ lat, lon, distanceNm, zoneIndex, onClose, -}: DischargeZonePanelProps) { + offset, +}: DischargeZonePanelProps & { offset: { x: number; y: number } }) { const zoneIdx = zoneIndex; const [expandedCat, setExpandedCat] = useState(null); + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: 'discharge-panel', + }); + + const tx = offset.x + (transform?.x ?? 0); + const ty = offset.y + (transform?.y ?? 0); const categories = [...new Set(RULES.map((r) => r.category))]; @@ -161,22 +182,33 @@ export function DischargeZonePanel({ border: '1px solid var(--stroke-default)', boxShadow: '0 16px 48px rgba(0,0,0,0.5)', backdropFilter: 'blur(12px)', + transform: `translate(${tx}px, ${ty}px)`, }} > - {/* Header */} + {/* Header — drag handle */}
-
🚢 오염물 배출 규정
+
🚢 오염물 배출 규정
해양환경관리법 제22조
- + e.stopPropagation()} + className="text-title-3 cursor-pointer text-fg-sub hover:text-fg" + style={{ pointerEvents: 'all' }} + > ✕
@@ -194,10 +226,7 @@ export function DischargeZonePanel({
영해기선 거리 - + {distanceNm.toFixed(1)} NM
@@ -206,12 +235,10 @@ export function DischargeZonePanel({ {ZONE_LABELS.map((label, i) => (
r.category === cat); const isExpanded = expandedCat === cat; const allForbidden = catRules.every((r) => r.zones[zoneIdx] === 'forbidden'); - const allAllowed = catRules.every((r) => r.zones[zoneIdx] === 'allowed'); - const summaryColor = allForbidden ? '#ef4444' : allAllowed ? '#22c55e' : '#eab308'; + const summaryColor = allForbidden ? 'var(--color-danger)' : 'var(--fg-sub)'; return (
@@ -245,11 +271,11 @@ export function DischargeZonePanel({
- {cat} + {cat}
- - {allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'} + + {allForbidden ? '전체 불가' : '허용'} {isExpanded ? '▾' : '▸'}
@@ -268,7 +294,7 @@ export function DischargeZonePanel({ borderRadius: 4, }} > - {rule.item} + {rule.item}
))} diff --git a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx index ef73bcc..cfe263f 100755 --- a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx @@ -250,11 +250,11 @@ export function IncidentsLeftPanel({ /> {(inc.mediaCount ?? 0) > 0 && ( )}
diff --git a/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx index 9b10f34..c23570a 100755 --- a/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx @@ -50,31 +50,8 @@ interface AnalysisItem { checked: boolean; } -/* ── 카테고리별 고유 색상 (목록 순서 인덱스 기반 — 중복 없음) ── */ -const CATEGORY_PALETTE: [number, number, number][] = [ - [239, 68, 68], // red - [249, 115, 22], // orange - [234, 179, 8], // yellow - [132, 204, 22], // lime - [20, 184, 166], // teal - [6, 182, 212], // cyan - [59, 130, 246], // blue - [99, 102, 241], // indigo - [168, 85, 247], // purple - [236, 72, 153], // pink - [244, 63, 94], // rose - [16, 185, 129], // emerald - [14, 165, 233], // sky - [139, 92, 246], // violet - [217, 119, 6], // amber - [45, 212, 191], // turquoise -]; - -function getCategoryColor(index: number): [number, number, number] { - return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length]; -} - -/* ── 카테고리 → 이모지 매핑 (prediction LeftPanel의 CATEGORY_ICON_MAP 기반) ── */ +/* ── 카테고리 → 이모지 매핑 (prediction LeftPanel의 CATEGORY_ICON_MAP 기반, 미사용 보존) ── */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars const CATEGORY_ICON: Record = { 어장정보: '🐟', 양식장: '🦪', @@ -140,8 +117,20 @@ function getActiveModels(p: PredictionAnalysis): string { /* ── HNS/구난 섹션 (미개발, 고정 구조만 유지) ────── */ const STATIC_SECTIONS = [ - { key: 'hns', icon: '🧪', title: 'HNS 대기확산', color: '#a855f7', colorRgb: '168,85,247' }, - { key: 'rsc', icon: '🚨', title: '긴급구난', color: '#06b6d4', colorRgb: '6,182,212' }, + { + key: 'hns', + icon: '🧪', + title: 'HNS 대기확산', + color: 'var(--color-accent)', + colorRgb: '6,182,212', + }, + { + key: 'rsc', + icon: '🚨', + title: '긴급구난', + color: 'var(--color-accent)', + colorRgb: '6,182,212', + }, ]; /* ── Component ───────────────────────────────────── */ @@ -292,7 +281,7 @@ export function IncidentsRightPanel({ key: 'oil', icon: '🛢', title: '유출유 확산예측', - color: '#f97316', + color: 'var(--color-accent)', colorRgb: '249,115,22', totalLabel: `전체 ${predItems.length}건`, items: predItems.map((p) => { @@ -310,7 +299,7 @@ export function IncidentsRightPanel({ if (!incident) { return ( -
+
📊
좌측에서 사고를 선택하면 @@ -327,7 +316,7 @@ export function IncidentsRightPanel({
🔬 통합분석 조회
- 선택: {incident.name} + 선택: {incident.name}
@@ -344,22 +333,19 @@ export function IncidentsRightPanel({
- {sec.icon} - - {sec.title} - + {/* {sec.icon} */} + {sec.title}
@@ -374,8 +360,7 @@ export function IncidentsRightPanel({ className="flex items-center gap-1.5" style={{ padding: '5px 8px', - background: `rgba(${sec.colorRgb},0.06)`, - border: `1px solid rgba(${sec.colorRgb},0.15)`, + border: '1px solid var(--stroke-default)', borderRadius: '4px', }} > @@ -415,22 +400,19 @@ export function IncidentsRightPanel({
- {sec.icon} - - {sec.title} - + {/* {sec.icon} */} + {sec.title}
준비 중입니다
@@ -443,8 +425,8 @@ export function IncidentsRightPanel({ {/* 민감자원 */}
- 🐟 - 민감자원 + {/* 🐟 */} + 민감자원
{sensCategories.length === 0 ? ( @@ -452,38 +434,33 @@ export function IncidentsRightPanel({ 해당 사고 영역의 민감자원이 없습니다
) : ( - sensCategories.map((cat, i) => { - const icon = CATEGORY_ICON[cat.category] ?? '🌊'; + sensCategories.map((cat) => { const areaLabel = cat.totalArea != null ? `${cat.totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 0 })}ha` : `${cat.count}개소`; - const [r, g, b] = getCategoryColor(i); - const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; return ( @@ -496,10 +473,9 @@ export function IncidentsRightPanel({ {/* 근처 방제자원 */}
- 🛡 - 근처 방제자원 + 근처 방제자원 {nearbyOrgs.length > 0 && ( - + {nearbyOrgs.length}개 )} @@ -519,21 +495,18 @@ export function IncidentsRightPanel({ 반경 내 방제자원 없음
) : ( -
+
{nearbyOrgs.map((org) => (
{org.orgTp} @@ -544,7 +517,7 @@ export function IncidentsRightPanel({ {org.totalAssets > 0 ? ` · 장비 ${org.totalAssets}개` : ''}
- + {org.distanceNm.toFixed(1)} nm
@@ -553,10 +526,10 @@ export function IncidentsRightPanel({ )} {/* Radius slider */} -
+
탐색 반경 - + {nearbyRadius} nm
@@ -572,7 +545,7 @@ export function IncidentsRightPanel({ height: '4px', background: 'var(--stroke-default)', borderRadius: '2px', - accentColor: '#f59e0b', + accentColor: 'var(--color-accent)', }} />
@@ -605,7 +578,8 @@ export function IncidentsRightPanel({ color: isActive ? 'var(--color-accent)' : 'var(--fg-disabled)', }} > - {v.icon} {v.label} + {/* {v.icon} */} + {v.label} ); })} @@ -627,9 +601,7 @@ export function IncidentsRightPanel({ className="w-full text-label-2 font-bold cursor-pointer" style={{ padding: '8px', - background: analysisActive - ? 'linear-gradient(135deg,rgba(239,68,68,0.15),rgba(239,68,68,0.1))' - : 'linear-gradient(135deg,rgba(6,182,212,0.15),rgba(59,130,246,0.1))', + background: analysisActive ? 'rgba(239,68,68,0.1)' : 'rgba(6,182,212,0.1)', border: analysisActive ? '1px solid rgba(239,68,68,0.3)' : '1px solid rgba(6,182,212,0.3)', diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx index 9d6837b..705a8db 100755 --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -137,6 +137,8 @@ export function IncidentsView() { const [hoverInfo, setHoverInfo] = useState(null); const [dischargeMode, setDischargeMode] = useState(false); + const [leftCollapsed, setLeftCollapsed] = useState(false); + const [rightCollapsed, setRightCollapsed] = useState(false); const [dischargeInfo, setDischargeInfo] = useState<{ lat: number; lon: number; @@ -195,12 +197,17 @@ export function IncidentsView() { if (sections.length === 0) return; const tags: { icon: string; label: string; color: string }[] = []; sections.forEach((s) => { - if (s.key === 'oil') tags.push({ icon: '🛢', label: '유출유', color: '#f97316' }); - if (s.key === 'hns') tags.push({ icon: '🧪', label: 'HNS', color: '#a855f7' }); - if (s.key === 'rsc') tags.push({ icon: '🚨', label: '구난', color: '#06b6d4' }); + if (s.key === 'oil') + tags.push({ icon: '🛢', label: '유출유', color: 'var(--color-warning)' }); + if (s.key === 'hns') tags.push({ icon: '🧪', label: 'HNS', color: 'var(--color-tertiary)' }); + if (s.key === 'rsc') tags.push({ icon: '🚨', label: '구난', color: 'var(--color-accent)' }); }); if (sensitiveCount > 0) - tags.push({ icon: '🐟', label: `민감자원 ${sensitiveCount}건`, color: '#22c55e' }); + tags.push({ + icon: '🐟', + label: `민감자원 ${sensitiveCount}건`, + color: 'var(--color-success)', + }); setAnalysisTags(tags); setAnalysisActive(true); }; @@ -573,11 +580,13 @@ export function IncidentsView() { return (
{/* Left Panel */} - +
+ +
{/* Center - Map + Analysis Views */}
@@ -588,7 +597,8 @@ export function IncidentsView() { style={{ height: 36, padding: '0 16px', - background: 'linear-gradient(90deg,rgba(6,182,212,0.06),var(--bg-surface))', + background: + 'linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 6%, transparent), var(--bg-surface))', }} >
@@ -601,8 +611,8 @@ export function IncidentsView() { className="text-caption font-semibold rounded-md" style={{ padding: '2px 8px', - background: `${t.color}18`, - border: `1px solid ${t.color}40`, + background: `color-mix(in srgb, ${t.color} 18%, transparent)`, + border: `1px solid color-mix(in srgb, ${t.color} 40%, transparent)`, color: t.color, }} > @@ -625,10 +635,13 @@ export function IncidentsView() { className="text-caption font-semibold cursor-pointer rounded-sm" style={{ padding: '3px 10px', - background: viewMode === v.mode ? 'rgba(6,182,212,0.12)' : 'var(--bg-card)', + background: + viewMode === v.mode + ? 'color-mix(in srgb, var(--color-accent) 12%, transparent)' + : 'var(--bg-card)', border: viewMode === v.mode - ? '1px solid rgba(6,182,212,0.3)' + ? '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)' : '1px solid var(--stroke-default)', color: viewMode === v.mode ? 'var(--color-accent)' : 'var(--fg-disabled)', }} @@ -641,8 +654,8 @@ export function IncidentsView() { className="text-caption font-semibold cursor-pointer rounded-sm" style={{ padding: '3px 8px', - background: 'rgba(239,68,68,0.06)', - border: '1px solid rgba(239,68,68,0.2)', + background: 'color-mix(in srgb, var(--color-danger) 6%, transparent)', + border: '1px solid color-mix(in srgb, var(--color-danger) 20%, transparent)', color: 'var(--color-danger)', }} > @@ -654,6 +667,44 @@ export function IncidentsView() { {/* Map / Analysis Content Area */}
+ {/* Left panel toggle button */} + + + {/* Right panel toggle button */} + + {/* Default Map (visible when not in analysis or in overlay mode) */} {(!analysisActive || viewMode === 'overlay') && (
@@ -691,12 +742,13 @@ export function IncidentsView() { onClose={() => setIncidentPopup(null)} closeButton={true} closeOnClick={false} + className="incident-popup" >
-
+
{incidentPopup.incident.name}
-
+
상태: {getStatusLabel(incidentPopup.incident.status)}
일시: {incidentPopup.incident.date} {incidentPopup.incident.time} @@ -706,7 +758,7 @@ export function IncidentsView() {
원인: {incidentPopup.incident.causeType}
)} {incidentPopup.incident.prediction && ( -
+
{incidentPopup.incident.prediction}
)} @@ -750,7 +802,7 @@ export function IncidentsView() { width: 180, height: 120, background: - 'radial-gradient(ellipse, rgba(249,115,22,0.35) 0%, rgba(249,115,22,0.1) 50%, transparent 70%)', + 'radial-gradient(ellipse, color-mix(in srgb, var(--color-warning) 35%, transparent) 0%, color-mix(in srgb, var(--color-warning) 10%, transparent) 50%, transparent 70%)', borderRadius: '50%', transform: 'rotate(-15deg)', }} @@ -765,7 +817,7 @@ export function IncidentsView() { width: 150, height: 100, background: - 'radial-gradient(ellipse, rgba(168,85,247,0.3) 0%, rgba(168,85,247,0.08) 50%, transparent 70%)', + 'radial-gradient(ellipse, color-mix(in srgb, var(--color-tertiary) 30%, transparent) 0%, color-mix(in srgb, var(--color-tertiary) 8%, transparent) 50%, transparent 70%)', borderRadius: '50%', transform: 'rotate(20deg)', }} @@ -779,7 +831,8 @@ export function IncidentsView() { left: '42%', width: 200, height: 200, - border: '2px dashed rgba(6,182,212,0.4)', + border: + '2px dashed color-mix(in srgb, var(--color-accent) 40%, transparent)', borderRadius: '50%', }} /> @@ -796,18 +849,16 @@ export function IncidentsView() { className="absolute z-[500] cursor-pointer rounded-md text-caption font-bold font-korean" style={{ top: 10, - right: dischargeMode ? 340 : 180, + right: 180, padding: '6px 10px', - background: dischargeMode ? 'rgba(6,182,212,0.15)' : 'rgba(13,17,23,0.88)', - border: dischargeMode - ? '1px solid rgba(6,182,212,0.4)' - : '1px solid var(--stroke-default)', - color: dischargeMode ? '#22d3ee' : 'var(--fg-disabled)', + background: 'var(--bg-base)', + border: '1px solid var(--stroke-default)', + color: dischargeMode ? 'var(--color-accent)' : 'var(--fg-disabled)', backdropFilter: 'blur(8px)', - transition: 'all 0.2s', + transition: 'color 0.2s', }} > - 🚢 배출규정 {dischargeMode ? 'ON' : 'OFF'} + 배출규정 {dischargeMode ? 'ON' : 'OFF'} {/* 오염물 배출 규정 패널 */} @@ -830,9 +881,9 @@ export function IncidentsView() { left: '50%', transform: 'translate(-50%, -50%)', padding: '12px 20px', - background: 'rgba(13,17,23,0.9)', - border: '1px solid rgba(6,182,212,0.3)', - color: '#22d3ee', + background: 'var(--bg-base)', + border: '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)', + color: 'var(--color-accent)', backdropFilter: 'blur(8px)', pointerEvents: 'none', }} @@ -845,14 +896,14 @@ export function IncidentsView() {
-
- AIS Live + /> */} + AIS Live MarineTraffic
-
- 선박 20 -
-
- 사고 6 -
-
- 방제선 2 -
+
선박 20
+
사고 6
+
방제선 2
@@ -881,29 +926,29 @@ export function IncidentsView() {
사고 상태
-
+
{[ - { c: '#ef4444', l: '대응중' }, - { c: '#f59e0b', l: '조사중' }, - { c: '#6b7280', l: '종료' }, + { c: 'var(--color-danger)', l: '대응중' }, + { c: 'var(--color-warning)', l: '조사중' }, + { c: 'var(--fg-disabled)', l: '종료' }, ].map((s) => ( -
+
{s.l}
))}
AIS 선박
-
+
{VESSEL_LEGEND.map((vl) => ( -
+
- {vl.type} + {vl.type}
))}
@@ -993,11 +1038,15 @@ export function IncidentsView() { className="flex items-center shrink-0 border-b border-stroke" style={{ height: 28, - background: 'linear-gradient(90deg,rgba(249,115,22,0.08),var(--bg-surface))', + background: + 'linear-gradient(90deg, color-mix(in srgb, var(--color-warning) 8%, transparent), var(--bg-surface))', padding: '0 10px', }} > - + 🛢 유출유 확산예측
@@ -1006,7 +1055,7 @@ export function IncidentsView() { style={{ padding: 10 }} >
@@ -1016,11 +1065,15 @@ export function IncidentsView() { className="flex items-center shrink-0 border-b border-stroke" style={{ height: 28, - background: 'linear-gradient(90deg,rgba(168,85,247,0.08),var(--bg-surface))', + background: + 'linear-gradient(90deg, color-mix(in srgb, var(--color-tertiary) 8%, transparent), var(--bg-surface))', padding: '0 10px', }} > - + 🧪 HNS 대기확산
@@ -1029,7 +1082,7 @@ export function IncidentsView() { style={{ padding: 10 }} >
@@ -1039,7 +1092,8 @@ export function IncidentsView() { className="flex items-center shrink-0 border-b border-stroke" style={{ height: 28, - background: 'linear-gradient(90deg,rgba(6,182,212,0.08),var(--bg-surface))', + background: + 'linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 8%, transparent), var(--bg-surface))', padding: '0 10px', }} > @@ -1050,7 +1104,7 @@ export function IncidentsView() { style={{ padding: 10 }} >
@@ -1074,8 +1128,8 @@ export function IncidentsView() { className="cursor-pointer rounded text-caption font-semibold" style={{ padding: '4px 12px', - background: 'rgba(59,130,246,0.1)', - border: '1px solid rgba(59,130,246,0.2)', + background: 'color-mix(in srgb, var(--color-info) 10%, transparent)', + border: '1px solid color-mix(in srgb, var(--color-info) 20%, transparent)', color: 'var(--color-info)', }} > @@ -1085,9 +1139,9 @@ export function IncidentsView() { className="cursor-pointer rounded text-caption font-semibold" style={{ padding: '4px 12px', - background: 'rgba(168,85,247,0.1)', - border: '1px solid rgba(168,85,247,0.2)', - color: '#a78bfa', + background: 'color-mix(in srgb, var(--color-tertiary) 10%, transparent)', + border: '1px solid color-mix(in srgb, var(--color-tertiary) 20%, transparent)', + color: 'var(--color-tertiary)', }} > 🔗 R&D 연계 @@ -1098,17 +1152,19 @@ export function IncidentsView() {
{/* Right Panel */} - +
+ +
); } @@ -1145,11 +1201,11 @@ function SplitPanelContent({ model: 'KOSPS + OpenDrift · BUNKER-C 150kL', items: [ { label: '예측 시간', value: '72시간 (3일)' }, - { label: '최대 확산거리', value: '12.3 NM', color: '#f97316' }, - { label: '해안 도달 시간', value: '18시간 후', color: '#ef4444' }, + { label: '최대 확산거리', value: '12.3 NM', color: 'var(--color-warning)' }, + { label: '해안 도달 시간', value: '18시간 후', color: 'var(--color-danger)' }, { label: '영향 해안선', value: '27.5 km' }, { label: '풍화율', value: '32.4%' }, - { label: '잔존유량', value: '101.4 kL', color: '#f97316' }, + { label: '잔존유량', value: '101.4 kL', color: 'var(--color-warning)' }, ], summary: '여수항 남동쪽 방향 확산, 18시간 후 돌산도 해안 도달 예상. 조류 영향으로 남서쪽 이동.', @@ -1158,12 +1214,12 @@ function SplitPanelContent({ title: 'HNS 대기확산 결과', model: 'ALOHA + PHAST · 톨루엔 5톤', items: [ - { label: 'IDLH 범위', value: '1.2 km', color: '#ef4444' }, - { label: 'ERPG-2 범위', value: '2.8 km', color: '#f97316' }, - { label: 'ERPG-1 범위', value: '5.1 km', color: '#eab308' }, + { label: 'IDLH 범위', value: '1.2 km', color: 'var(--color-danger)' }, + { label: 'ERPG-2 범위', value: '2.8 km', color: 'var(--color-warning)' }, + { label: 'ERPG-1 범위', value: '5.1 km', color: 'var(--color-caution)' }, { label: '풍향', value: 'SW → NE 방향' }, { label: '대기 안정도', value: 'D등급 (중립)' }, - { label: '영향 인구', value: '약 2,400명', color: '#ef4444' }, + { label: '영향 인구', value: '약 2,400명', color: 'var(--color-danger)' }, ], summary: '남서풍에 의해 북동쪽 내륙 방향 확산. IDLH 1.2km 이내 즉시 대피 필요.', }, @@ -1171,12 +1227,12 @@ function SplitPanelContent({ title: '긴급구난 SAR 결과', model: 'SAROPS · Monte Carlo 10,000회 시뮬레이션', items: [ - { label: '95% 확률 범위', value: '8.5 NM²', color: '#06b6d4' }, + { label: '95% 확률 범위', value: '8.5 NM²', color: 'var(--color-accent)' }, { label: '최적 탐색 경로', value: 'Sector Search' }, { label: '예상 표류 속도', value: '1.8 kn' }, { label: '표류 방향', value: 'NE (045°)' }, - { label: '생존 가능 시간', value: '36시간', color: '#ef4444' }, - { label: '필요 자산', value: '헬기 2 + 경비정 3', color: '#f97316' }, + { label: '생존 가능 시간', value: '36시간', color: 'var(--color-danger)' }, + { label: '필요 자산', value: '헬기 2 + 경비정 3', color: 'var(--color-warning)' }, ], summary: '북동쪽 방향 표류 예상. 06:00 기준 최적 탐색 패턴: 섹터탐색(Sector Search).', }, @@ -1190,8 +1246,8 @@ function SplitPanelContent({ className="rounded-sm" style={{ padding: '10px 12px', - background: `${tag.color}08`, - border: `1px solid ${tag.color}20`, + background: `color-mix(in srgb, ${tag.color} 8%, transparent)`, + border: `1px solid color-mix(in srgb, ${tag.color} 20%, transparent)`, }} >
@@ -1219,7 +1275,7 @@ function SplitPanelContent({ {item.label} {item.value} @@ -1231,8 +1287,8 @@ function SplitPanelContent({ className="rounded-sm text-caption text-fg-sub" style={{ padding: '8px 10px', - background: `${tag.color}06`, - border: `1px solid ${tag.color}15`, + background: `color-mix(in srgb, ${tag.color} 6%, transparent)`, + border: `1px solid color-mix(in srgb, ${tag.color} 15%, transparent)`, lineHeight: 1.6, }} > @@ -1262,8 +1318,10 @@ function VesselPopupPanel({ onClose: () => void; onDetail: () => void; }) { - const statusColor = v.status.includes('사고') ? '#ef4444' : '#22c55e'; - const statusBg = v.status.includes('사고') ? 'rgba(239,68,68,0.15)' : 'rgba(34,197,94,0.1)'; + const statusColor = v.status.includes('사고') ? 'var(--color-danger)' : 'var(--color-success)'; + const statusBg = v.status.includes('사고') + ? 'color-mix(in srgb, var(--color-danger) 15%, transparent)' + : 'color-mix(in srgb, var(--color-success) 10%, transparent)'; return (
@@ -1288,8 +1346,8 @@ function VesselPopupPanel({ alignItems: 'center', gap: 8, padding: '10px 14px', - background: 'var(--bg-elevated)', - borderBottom: '1px solid var(--stroke-default)', + background: 'rgba(22,27,34,0.97)', + borderBottom: '1px solid rgba(48,54,61,0.8)', }} >
-
+
{v.name}
-
MMSI: {v.mmsi}
+
+ MMSI: {v.mmsi} +
- +
{/* Ship Image */}
🚢 @@ -1324,14 +1392,14 @@ function VesselPopupPanel({ {/* Tags */}
{v.typS} @@ -1341,7 +1409,7 @@ function VesselPopupPanel({ style={{ padding: '2px 8px', background: statusBg, - border: `1px solid ${statusColor}40`, + border: `1px solid color-mix(in srgb, ${statusColor} 40%, transparent)`, color: statusColor, }} > @@ -1361,12 +1429,20 @@ function VesselPopupPanel({ }} >
- 출항지 - {v.depart} + + 출항지 + + + {v.depart} +
- 입항지 - {v.arrive} + + 입항지 + + + {v.arrive} +
@@ -1376,31 +1452,34 @@ function VesselPopupPanel({
+ )} + + {/* Right panel toggle button */} + {activeSubTab === 'analysis' && ( + + )} + {activeSubTab === 'list' ? ( { - if (!selectedAnalysis) { - alert('선택된 사고가 없습니다.\n분석 목록에서 사고를 선택해주세요.'); - return; +
+ { + if (!selectedAnalysis) { + alert('선택된 사고가 없습니다.\n분석 목록에서 사고를 선택해주세요.'); + return; + } + setRecalcModalOpen(true); + }} + onOpenReport={handleOpenReport} + detail={analysisDetail} + summary={ + stepSummariesByModel[windHydrModel]?.[currentStep] ?? + summaryByModel[windHydrModel] ?? + simulationSummary } - setRecalcModalOpen(true); - }} - onOpenReport={handleOpenReport} - detail={analysisDetail} - summary={ - stepSummariesByModel[windHydrModel]?.[currentStep] ?? - summaryByModel[windHydrModel] ?? - simulationSummary - } - boomBlockedVolume={boomBlockedVolume} - displayControls={displayControls} - onDisplayControlsChange={setDisplayControls} - windHydrModel={windHydrModel} - windHydrModelOptions={windHydrModelOptions} - onWindHydrModelChange={setWindHydrModel} - analysisTab={analysisTab} - onSwitchAnalysisTab={setAnalysisTab} - drawAnalysisMode={drawAnalysisMode} - analysisPolygonPoints={analysisPolygonPoints} - circleRadiusNm={circleRadiusNm} - onCircleRadiusChange={setCircleRadiusNm} - analysisResult={analysisResult} - incidentCoord={incidentCoord} - centerPoints={centerPoints} - predictionTime={predictionTime} - onStartPolygonDraw={handleStartPolygonDraw} - onRunPolygonAnalysis={handleRunPolygonAnalysis} - onRunCircleAnalysis={handleRunCircleAnalysis} - onCancelAnalysis={handleCancelAnalysis} - onClearAnalysis={handleClearAnalysis} - /> + boomBlockedVolume={boomBlockedVolume} + displayControls={displayControls} + onDisplayControlsChange={setDisplayControls} + windHydrModel={windHydrModel} + windHydrModelOptions={windHydrModelOptions} + onWindHydrModelChange={setWindHydrModel} + analysisTab={analysisTab} + onSwitchAnalysisTab={setAnalysisTab} + drawAnalysisMode={drawAnalysisMode} + analysisPolygonPoints={analysisPolygonPoints} + circleRadiusNm={circleRadiusNm} + onCircleRadiusChange={setCircleRadiusNm} + analysisResult={analysisResult} + incidentCoord={incidentCoord} + centerPoints={centerPoints} + predictionTime={predictionTime} + onStartPolygonDraw={handleStartPolygonDraw} + onRunPolygonAnalysis={handleRunPolygonAnalysis} + onRunCircleAnalysis={handleRunCircleAnalysis} + onCancelAnalysis={handleCancelAnalysis} + onClearAnalysis={handleClearAnalysis} + /> +
)} {/* 확산 예측 실행 중 로딩 오버레이 */} diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index 483c862..0109d13 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -117,7 +117,7 @@ export function RightPanel({ }, [incidentCoord, centerPoints, summary, predictionTime]); return ( -
+
{/* Tab Header */}
diff --git a/frontend/src/tabs/scat/components/PreScatView.tsx b/frontend/src/tabs/scat/components/PreScatView.tsx index d7a0ec1..56808a8 100755 --- a/frontend/src/tabs/scat/components/PreScatView.tsx +++ b/frontend/src/tabs/scat/components/PreScatView.tsx @@ -16,6 +16,8 @@ import ScatRightPanel from './ScatRightPanel'; // ═══ Main PreScatView ═══ export function PreScatView() { + const [leftCollapsed, setLeftCollapsed] = useState(false); + const [rightCollapsed, setRightCollapsed] = useState(false); const [segments, setSegments] = useState([]); const [zones, setZones] = useState([]); const [jurisdictions, setJurisdictions] = useState([]); @@ -199,29 +201,70 @@ export function PreScatView() { return (
- + {/* Left Panel */} +
+ +
+ +
+ {/* Left panel toggle button */} + + + {/* Right panel toggle button */} + -
*/}
- + {/* Right Panel */} +
+ +
{popupData && ( diff --git a/frontend/src/tabs/scat/components/ScatLeftPanel.tsx b/frontend/src/tabs/scat/components/ScatLeftPanel.tsx index 7cdc25d..0e8b725 100644 --- a/frontend/src/tabs/scat/components/ScatLeftPanel.tsx +++ b/frontend/src/tabs/scat/components/ScatLeftPanel.tsx @@ -156,7 +156,7 @@ function ScatLeftPanel({ }, []); return ( -
+
{/* Filters */}
@@ -269,7 +269,11 @@ function ScatLeftPanel({ rowCount={filtered.length} rowHeight={88} overscanCount={5} - style={{ height: listHeight }} + style={{ + height: listHeight, + scrollbarWidth: 'thin', + scrollbarColor: 'var(--stroke-default) transparent', + }} rowComponent={SegRow} rowProps={{ filtered, diff --git a/frontend/src/tabs/scat/components/ScatMap.tsx b/frontend/src/tabs/scat/components/ScatMap.tsx index e96525a..9fb5227 100644 --- a/frontend/src/tabs/scat/components/ScatMap.tsx +++ b/frontend/src/tabs/scat/components/ScatMap.tsx @@ -310,7 +310,7 @@ function ScatMap({
{/* Right info cards */} -
+
{/* ESI Legend */}
diff --git a/frontend/src/tabs/scat/components/ScatRightPanel.tsx b/frontend/src/tabs/scat/components/ScatRightPanel.tsx index 331b1c9..ee8b25b 100644 --- a/frontend/src/tabs/scat/components/ScatRightPanel.tsx +++ b/frontend/src/tabs/scat/components/ScatRightPanel.tsx @@ -23,7 +23,7 @@ export default function ScatRightPanel({ if (!detail && !loading) { return ( -
+
🏖️
좌측 목록에서 구간을 @@ -37,7 +37,7 @@ export default function ScatRightPanel({ } return ( -
+
{/* 헤더 */}
{detail ? ( diff --git a/frontend/src/tabs/weather/components/WeatherMapControls.tsx b/frontend/src/tabs/weather/components/WeatherMapControls.tsx index 9d6714f..fac20c1 100644 --- a/frontend/src/tabs/weather/components/WeatherMapControls.tsx +++ b/frontend/src/tabs/weather/components/WeatherMapControls.tsx @@ -33,7 +33,7 @@ export function WeatherMapControls({ center, zoom }: WeatherMapControlsProps) {
diff --git a/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx b/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx index 28175dd..5d9b442 100755 --- a/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx +++ b/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx @@ -33,26 +33,26 @@ interface WeatherMapOverlayProps { selectedStationId: string | null; } -// 풍속에 따른 hex 색상 반환 +// 풍속에 따른 색상 반환 function getWindHexColor(speed: number, isSelected: boolean): string { - if (isSelected) return '#06b6d4'; - if (speed > 10) return '#ef4444'; - if (speed > 7) return '#f59e0b'; - return '#3b82f6'; + if (isSelected) return 'var(--color-accent)'; + if (speed > 10) return 'var(--color-danger)'; + if (speed > 7) return 'var(--color-caution)'; + return 'var(--color-info)'; } -// 파고에 따른 hex 색상 반환 +// 파고에 따른 색상 반환 function getWaveHexColor(height: number): string { - if (height > 2.5) return '#ef4444'; - if (height > 1.5) return '#f59e0b'; - return '#3b82f6'; + if (height > 2.5) return 'var(--color-danger)'; + if (height > 1.5) return 'var(--color-caution)'; + return 'var(--color-info)'; } -// 수온에 따른 hex 색상 반환 +// 수온에 따른 색상 반환 function getTempHexColor(temp: number): string { - if (temp > 8) return '#ef4444'; - if (temp > 6) return '#f59e0b'; - return '#3b82f6'; + if (temp > 8) return 'var(--color-danger)'; + if (temp > 6) return 'var(--color-caution)'; + return 'var(--color-info)'; } /** @@ -91,15 +91,17 @@ export function WeatherMapOverlay({ width={24} height={24} viewBox="0 0 24 24" - style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }} + style={{ filter: 'drop-shadow(0 2px 6px rgba(0,0,0,0.5))' }} > - {/* 위쪽이 바람 방향을 나타내는 삼각형 */} - + {/* 흰 외곽선 레이어 */} + + {/* 색상 레이어 */} +
{station.wind.speed.toFixed(1)} @@ -205,7 +207,6 @@ export function useWeatherDeckLayers( onStationClick: (station: WeatherStation) => void, ): Layer[] { return useMemo(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: Layer[] = []; // 파고 분포 ScatterplotLayer (Circle 대체, 반경 = 파고 * 15km) diff --git a/frontend/src/tabs/weather/components/WeatherRightPanel.tsx b/frontend/src/tabs/weather/components/WeatherRightPanel.tsx index d7950b2..7d73638 100755 --- a/frontend/src/tabs/weather/components/WeatherRightPanel.tsx +++ b/frontend/src/tabs/weather/components/WeatherRightPanel.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + interface WeatherData { stationName: string; location: { lat: number; lon: number }; @@ -46,20 +48,14 @@ interface WeatherRightPanelProps { weatherData: WeatherData | null; } -/** 풍속 등급 색상 */ -function windColor(speed: number): string { - if (speed >= 14) return '#ef4444'; - if (speed >= 10) return '#f97316'; - if (speed >= 6) return '#eab308'; - return '#22c55e'; +/** 풍속 텍스트 색상 (2단계 — danger | accent) */ +function windTextColor(speed: number): string { + return speed >= 10 ? 'var(--color-danger)' : 'var(--color-accent)'; } -/** 파고 등급 색상 */ -function waveColor(height: number): string { - if (height >= 3) return '#ef4444'; - if (height >= 2) return '#f97316'; - if (height >= 1) return '#eab308'; - return '#22c55e'; +/** 파고 텍스트 색상 (2단계 — danger | accent) */ +function waveTextColor(height: number): string { + return height >= 2 ? 'var(--color-danger)' : 'var(--color-accent)'; } /** 풍향 텍스트 */ @@ -86,13 +82,38 @@ function windDirText(deg: number): string { } export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { + const [collapsed, setCollapsed] = useState(false); + + if (collapsed) { + return ( +
+ +
+ ); + } + if (!weatherData) { return (
-
+

지도에서 해양 지점을 클릭하세요

+
); @@ -109,18 +130,30 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
{/* 헤더 */}
-
- - 📍 {weatherData.stationName} - - - 기상예보관 - +
+
+
+ + 📍 {weatherData.stationName} + + + 기상예보관 + +
+

+ {weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E ·{' '} + {weatherData.currentTime} +

+
+
-

- {weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E ·{' '} - {weatherData.currentTime} -

{/* 스크롤 콘텐츠 */} @@ -131,13 +164,13 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { {/* ── 핵심 지표 3칸 카드 ── */}
-
+
{wSpd.toFixed(1)}
풍속 (m/s)
-
+
{wHgt.toFixed(1)}
파고 (m)
@@ -152,9 +185,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { {/* ── 바람 상세 ── */}
-
- 🌬️ 바람 현황 -
+
🌬️ 바람 현황
{/* 풍향 컴파스 */}
@@ -202,11 +233,11 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { y1="25" x2={25 + 14 * Math.sin((wind.direction * Math.PI) / 180)} y2={25 - 14 * Math.cos((wind.direction * Math.PI) / 180)} - stroke={windColor(wSpd)} + stroke="var(--color-accent)" strokeWidth="2" strokeLinecap="round" /> - +
@@ -222,19 +253,13 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
1k 최고 - + {Number(wind.speed_1k).toFixed(1)}
3k 평균 - + {Number(wind.speed_3k).toFixed(1)}
@@ -248,11 +273,8 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
@@ -263,24 +285,20 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { {/* ── 파도 상세 ── */}
-
🌊 파도
+
🌊 파도
-
- {wHgt.toFixed(1)}m -
+
{wHgt.toFixed(1)}m
유의파고
-
+
{wave.maxHeight.toFixed(1)}m
최고파고
-
- {wave.period}s -
+
{wave.period}s
주기
@@ -295,7 +313,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { className="h-full rounded-full transition-all" style={{ width: `${Math.min((wHgt / 5) * 100, 100)}%`, - background: waveColor(wHgt), + background: 'var(--color-accent)', }} />
@@ -307,14 +325,10 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { {/* ── 수온/공기 ── */}
-
- 🌡️ 수온 · 공기 -
+
🌡️ 수온 · 공기
-
- {wTemp.toFixed(1)}° -
+
{wTemp.toFixed(1)}°
수온
@@ -332,9 +346,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { {/* ── 시간별 예보 ── */}
-
- ⏰ 시간별 예보 -
+
⏰ 시간별 예보
{forecast.map((f, i) => (
-
- ☀️ 천문 · 조석 -
+
☀️ 천문 · 조석
{[ { icon: '🌅', label: '일출', value: astronomy.sunrise }, @@ -381,17 +391,21 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) { {/* ── 날씨 특보 ── */} {alert && (
-
- 🚨 날씨 특보 -
+
🚨 날씨 특보
주의 diff --git a/frontend/src/tabs/weather/components/WeatherView.tsx b/frontend/src/tabs/weather/components/WeatherView.tsx index 834b510..058a7e4 100755 --- a/frontend/src/tabs/weather/components/WeatherView.tsx +++ b/frontend/src/tabs/weather/components/WeatherView.tsx @@ -90,7 +90,6 @@ const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat] const WEATHER_MAP_ZOOM = 7; // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록) -// eslint-disable-next-line @typescript-eslint/no-explicit-any function DeckGLOverlay({ layers }: { layers: Layer[] }) { const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); overlay.setProps({ layers }); @@ -178,7 +177,7 @@ function WeatherMapInner({ {/* 핀 꼬리 */}
{/* 좌표 라벨 */} -
+
{clickedLocation.lat.toFixed(3)}°N {clickedLocation.lon.toFixed(3)}°E
@@ -295,7 +294,7 @@ export function WeatherView() { {/* Main Map Area */}
{/* Tab Navigation */} -
+
{(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => (