release: 2026-04-14 (267건 커밋) #171
@ -4,6 +4,8 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-04-09]
|
||||
|
||||
### 추가
|
||||
- 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast)
|
||||
- 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather)
|
||||
|
||||
@ -1458,19 +1458,19 @@ function MapControls({ center, zoom }: { center: [number, number]; zoom: number
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => map?.zoomIn()}
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-xs"
|
||||
className="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-xs"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => map?.zoomOut()}
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-xs"
|
||||
className="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-xs"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:text-fg transition-all text-caption"
|
||||
className="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:text-fg transition-all text-caption"
|
||||
>
|
||||
🎯
|
||||
</button>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -219,7 +219,7 @@ export function HNSLeftPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 min-w-[320px] flex flex-col h-full bg-bg-surface border-r border-stroke overflow-hidden">
|
||||
<div className="w-full min-w-0 flex flex-col h-full bg-bg-surface border-r border-stroke overflow-hidden">
|
||||
{/* Scrollable Content */}
|
||||
<div
|
||||
className="flex-1 overflow-y-scroll scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent bg-bg-base"
|
||||
|
||||
@ -35,8 +35,8 @@ export function HNSRightPanel({
|
||||
}: HNSRightPanelProps) {
|
||||
if (!dispersionResult) {
|
||||
return (
|
||||
<div className="w-[300px] bg-bg-surface border-l border-stroke p-4 overflow-auto">
|
||||
<div className="flex flex-col gap-3 items-center justify-center h-full text-fg-disabled text-label-1">
|
||||
<div className="w-full h-full bg-bg-surface border-l border-stroke p-4 overflow-auto flex items-center justify-center">
|
||||
<div className="flex flex-col gap-3 items-center text-fg-disabled text-label-1">
|
||||
<div style={{ fontSize: '32px', opacity: 0.3 }}>📊</div>
|
||||
<div>예측 실행 후 결과가 표시됩니다</div>
|
||||
</div>
|
||||
@ -58,7 +58,7 @@ export function HNSRightPanel({
|
||||
: 'ALOHA';
|
||||
|
||||
return (
|
||||
<div className="w-[300px] bg-bg-surface border-l border-stroke p-4 overflow-auto flex flex-col gap-4">
|
||||
<div className="w-full bg-bg-surface border-l border-stroke p-4 overflow-auto flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
|
||||
@ -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() {
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Panel - 분석 목록일 때는 숨김 */}
|
||||
{activeSubTab === 'analysis' && (
|
||||
<HNSLeftPanel
|
||||
activeSubTab={activeSubTab}
|
||||
onSubTabChange={setActiveSubTab}
|
||||
incidentCoord={incidentCoord}
|
||||
onCoordChange={setIncidentCoord}
|
||||
onMapSelectClick={() => setIsSelectingLocation(true)}
|
||||
onRunPrediction={handleRunPrediction}
|
||||
isRunningPrediction={isRunningPrediction}
|
||||
onParamsChange={handleParamsChange}
|
||||
onReset={handleReset}
|
||||
loadedParams={loadedParams}
|
||||
/>
|
||||
<div className="shrink-0 overflow-hidden" style={{ width: leftCollapsed ? 0 : 320 }}>
|
||||
<HNSLeftPanel
|
||||
activeSubTab={activeSubTab}
|
||||
onSubTabChange={setActiveSubTab}
|
||||
incidentCoord={incidentCoord}
|
||||
onCoordChange={setIncidentCoord}
|
||||
onMapSelectClick={() => setIsSelectingLocation(true)}
|
||||
onRunPrediction={handleRunPrediction}
|
||||
isRunningPrediction={isRunningPrediction}
|
||||
onParamsChange={handleParamsChange}
|
||||
onReset={handleReset}
|
||||
loadedParams={loadedParams}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Center - Map/Content Area */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
{/* Left panel toggle button */}
|
||||
{activeSubTab === 'analysis' && (
|
||||
<button
|
||||
onClick={() => setLeftCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
left: 0,
|
||||
width: 18,
|
||||
height: 40,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderLeft: 'none',
|
||||
borderRadius: '0 6px 6px 0',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{leftCollapsed ? '▶' : '◀'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Right panel toggle button */}
|
||||
{activeSubTab === 'analysis' && (
|
||||
<button
|
||||
onClick={() => setRightCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
right: 0,
|
||||
width: 18,
|
||||
height: 40,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRight: 'none',
|
||||
borderRadius: '6px 0 0 6px',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{rightCollapsed ? '◀' : '▶'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{activeSubTab === 'list' ? (
|
||||
<HNSAnalysisListTable
|
||||
onTabChange={(v) =>
|
||||
@ -942,14 +988,16 @@ export function HNSView() {
|
||||
|
||||
{/* Right Panel - 분석 목록일 때는 숨김 */}
|
||||
{activeSubTab === 'analysis' && (
|
||||
<HNSRightPanel
|
||||
dispersionResult={dispersionResult}
|
||||
computedResult={computedResult}
|
||||
weatherData={inputParams?.weather ?? null}
|
||||
onOpenRecalc={() => setRecalcModalOpen(true)}
|
||||
onOpenReport={handleOpenReport}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
<div className="shrink-0 overflow-hidden" style={{ width: rightCollapsed ? 0 : 300 }}>
|
||||
<HNSRightPanel
|
||||
dispersionResult={dispersionResult}
|
||||
computedResult={computedResult}
|
||||
weatherData={inputParams?.weather ?? null}
|
||||
onOpenRecalc={() => setRecalcModalOpen(true)}
|
||||
onOpenReport={handleOpenReport}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HNS 재계산 모달 */}
|
||||
|
||||
@ -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 (
|
||||
<span
|
||||
className="text-caption font-bold px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' }}
|
||||
className="text-caption px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
|
||||
color: 'var(--color-danger)',
|
||||
}}
|
||||
>
|
||||
배출불가
|
||||
</span>
|
||||
);
|
||||
if (status === 'allowed')
|
||||
return (
|
||||
<span
|
||||
className="text-caption font-bold px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' }}
|
||||
>
|
||||
배출가능
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className="text-caption font-bold px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)' }}
|
||||
>
|
||||
조건부
|
||||
<span className="text-caption px-1.5 py-0.5 rounded text-fg-sub">
|
||||
{status === 'allowed' ? '배출가능' : '조건부'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<DndContext onDragEnd={handleDragEnd}>
|
||||
<DraggablePanel {...props} offset={offset} />
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggablePanel({
|
||||
lat,
|
||||
lon,
|
||||
distanceNm,
|
||||
zoneIndex,
|
||||
onClose,
|
||||
}: DischargeZonePanelProps) {
|
||||
offset,
|
||||
}: DischargeZonePanelProps & { offset: { x: number; y: number } }) {
|
||||
const zoneIdx = zoneIndex;
|
||||
const [expandedCat, setExpandedCat] = useState<string | null>(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 */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="shrink-0 flex items-center justify-between"
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--stroke-default)',
|
||||
background: 'var(--bg-elevated)',
|
||||
cursor: 'grab',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="text-label-2 font-bold text-fg font-korean">🚢 오염물 배출 규정</div>
|
||||
<div className="text-label-2 text-fg font-korean">🚢 오염물 배출 규정</div>
|
||||
<div className="text-caption text-fg-sub font-korean">해양환경관리법 제22조</div>
|
||||
</div>
|
||||
<span onClick={onClose} className="text-title-3 cursor-pointer text-fg-sub hover:text-fg">
|
||||
<span
|
||||
onClick={onClose}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="text-title-3 cursor-pointer text-fg-sub hover:text-fg"
|
||||
style={{ pointerEvents: 'all' }}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
@ -194,10 +226,7 @@ export function DischargeZonePanel({
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-caption text-fg-sub font-korean">영해기선 거리</span>
|
||||
<span
|
||||
className="text-label-2 font-bold font-mono"
|
||||
style={{ color: ZONE_COLORS[zoneIdx] }}
|
||||
>
|
||||
<span className="text-label-2 font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
|
||||
{distanceNm.toFixed(1)} NM
|
||||
</span>
|
||||
</div>
|
||||
@ -206,12 +235,10 @@ export function DischargeZonePanel({
|
||||
{ZONE_LABELS.map((label, i) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex-1 text-center rounded-sm"
|
||||
className="flex-1 text-center rounded-sm text-[10px]"
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
fontSize: 8,
|
||||
fontWeight: i === zoneIdx ? 700 : 400,
|
||||
color: i === zoneIdx ? 'var(--fg-default)' : 'var(--fg-sub)',
|
||||
padding: '2px 0',
|
||||
color: i === zoneIdx ? '#000' : 'var(--fg-sub)',
|
||||
background: i === zoneIdx ? ZONE_COLORS[i] : 'var(--hover-overlay)',
|
||||
border: i === zoneIdx ? 'none' : '1px solid var(--stroke-light)',
|
||||
}}
|
||||
@ -231,8 +258,7 @@ export function DischargeZonePanel({
|
||||
const catRules = RULES.filter((r) => 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 (
|
||||
<div key={cat} style={{ borderBottom: '1px solid var(--stroke-light)' }}>
|
||||
@ -245,11 +271,11 @@ export function DischargeZonePanel({
|
||||
<div
|
||||
style={{ width: 6, height: 6, borderRadius: '50%', background: summaryColor }}
|
||||
/>
|
||||
<span className="text-caption font-bold text-fg font-korean">{cat}</span>
|
||||
<span className="text-caption text-fg font-korean">{cat}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-caption font-semibold" style={{ color: summaryColor }}>
|
||||
{allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'}
|
||||
<span className="text-caption" style={{ color: summaryColor }}>
|
||||
{allForbidden ? '전체 불가' : '허용'}
|
||||
</span>
|
||||
<span className="text-caption text-fg-sub">{isExpanded ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
@ -268,7 +294,7 @@ export function DischargeZonePanel({
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<span className="text-caption text-fg font-korean">{rule.item}</span>
|
||||
<span className="text-caption text-fg-sub font-korean">{rule.item}</span>
|
||||
<StatusBadge status={rule.zones[zoneIdx]} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -250,11 +250,11 @@ export function IncidentsLeftPanel({
|
||||
/>
|
||||
<button
|
||||
onClick={resetPage}
|
||||
className="text-label-2 font-semibold cursor-pointer whitespace-nowrap text-white border-none"
|
||||
className="rounded-sm text-label-2 font-semibold cursor-pointer whitespace-nowrap text-color-accent"
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
background: 'linear-gradient(135deg,var(--color-accent),var(--color-info))',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid rgba(6,182,212,.3)',
|
||||
background: 'rgba(6,182,212,.08)',
|
||||
}}
|
||||
>
|
||||
조회
|
||||
@ -349,7 +349,7 @@ export function IncidentsLeftPanel({
|
||||
setSelectedStatus(s.id);
|
||||
resetPage();
|
||||
}}
|
||||
className="flex items-center gap-1 text-caption font-semibold cursor-pointer"
|
||||
className="flex items-center gap-1 text-caption cursor-pointer"
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: '12px',
|
||||
@ -435,7 +435,7 @@ export function IncidentsLeftPanel({
|
||||
>
|
||||
{/* Row 1: name + status */}
|
||||
<div className="flex items-center justify-between mb-[5px]">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span
|
||||
className="shrink-0"
|
||||
style={{
|
||||
@ -449,7 +449,7 @@ export function IncidentsLeftPanel({
|
||||
{inc.name}
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 text-caption font-semibold"
|
||||
className="shrink-0 text-caption"
|
||||
style={{
|
||||
padding: '2px 10px',
|
||||
borderRadius: '10px',
|
||||
@ -463,9 +463,9 @@ export function IncidentsLeftPanel({
|
||||
{/* Row 2: meta */}
|
||||
<div className="flex items-center gap-2 text-caption text-fg-disabled mb-[5px]">
|
||||
<span>
|
||||
📅 {inc.date} {inc.time}
|
||||
{inc.date} {inc.time}
|
||||
</span>
|
||||
<span>🏛 {inc.office}</span>
|
||||
<span> {inc.office}</span>
|
||||
</div>
|
||||
{/* Row 3: tags + buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
@ -485,12 +485,12 @@ export function IncidentsLeftPanel({
|
||||
)}
|
||||
{inc.oilType && (
|
||||
<span
|
||||
className="text-caption font-medium text-color-warning"
|
||||
className="text-caption font-medium text-fg-sub"
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '3px',
|
||||
background: 'rgba(249,115,22,0.08)',
|
||||
border: '1px solid rgba(249,115,22,0.2)',
|
||||
background: 'rgba(100,116,139,0.08)',
|
||||
border: '1px solid rgba(100,116,139,0.2)',
|
||||
}}
|
||||
>
|
||||
{inc.oilType}
|
||||
@ -498,12 +498,12 @@ export function IncidentsLeftPanel({
|
||||
)}
|
||||
{inc.prediction && (
|
||||
<span
|
||||
className="text-caption font-medium text-color-success"
|
||||
className="text-caption font-medium text-fg-sub"
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '3px',
|
||||
background: 'rgba(34,197,94,0.08)',
|
||||
border: '1px solid rgba(34,197,94,0.2)',
|
||||
background: 'rgba(100,116,139,0.08)',
|
||||
border: '1px solid rgba(100,116,139,0.2)',
|
||||
}}
|
||||
>
|
||||
{inc.prediction}
|
||||
@ -528,7 +528,7 @@ export function IncidentsLeftPanel({
|
||||
transition: '0.15s',
|
||||
}}
|
||||
>
|
||||
🌤
|
||||
기상정보
|
||||
</button>
|
||||
{(inc.mediaCount ?? 0) > 0 && (
|
||||
<button
|
||||
@ -548,7 +548,7 @@ export function IncidentsLeftPanel({
|
||||
transition: '0.15s',
|
||||
}}
|
||||
>
|
||||
📹 <span className="text-caption">{inc.mediaCount}</span>
|
||||
<span className="text-caption">{inc.mediaCount}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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<string, string> = {
|
||||
어장정보: '🐟',
|
||||
양식장: '🦪',
|
||||
@ -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 (
|
||||
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-[280px] min-w-[280px]">
|
||||
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-full h-full">
|
||||
<div className="text-center text-fg-disabled text-label-2">
|
||||
<div className="text-[32px] mb-2 opacity-30">📊</div>
|
||||
좌측에서 사고를 선택하면
|
||||
@ -327,7 +316,7 @@ export function IncidentsRightPanel({
|
||||
<div className="px-[14px] py-2.5 border-b border-stroke shrink-0">
|
||||
<div className="text-xs font-bold mb-0.5">🔬 통합분석 조회</div>
|
||||
<div className="text-caption text-fg-disabled">
|
||||
선택: <b className="text-color-accent">{incident.name}</b>
|
||||
선택: <span className="text-fg-disabled">{incident.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -344,22 +333,19 @@ export function IncidentsRightPanel({
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">{sec.icon}</span>
|
||||
<span className="text-xs font-bold" style={{ color: sec.color }}>
|
||||
{sec.title}
|
||||
</span>
|
||||
{/* <span className="text-sm">{sec.icon}</span> */}
|
||||
<span className="text-xs">{sec.title}</span>
|
||||
</div>
|
||||
<button
|
||||
className="text-caption font-semibold cursor-pointer"
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: '4px',
|
||||
background: `rgba(${sec.colorRgb},0.1)`,
|
||||
border: `1px solid rgba(${sec.colorRgb},0.25)`,
|
||||
border: '1px solid var(--stroke-default)',
|
||||
color: sec.color,
|
||||
}}
|
||||
>
|
||||
📋 조회
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@ -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({
|
||||
<div key={sec.key} className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">{sec.icon}</span>
|
||||
<span className="text-xs font-bold" style={{ color: sec.color }}>
|
||||
{sec.title}
|
||||
</span>
|
||||
{/* <span className="text-sm">{sec.icon}</span> */}
|
||||
<span className="text-xs">{sec.title}</span>
|
||||
</div>
|
||||
<button
|
||||
className="text-caption font-semibold cursor-pointer"
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: '4px',
|
||||
background: `rgba(${sec.colorRgb},0.1)`,
|
||||
border: `1px solid rgba(${sec.colorRgb},0.25)`,
|
||||
border: '1px solid var(--stroke-default)',
|
||||
color: sec.color,
|
||||
}}
|
||||
>
|
||||
📋 조회
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled text-center py-1.5">준비 중입니다</div>
|
||||
@ -443,8 +425,8 @@ export function IncidentsRightPanel({
|
||||
{/* 민감자원 */}
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-sm">🐟</span>
|
||||
<span className="text-xs font-bold text-color-success">민감자원</span>
|
||||
{/* <span className="text-sm">🐟</span> */}
|
||||
<span className="text-xs">민감자원</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[3px]">
|
||||
{sensCategories.length === 0 ? (
|
||||
@ -452,38 +434,33 @@ export function IncidentsRightPanel({
|
||||
해당 사고 영역의 민감자원이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
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 (
|
||||
<label
|
||||
key={cat.category}
|
||||
className="flex items-center cursor-pointer text-caption gap-[5px] rounded-[3px]"
|
||||
style={{ padding: '4px 6px', background: `rgba(${r},${g},${b},0.06)` }}
|
||||
className="flex items-center cursor-pointer text-caption gap-[5px]"
|
||||
style={{ padding: '3px 0' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedSensCategories.has(cat.category)}
|
||||
onChange={() => toggleSensCategory(cat.category)}
|
||||
style={{ accentColor: hex }}
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: hex,
|
||||
background: 'var(--color-accent)',
|
||||
flexShrink: 0,
|
||||
display: 'inline-block',
|
||||
border: `1px solid rgba(${r},${g},${b},0.45)`,
|
||||
}}
|
||||
/>
|
||||
<span>{icon}</span>
|
||||
<span className="flex-1">{cat.category}</span>
|
||||
<span className="text-fg-disabled font-mono shrink-0">({areaLabel})</span>
|
||||
</label>
|
||||
@ -496,10 +473,9 @@ export function IncidentsRightPanel({
|
||||
{/* 근처 방제자원 */}
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-sm">🛡</span>
|
||||
<span className="text-xs font-bold text-color-boom">근처 방제자원</span>
|
||||
<span className="text-xs font-bold text-color-accent">근처 방제자원</span>
|
||||
{nearbyOrgs.length > 0 && (
|
||||
<span className="ml-auto text-caption font-mono text-color-boom">
|
||||
<span className="ml-auto text-caption font-mono text-color-accent">
|
||||
{nearbyOrgs.length}개
|
||||
</span>
|
||||
)}
|
||||
@ -519,21 +495,18 @@ export function IncidentsRightPanel({
|
||||
반경 내 방제자원 없음
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-[3px] max-h-[200px] overflow-y-auto">
|
||||
<div className="flex flex-col max-h-[200px] overflow-y-auto">
|
||||
{nearbyOrgs.map((org) => (
|
||||
<div
|
||||
key={org.orgSn}
|
||||
className="flex items-start gap-1.5 rounded-[3px] px-[6px] py-[5px]"
|
||||
style={{
|
||||
background: 'rgba(245,158,11,0.05)',
|
||||
border: '1px solid rgba(245,158,11,0.08)',
|
||||
}}
|
||||
className="flex items-start gap-1.5 px-[2px] py-[5px]"
|
||||
style={{ borderBottom: '1px solid var(--stroke-default)' }}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1 mb-[2px]">
|
||||
<span
|
||||
className="text-caption px-[4px] py-[1px] rounded-[2px] font-bold shrink-0"
|
||||
style={{ background: 'rgba(245,158,11,0.15)', color: '#f59e0b' }}
|
||||
className="text-caption px-[4px] py-[1px] rounded-[2px] font-bold shrink-0 text-color-accent"
|
||||
style={{ background: 'rgba(6,182,212,0.1)' }}
|
||||
>
|
||||
{org.orgTp}
|
||||
</span>
|
||||
@ -544,7 +517,7 @@ export function IncidentsRightPanel({
|
||||
{org.totalAssets > 0 ? ` · 장비 ${org.totalAssets}개` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-caption font-mono text-color-boom shrink-0">
|
||||
<span className="text-caption font-mono text-color-accent shrink-0">
|
||||
{org.distanceNm.toFixed(1)} nm
|
||||
</span>
|
||||
</div>
|
||||
@ -553,10 +526,10 @@ export function IncidentsRightPanel({
|
||||
)}
|
||||
|
||||
{/* Radius slider */}
|
||||
<div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(245,158,11,0.1)' }}>
|
||||
<div className="mt-2 pt-2" style={{ borderTop: '1px solid var(--stroke-default)' }}>
|
||||
<div className="flex items-center justify-between mb-[5px]">
|
||||
<span className="text-caption text-fg-disabled">탐색 반경</span>
|
||||
<span className="text-caption font-bold font-mono text-color-boom">
|
||||
<span className="text-caption font-bold font-mono text-color-accent">
|
||||
{nearbyRadius} nm
|
||||
</span>
|
||||
</div>
|
||||
@ -572,7 +545,7 @@ export function IncidentsRightPanel({
|
||||
height: '4px',
|
||||
background: 'var(--stroke-default)',
|
||||
borderRadius: '2px',
|
||||
accentColor: '#f59e0b',
|
||||
accentColor: 'var(--color-accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -605,7 +578,8 @@ export function IncidentsRightPanel({
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
}}
|
||||
>
|
||||
{v.icon} {v.label}
|
||||
{/* {v.icon} */}
|
||||
{v.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@ -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)',
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -130,7 +130,7 @@ export function LeftPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 min-w-[320px] bg-bg-surface border-r border-stroke flex flex-col">
|
||||
<div className="w-full min-w-0 h-full bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
|
||||
{/* Scrollable Content */}
|
||||
<div
|
||||
className="flex-1 overflow-y-scroll scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent"
|
||||
|
||||
@ -166,6 +166,8 @@ export const ALL_MODELS: PredictionModel[] = ['KOSPS', 'POSEIDON', 'OpenDrift'];
|
||||
|
||||
export function OilSpillView() {
|
||||
const { activeSubTab, setActiveSubTab } = useSubMenu('prediction');
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set());
|
||||
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
|
||||
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
|
||||
@ -1129,88 +1131,132 @@ export function OilSpillView() {
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar */}
|
||||
{activeSubTab === 'analysis' && (
|
||||
<LeftPanel
|
||||
selectedAnalysis={selectedAnalysis}
|
||||
enabledLayers={enabledLayers}
|
||||
onToggleLayer={handleToggleLayer}
|
||||
accidentTime={accidentTime}
|
||||
onAccidentTimeChange={(v) => {
|
||||
setAccidentTime(v);
|
||||
setValidationErrors((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete('accidentTime');
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
incidentCoord={incidentCoord}
|
||||
onCoordChange={(v) => {
|
||||
setIncidentCoord(v);
|
||||
setValidationErrors((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete('coord');
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
isSelectingLocation={isSelectingLocation}
|
||||
onMapSelectClick={() => setIsSelectingLocation((prev) => !prev)}
|
||||
onRunSimulation={handleRunSimulation}
|
||||
isRunningSimulation={isRunningSimulation}
|
||||
selectedModels={selectedModels}
|
||||
onModelsChange={(v) => {
|
||||
setSelectedModels(v);
|
||||
setValidationErrors((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete('models');
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
visibleModels={visibleModels}
|
||||
onVisibleModelsChange={setVisibleModels}
|
||||
hasResults={oilTrajectory.length > 0}
|
||||
predictionTime={predictionTime}
|
||||
onPredictionTimeChange={setPredictionTime}
|
||||
spillType={spillType}
|
||||
onSpillTypeChange={setSpillType}
|
||||
oilType={oilType}
|
||||
onOilTypeChange={setOilType}
|
||||
spillAmount={spillAmount}
|
||||
onSpillAmountChange={setSpillAmount}
|
||||
incidentName={incidentName}
|
||||
onIncidentNameChange={(v) => {
|
||||
setIncidentName(v);
|
||||
setValidationErrors((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete('incidentName');
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
spillUnit={spillUnit}
|
||||
onSpillUnitChange={setSpillUnit}
|
||||
boomLines={boomLines}
|
||||
onBoomLinesChange={setBoomLines}
|
||||
oilTrajectory={oilTrajectory}
|
||||
algorithmSettings={algorithmSettings}
|
||||
onAlgorithmSettingsChange={setAlgorithmSettings}
|
||||
isDrawingBoom={isDrawingBoom}
|
||||
onDrawingBoomChange={setIsDrawingBoom}
|
||||
drawingPoints={drawingPoints}
|
||||
onDrawingPointsChange={setDrawingPoints}
|
||||
containmentResult={containmentResult}
|
||||
onContainmentResultChange={setContainmentResult}
|
||||
layerOpacity={layerOpacity}
|
||||
onLayerOpacityChange={setLayerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
onLayerBrightnessChange={setLayerBrightness}
|
||||
layerColors={layerColors}
|
||||
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
||||
sensitiveResources={sensitiveResourceCategories}
|
||||
onImageAnalysisResult={handleImageAnalysisResult}
|
||||
validationErrors={validationErrors}
|
||||
/>
|
||||
<div className="shrink-0 overflow-hidden" style={{ width: leftCollapsed ? 0 : 320 }}>
|
||||
<LeftPanel
|
||||
selectedAnalysis={selectedAnalysis}
|
||||
enabledLayers={enabledLayers}
|
||||
onToggleLayer={handleToggleLayer}
|
||||
accidentTime={accidentTime}
|
||||
onAccidentTimeChange={(v) => {
|
||||
setAccidentTime(v);
|
||||
setValidationErrors((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete('accidentTime');
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
incidentCoord={incidentCoord}
|
||||
onCoordChange={(v) => {
|
||||
setIncidentCoord(v);
|
||||
setValidationErrors((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete('coord');
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
isSelectingLocation={isSelectingLocation}
|
||||
onMapSelectClick={() => setIsSelectingLocation((prev) => !prev)}
|
||||
onRunSimulation={handleRunSimulation}
|
||||
isRunningSimulation={isRunningSimulation}
|
||||
selectedModels={selectedModels}
|
||||
onModelsChange={(v) => {
|
||||
setSelectedModels(v);
|
||||
setValidationErrors((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete('models');
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
visibleModels={visibleModels}
|
||||
onVisibleModelsChange={setVisibleModels}
|
||||
hasResults={oilTrajectory.length > 0}
|
||||
predictionTime={predictionTime}
|
||||
onPredictionTimeChange={setPredictionTime}
|
||||
spillType={spillType}
|
||||
onSpillTypeChange={setSpillType}
|
||||
oilType={oilType}
|
||||
onOilTypeChange={setOilType}
|
||||
spillAmount={spillAmount}
|
||||
onSpillAmountChange={setSpillAmount}
|
||||
incidentName={incidentName}
|
||||
onIncidentNameChange={(v) => {
|
||||
setIncidentName(v);
|
||||
setValidationErrors((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete('incidentName');
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
spillUnit={spillUnit}
|
||||
onSpillUnitChange={setSpillUnit}
|
||||
boomLines={boomLines}
|
||||
onBoomLinesChange={setBoomLines}
|
||||
oilTrajectory={oilTrajectory}
|
||||
algorithmSettings={algorithmSettings}
|
||||
onAlgorithmSettingsChange={setAlgorithmSettings}
|
||||
isDrawingBoom={isDrawingBoom}
|
||||
onDrawingBoomChange={setIsDrawingBoom}
|
||||
drawingPoints={drawingPoints}
|
||||
onDrawingPointsChange={setDrawingPoints}
|
||||
containmentResult={containmentResult}
|
||||
onContainmentResultChange={setContainmentResult}
|
||||
layerOpacity={layerOpacity}
|
||||
onLayerOpacityChange={setLayerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
onLayerBrightnessChange={setLayerBrightness}
|
||||
layerColors={layerColors}
|
||||
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
||||
sensitiveResources={sensitiveResourceCategories}
|
||||
onImageAnalysisResult={handleImageAnalysisResult}
|
||||
validationErrors={validationErrors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Center - Map/Content Area */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
{/* Left panel toggle button */}
|
||||
{activeSubTab === 'analysis' && (
|
||||
<button
|
||||
onClick={() => setLeftCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
left: 0,
|
||||
width: 18,
|
||||
height: 40,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderLeft: 'none',
|
||||
borderRadius: '0 6px 6px 0',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{leftCollapsed ? '▶' : '◀'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Right panel toggle button */}
|
||||
{activeSubTab === 'analysis' && (
|
||||
<button
|
||||
onClick={() => setRightCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
right: 0,
|
||||
width: 18,
|
||||
height: 40,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRight: 'none',
|
||||
borderRadius: '6px 0 0 6px',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{rightCollapsed ? '◀' : '▶'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{activeSubTab === 'list' ? (
|
||||
<AnalysisListTable
|
||||
onTabChange={setActiveSubTab}
|
||||
@ -1655,44 +1701,46 @@ export function OilSpillView() {
|
||||
|
||||
{/* Right Panel */}
|
||||
{activeSubTab === 'analysis' && (
|
||||
<RightPanel
|
||||
onOpenBacktrack={handleOpenBacktrack}
|
||||
onOpenRecalc={() => {
|
||||
if (!selectedAnalysis) {
|
||||
alert('선택된 사고가 없습니다.\n분석 목록에서 사고를 선택해주세요.');
|
||||
return;
|
||||
<div className="shrink-0 overflow-hidden" style={{ width: rightCollapsed ? 0 : 300 }}>
|
||||
<RightPanel
|
||||
onOpenBacktrack={handleOpenBacktrack}
|
||||
onOpenRecalc={() => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 확산 예측 실행 중 로딩 오버레이 */}
|
||||
|
||||
@ -117,7 +117,7 @@ export function RightPanel({
|
||||
}, [incidentCoord, centerPoints, summary, predictionTime]);
|
||||
|
||||
return (
|
||||
<div className="w-[300px] min-w-[300px] bg-bg-surface border-l border-stroke flex flex-col">
|
||||
<div className="w-full min-w-0 h-full bg-bg-surface border-l border-stroke flex flex-col overflow-hidden">
|
||||
{/* Tab Header */}
|
||||
<div className="flex border-b border-stroke">
|
||||
<button className="flex-1 py-3 text-center text-label-1 font-medium text-color-accent border-b-2 border-color-accent transition-all font-korean">
|
||||
@ -572,7 +572,7 @@ export function RightPanel({
|
||||
|
||||
{/* Bottom Action Buttons */}
|
||||
<div className="flex gap-1.5 p-3 border-t border-stroke">
|
||||
<button className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-color-accent text-color-accent font-korean hover:bg-[rgba(6,182,212,0.08)] transition-colors">
|
||||
<button className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-stroke font-korean hover:bg-[rgba(6,182,212,0.08)] transition-colors">
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
@ -589,7 +589,7 @@ export function RightPanel({
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenBacktrack}
|
||||
className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-[var(--color-tertiary)] text-[var(--color-tertiary)] font-korean hover:bg-[rgba(168,85,247,0.08)] transition-colors"
|
||||
className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-stroke font-korean hover:bg-[rgba(168,85,247,0.08)] transition-colors"
|
||||
>
|
||||
역추적
|
||||
</button>
|
||||
|
||||
@ -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<ScatSegment[]>([]);
|
||||
const [zones, setZones] = useState<ApiZoneItem[]>([]);
|
||||
const [jurisdictions, setJurisdictions] = useState<string[]>([]);
|
||||
@ -199,29 +201,70 @@ export function PreScatView() {
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-full bg-bg-base overflow-hidden">
|
||||
<ScatLeftPanel
|
||||
segments={segments}
|
||||
zones={zones}
|
||||
jurisdictions={jurisdictions}
|
||||
offices={offices}
|
||||
selectedOffice={selectedOffice}
|
||||
onOfficeChange={setSelectedOffice}
|
||||
selectedSeg={selectedSeg}
|
||||
onSelectSeg={setSelectedSeg}
|
||||
onOpenPopup={handleOpenPopup}
|
||||
jurisdictionFilter={jurisdictionFilter}
|
||||
onJurisdictionChange={setJurisdictionFilter}
|
||||
areaFilter={areaFilter}
|
||||
onAreaChange={setAreaFilter}
|
||||
phaseFilter={phaseFilter}
|
||||
onPhaseChange={setPhaseFilter}
|
||||
statusFilter={statusFilter}
|
||||
onStatusChange={setStatusFilter}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
{/* Left Panel */}
|
||||
<div className="shrink-0 overflow-hidden" style={{ width: leftCollapsed ? 0 : 340 }}>
|
||||
<ScatLeftPanel
|
||||
segments={segments}
|
||||
zones={zones}
|
||||
jurisdictions={jurisdictions}
|
||||
offices={offices}
|
||||
selectedOffice={selectedOffice}
|
||||
onOfficeChange={setSelectedOffice}
|
||||
selectedSeg={selectedSeg}
|
||||
onSelectSeg={setSelectedSeg}
|
||||
onOpenPopup={handleOpenPopup}
|
||||
jurisdictionFilter={jurisdictionFilter}
|
||||
onJurisdictionChange={setJurisdictionFilter}
|
||||
areaFilter={areaFilter}
|
||||
onAreaChange={setAreaFilter}
|
||||
phaseFilter={phaseFilter}
|
||||
onPhaseChange={setPhaseFilter}
|
||||
statusFilter={statusFilter}
|
||||
onStatusChange={setStatusFilter}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
{/* Left panel toggle button */}
|
||||
<button
|
||||
onClick={() => setLeftCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
left: 0,
|
||||
width: 18,
|
||||
height: 40,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderLeft: 'none',
|
||||
borderRadius: '0 6px 6px 0',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{leftCollapsed ? '▶' : '◀'}
|
||||
</button>
|
||||
|
||||
{/* Right panel toggle button */}
|
||||
<button
|
||||
onClick={() => setRightCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
right: 0,
|
||||
width: 18,
|
||||
height: 40,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
borderRight: 'none',
|
||||
borderRadius: '6px 0 0 6px',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{rightCollapsed ? '◀' : '▶'}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<ScatMap
|
||||
segments={segments}
|
||||
zones={zones}
|
||||
@ -237,7 +280,10 @@ export function PreScatView() {
|
||||
/> */}
|
||||
</div>
|
||||
|
||||
<ScatRightPanel detail={panelDetail} loading={panelLoading} />
|
||||
{/* Right Panel */}
|
||||
<div className="shrink-0 overflow-hidden" style={{ width: rightCollapsed ? 0 : 280 }}>
|
||||
<ScatRightPanel detail={panelDetail} loading={panelLoading} />
|
||||
</div>
|
||||
|
||||
{popupData && (
|
||||
<ScatPopup data={popupData} segCode={selectedSeg.code} onClose={handleClosePopup} />
|
||||
|
||||
@ -156,7 +156,7 @@ function ScatLeftPanel({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-[340px] min-w-[340px] bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
|
||||
<div className="w-full h-full min-w-0 bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
|
||||
{/* Filters */}
|
||||
<div className="p-3.5 border-b border-stroke">
|
||||
<div className="flex items-center gap-1.5 text-caption font-bold uppercase tracking-wider text-fg mb-3">
|
||||
@ -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,
|
||||
|
||||
@ -310,7 +310,7 @@ function ScatMap({
|
||||
</div>
|
||||
|
||||
{/* Right info cards */}
|
||||
<div className="absolute top-3.5 right-3.5 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
|
||||
<div className="absolute top-3.5 right-8 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
|
||||
{/* ESI Legend */}
|
||||
<div className="bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
||||
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
|
||||
|
||||
@ -23,7 +23,7 @@ export default function ScatRightPanel({
|
||||
|
||||
if (!detail && !loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-[280px] min-w-[280px] h-full">
|
||||
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-full h-full">
|
||||
<div className="text-3xl mb-2">🏖️</div>
|
||||
<div className="text-center text-fg-disabled text-label-2 leading-relaxed">
|
||||
좌측 목록에서 구간을
|
||||
@ -37,7 +37,7 @@ export default function ScatRightPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden h-full w-[280px] min-w-[280px]">
|
||||
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden h-full w-full">
|
||||
{/* 헤더 */}
|
||||
<div className="px-3.5 py-2.5 border-b border-stroke shrink-0">
|
||||
{detail ? (
|
||||
|
||||
@ -33,7 +33,7 @@ export function WeatherMapControls({ center, zoom }: WeatherMapControlsProps) {
|
||||
<div key={tooltip} className="relative group">
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-stroke rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-base"
|
||||
className="w-[38px] h-[38px] bg-bg-surface border border-stroke rounded-sm shadow-md text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-base"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
|
||||
@ -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))' }}
|
||||
>
|
||||
{/* 위쪽이 바람 방향을 나타내는 삼각형 */}
|
||||
<polygon points="12,2 4,22 12,16 20,22" fill={color} opacity="0.9" />
|
||||
{/* 흰 외곽선 레이어 */}
|
||||
<polygon points="12,2 4,22 12,16 20,22" fill="white" opacity="0.9" />
|
||||
{/* 색상 레이어 */}
|
||||
<polygon points="12,3 5,21 12,15.5 19,21" fill={color} opacity="0.95" />
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
style={{ color, textShadow: '0 1px 3px rgba(0,0,0,0.8)' }}
|
||||
className="text-xs font-bold leading-none"
|
||||
className="text-xs font-bold leading-none px-1 py-px rounded-sm bg-bg-base/80"
|
||||
style={{ color }}
|
||||
>
|
||||
{station.wind.speed.toFixed(1)}
|
||||
</span>
|
||||
@ -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)
|
||||
|
||||
@ -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 (
|
||||
<div className="flex flex-col bg-bg-surface border-l border-stroke w-8 shrink-0">
|
||||
<button
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="flex-1 flex flex-col items-center justify-start pt-3 gap-1 text-fg-sub hover:text-fg transition-colors"
|
||||
title="패널 펼치기"
|
||||
>
|
||||
<span className="text-heading-2 leading-none">‹</span>
|
||||
<span className="text-subtitle text-fg-disabled [writing-mode:vertical-rl]">펼치기</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!weatherData) {
|
||||
return (
|
||||
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden w-[320px] shrink-0">
|
||||
<div className="p-6 text-center">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke bg-bg-elevated">
|
||||
<p className="text-fg-disabled text-title-4 font-korean">
|
||||
지도에서 해양 지점을 클릭하세요
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="shrink-0 flex items-center gap-0.5 text-fg-sub hover:text-fg transition-colors ml-2"
|
||||
title="패널 접기"
|
||||
>
|
||||
<span className="text-subtitle text-fg-disabled">접기</span>
|
||||
<span className="text-heading-2 leading-none">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -109,18 +130,30 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden w-[320px] shrink-0">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-title-4 font-bold text-color-accent font-korean">
|
||||
📍 {weatherData.stationName}
|
||||
</span>
|
||||
<span className="px-1.5 py-px text-label-2 rounded bg-[rgba(6,182,212,0.15)] text-color-accent font-bold">
|
||||
기상예보관
|
||||
</span>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-title-4 font-bold text-color-accent font-korean truncate">
|
||||
📍 {weatherData.stationName}
|
||||
</span>
|
||||
<span className="px-1.5 py-px text-label-2 rounded bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent font-bold shrink-0">
|
||||
기상예보관
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label-2 text-fg-disabled font-mono">
|
||||
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E ·{' '}
|
||||
{weatherData.currentTime}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="shrink-0 flex items-center gap-0.5 text-fg-sub hover:text-fg transition-colors ml-2 mt-0.5"
|
||||
title="패널 접기"
|
||||
>
|
||||
<span className="text-subtitle text-fg-disabled">접기</span>
|
||||
<span className="text-heading-2 leading-none">›</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-label-2 text-fg-disabled font-mono">
|
||||
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E ·{' '}
|
||||
{weatherData.currentTime}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 스크롤 콘텐츠 */}
|
||||
@ -131,13 +164,13 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
{/* ── 핵심 지표 3칸 카드 ── */}
|
||||
<div className="grid grid-cols-3 gap-1 px-3 py-2.5">
|
||||
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
|
||||
<div className="text-[20px] font-bold font-mono" style={{ color: windColor(wSpd) }}>
|
||||
<div className="text-[20px] font-bold font-mono" style={{ color: windTextColor(wSpd) }}>
|
||||
{wSpd.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean">풍속 (m/s)</div>
|
||||
</div>
|
||||
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
|
||||
<div className="text-[20px] font-bold font-mono" style={{ color: waveColor(wHgt) }}>
|
||||
<div className="text-[20px] font-bold font-mono" style={{ color: waveTextColor(wHgt) }}>
|
||||
{wHgt.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean">파고 (m)</div>
|
||||
@ -152,9 +185,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
|
||||
{/* ── 바람 상세 ── */}
|
||||
<div className="px-3 py-2 border-b border-stroke">
|
||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
||||
🌬️ 바람 현황
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean mb-2">🌬️ 바람 현황</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{/* 풍향 컴파스 */}
|
||||
<div className="relative w-[50px] h-[50px] shrink-0">
|
||||
@ -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"
|
||||
/>
|
||||
<circle cx="25" cy="25" r="3" fill={windColor(wSpd)} />
|
||||
<circle cx="25" cy="25" r="3" fill="var(--color-accent)" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-x-3 gap-y-1.5 text-label-2">
|
||||
@ -222,19 +253,13 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-fg-disabled">1k 최고</span>
|
||||
<span
|
||||
className="font-mono text-title-4"
|
||||
style={{ color: windColor(wind.speed_1k) }}
|
||||
>
|
||||
<span className="font-mono text-title-4 text-fg">
|
||||
{Number(wind.speed_1k).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-fg-disabled">3k 평균</span>
|
||||
<span
|
||||
className="font-mono text-title-4"
|
||||
style={{ color: windColor(wind.speed_3k) }}
|
||||
>
|
||||
<span className="font-mono text-title-4 text-fg">
|
||||
{Number(wind.speed_3k).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
@ -248,11 +273,8 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-[5px] bg-bg-card rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min((wSpd / 20) * 100, 100)}%`,
|
||||
background: windColor(wSpd),
|
||||
}}
|
||||
className="h-full rounded-full transition-all bg-color-accent"
|
||||
style={{ width: `${Math.min((wSpd / 20) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-label-2 font-mono text-fg-disabled shrink-0">
|
||||
@ -263,24 +285,20 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
|
||||
{/* ── 파도 상세 ── */}
|
||||
<div className="px-3 py-2 border-b border-stroke">
|
||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">🌊 파도</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean mb-2">🌊 파도</div>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
<div className="text-title-3 font-bold font-mono" style={{ color: waveColor(wHgt) }}>
|
||||
{wHgt.toFixed(1)}m
|
||||
</div>
|
||||
<div className="text-title-3 font-bold font-mono text-fg">{wHgt.toFixed(1)}m</div>
|
||||
<div className="text-caption text-fg-disabled">유의파고</div>
|
||||
</div>
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
<div className="text-title-3 font-bold font-mono text-color-danger">
|
||||
<div className="text-title-3 font-bold font-mono text-fg">
|
||||
{wave.maxHeight.toFixed(1)}m
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled">최고파고</div>
|
||||
</div>
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
<div className="text-title-3 font-bold font-mono text-color-accent">
|
||||
{wave.period}s
|
||||
</div>
|
||||
<div className="text-title-3 font-bold font-mono text-fg">{wave.period}s</div>
|
||||
<div className="text-caption text-fg-disabled">주기</div>
|
||||
</div>
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
@ -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)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -307,14 +325,10 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
|
||||
{/* ── 수온/공기 ── */}
|
||||
<div className="px-3 py-2 border-b border-stroke">
|
||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
||||
🌡️ 수온 · 공기
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean mb-2">🌡️ 수온 · 공기</div>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
<div className="text-title-3 font-bold font-mono text-color-accent">
|
||||
{wTemp.toFixed(1)}°
|
||||
</div>
|
||||
<div className="text-title-3 font-bold font-mono text-fg">{wTemp.toFixed(1)}°</div>
|
||||
<div className="text-caption text-fg-disabled">수온</div>
|
||||
</div>
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
@ -332,9 +346,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
|
||||
{/* ── 시간별 예보 ── */}
|
||||
<div className="px-3 py-2 border-b border-stroke">
|
||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
||||
⏰ 시간별 예보
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean mb-2">⏰ 시간별 예보</div>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{forecast.map((f, i) => (
|
||||
<div
|
||||
@ -353,9 +365,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
{/* ── 천문/조석 ── */}
|
||||
{astronomy && (
|
||||
<div className="px-3 py-2 border-b border-stroke">
|
||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
||||
☀️ 천문 · 조석
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean mb-2">☀️ 천문 · 조석</div>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{[
|
||||
{ icon: '🌅', label: '일출', value: astronomy.sunrise },
|
||||
@ -381,17 +391,21 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
{/* ── 날씨 특보 ── */}
|
||||
{alert && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
||||
🚨 날씨 특보
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean mb-2">🚨 날씨 특보</div>
|
||||
<div
|
||||
className="px-2.5 py-2 rounded border"
|
||||
style={{ background: 'rgba(239,68,68,.06)', borderColor: 'rgba(239,68,68,.2)' }}
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-danger) 6%, transparent)',
|
||||
borderColor: 'color-mix(in srgb, var(--color-danger) 20%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-label-2">
|
||||
<span
|
||||
className="px-1.5 py-px rounded text-label-2 font-bold"
|
||||
style={{ background: 'rgba(239,68,68,.15)', color: 'var(--color-danger)' }}
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
|
||||
color: 'var(--color-danger)',
|
||||
}}
|
||||
>
|
||||
주의
|
||||
</span>
|
||||
|
||||
@ -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<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||
overlay.setProps({ layers });
|
||||
@ -178,7 +177,7 @@ function WeatherMapInner({
|
||||
{/* 핀 꼬리 */}
|
||||
<div className="w-px h-3 bg-color-accent" />
|
||||
{/* 좌표 라벨 */}
|
||||
<div className="px-2 py-1 bg-bg-base/90 border border-color-accent rounded text-caption text-color-accent whitespace-nowrap backdrop-blur-sm">
|
||||
<div className="px-2 py-1 bg-bg-base border border-color-accent rounded text-caption text-color-accent whitespace-nowrap shadow-md">
|
||||
{clickedLocation.lat.toFixed(3)}°N {clickedLocation.lon.toFixed(3)}°E
|
||||
</div>
|
||||
</div>
|
||||
@ -295,7 +294,7 @@ export function WeatherView() {
|
||||
{/* Main Map Area */}
|
||||
<div className="flex-1 relative flex flex-col overflow-hidden">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center border-b border-stroke bg-bg-surface shrink-0">
|
||||
<div className="flex items-center border-b border-stroke bg-bg-surface shrink-0 pt-2 pb-2">
|
||||
<div className="flex items-center gap-2 px-6">
|
||||
{(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => (
|
||||
<button
|
||||
@ -356,10 +355,7 @@ export function WeatherView() {
|
||||
</Map>
|
||||
|
||||
{/* 레이어 컨트롤 */}
|
||||
<div
|
||||
className="absolute top-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
|
||||
style={{ padding: '6px 10px' }}
|
||||
>
|
||||
<div className="absolute top-4 left-4 bg-bg-surface border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5">
|
||||
<div className="text-caption font-semibold text-fg mb-1.5 font-korean">기상 레이어</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
@ -420,17 +416,12 @@ export function WeatherView() {
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div
|
||||
className="absolute bottom-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
|
||||
style={{ padding: '6px 10px', maxWidth: 180 }}
|
||||
>
|
||||
<div className="text-caption font-semibold text-fg mb-1.5 font-korean">기상 범례</div>
|
||||
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
|
||||
<div className="absolute bottom-4 left-4 bg-bg-surface border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5 max-w-[180px]">
|
||||
<div className="text-caption text-fg mb-1.5 font-korean">기상 범례</div>
|
||||
<div className="flex flex-col gap-1.5 text-[8px]">
|
||||
{/* 바람 */}
|
||||
<div>
|
||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
||||
바람 (m/s)
|
||||
</div>
|
||||
<div className="text-fg-sub mb-0.5">바람 (m/s)</div>
|
||||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
||||
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
|
||||
@ -441,7 +432,7 @@ export function WeatherView() {
|
||||
<div className="flex-1 h-full" style={{ background: '#f05421' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
|
||||
<div className="flex justify-between text-fg-disabled text-[7px]">
|
||||
<span>3</span>
|
||||
<span>5</span>
|
||||
<span>7</span>
|
||||
@ -453,16 +444,14 @@ export function WeatherView() {
|
||||
</div>
|
||||
{/* 해류 */}
|
||||
<div className="pt-1 border-t border-stroke">
|
||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
||||
해류 (m/s)
|
||||
</div>
|
||||
<div className="text-fg-sub mb-0.5">해류 (m/s)</div>
|
||||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(34, 197, 94)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
|
||||
<div className="flex justify-between text-fg-disabled text-[7px]">
|
||||
<span>0.2</span>
|
||||
<span>0.4</span>
|
||||
<span>0.6</span>
|
||||
@ -471,23 +460,18 @@ export function WeatherView() {
|
||||
</div>
|
||||
{/* 파고 */}
|
||||
<div className="pt-1 border-t border-stroke">
|
||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
||||
파고 (m)
|
||||
</div>
|
||||
<div className="text-fg-sub mb-0.5">파고 (m)</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<div className="w-2 h-2 rounded-full bg-color-info" />
|
||||
<span className="text-fg-disabled"><1.5 낮음</span>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 ml-1" />
|
||||
<div className="w-2 h-2 rounded-full bg-color-warning ml-1" />
|
||||
<span className="text-fg-disabled">~2.5</span>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 ml-1" />
|
||||
<div className="w-2 h-2 rounded-full bg-color-danger ml-1" />
|
||||
<span className="text-fg-disabled">>2.5</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 pt-1 border-t border-stroke text-fg-disabled font-korean"
|
||||
style={{ fontSize: 7 }}
|
||||
>
|
||||
<div className="mt-1 pt-1 border-t border-stroke text-fg-disabled text-[7px] font-korean">
|
||||
💡 지도 클릭 → 기상 예보 확인
|
||||
</div>
|
||||
</div>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user