wing-ops/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx
htlee 46c7307ab9 feat(incidents): 사고관리 탭 mock → DB/API 전환
- DB: ACDNT, SPIL_DATA, PRED_EXEC, ACDNT_WEATHER, ACDNT_MEDIA 5개 테이블 생성
- 시드: 사고 12건, 유출정보 12건, 예측실행 18건, 기상 6건, 미디어 6건
- 백엔드: incidentsService + incidentsRouter (사고 목록/상세/예측/기상/미디어 5개 API)
- 프론트: IncidentsView, IncidentTable, IncidentsLeftPanel, MediaModal mock → API 전환
- mockIncidents, WEATHER_DATA, MEDIA_DATA 3개 mock 완전 제거
- SECTION_DATA, MOCK_SENSITIVE, mockVessels는 별도 도메인으로 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:20:37 +09:00

528 lines
27 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useMemo, useRef, useEffect, forwardRef } from 'react'
import { MediaModal } from './MediaModal'
export interface Incident {
id: string
name: string
status: 'active' | 'investigating' | 'closed'
date: string
time: string
region: string
office: string
location: { lat: number; lon: number }
causeType?: string
oilType?: string
prediction?: string
vesselName?: string
mediaCount?: number
}
interface IncidentsLeftPanelProps {
incidents: Incident[]
selectedIncidentId: string | null
onIncidentSelect: (id: string) => void
}
const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const
const REGIONS = ['전체', '남해청', '서해청', '동해청', '제주청', '중부청'] as const
import { fetchIncidentWeather } from '../services/incidentsApi'
import type { WeatherInfo } from '../services/incidentsApi'
function formatDate(d: Date) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${dd}`
}
function getPresetStartDate(preset: string): string {
const now = new Date()
switch (preset) {
case '오늘': return formatDate(now)
case '1주일': { const d = new Date(now); d.setDate(d.getDate() - 7); return formatDate(d) }
case '1개월': { const d = new Date(now); d.setMonth(d.getMonth() - 1); return formatDate(d) }
case '3개월': { const d = new Date(now); d.setMonth(d.getMonth() - 3); return formatDate(d) }
case '6개월': { const d = new Date(now); d.setMonth(d.getMonth() - 6); return formatDate(d) }
case '1년': { const d = new Date(now); d.setFullYear(d.getFullYear() - 1); return formatDate(d) }
default: return formatDate(now)
}
}
export function IncidentsLeftPanel({
incidents,
selectedIncidentId,
onIncidentSelect
}: IncidentsLeftPanelProps) {
const today = formatDate(new Date())
const todayLabel = today.replace(/-/g, '-')
const [searchTerm, setSearchTerm] = useState('')
const [selectedPeriod, setSelectedPeriod] = useState<string>('1개월')
const [dateFrom, setDateFrom] = useState(getPresetStartDate('1개월'))
const [dateTo, setDateTo] = useState(today)
const [selectedRegion, setSelectedRegion] = useState<string>('전체')
const [selectedStatus, setSelectedStatus] = useState<string>('전체')
// Media modal
const [mediaModalIncident, setMediaModalIncident] = useState<Incident | null>(null)
// Weather popup
const [weatherPopupId, setWeatherPopupId] = useState<string | null>(null)
const [weatherPos, setWeatherPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
const [weatherInfo, setWeatherInfo] = useState<WeatherInfo | null>(null)
const weatherRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!weatherPopupId) return
let cancelled = false
fetchIncidentWeather(parseInt(weatherPopupId)).then((data) => {
if (!cancelled) setWeatherInfo(data)
})
return () => { cancelled = true; setWeatherInfo(null) }
}, [weatherPopupId]);
useEffect(() => {
if (!weatherPopupId) return
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (weatherRef.current && !weatherRef.current.contains(target) && !target.closest('.inc-wx-btn')) {
setWeatherPopupId(null)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [weatherPopupId])
const handleWeatherClick = (e: React.MouseEvent, incId: string) => {
e.stopPropagation()
if (weatherPopupId === incId) {
setWeatherPopupId(null)
return
}
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
let top = rect.bottom + 6
let left = rect.left
if (top + 480 > window.innerHeight) top = rect.top - 480
if (left + 280 > window.innerWidth) left = window.innerWidth - 290
if (left < 10) left = 10
setWeatherPos({ top, left })
setWeatherPopupId(incId)
}
const filteredIncidents = useMemo(() => {
return incidents.filter((incident) => {
const matchesSearch =
incident.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(incident.vesselName && incident.vesselName.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesRegion = selectedRegion === '전체' || incident.region === selectedRegion
const matchesStatus =
selectedStatus === '전체' ||
(selectedStatus === 'active' && incident.status === 'active') ||
(selectedStatus === 'investigating' && incident.status === 'investigating') ||
(selectedStatus === 'closed' && incident.status === 'closed')
const matchesDate = incident.date >= dateFrom && incident.date <= dateTo
return matchesSearch && matchesRegion && matchesStatus && matchesDate
})
}, [incidents, searchTerm, selectedRegion, selectedStatus, dateFrom, dateTo])
const regionCounts = useMemo(() => {
const dateFiltered = incidents.filter(i => {
const matchesSearch = i.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(i.vesselName && i.vesselName.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesDate = i.date >= dateFrom && i.date <= dateTo
return matchesSearch && matchesDate
})
const counts: Record<string, number> = { '전체': dateFiltered.length }
REGIONS.forEach(r => { if (r !== '전체') counts[r] = dateFiltered.filter(i => i.region === r).length })
return counts
}, [incidents, searchTerm, dateFrom, dateTo])
const statusCounts = useMemo(() => {
const base = incidents.filter(i => {
const matchesSearch = i.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(i.vesselName && i.vesselName.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesRegion = selectedRegion === '전체' || i.region === selectedRegion
const matchesDate = i.date >= dateFrom && i.date <= dateTo
return matchesSearch && matchesRegion && matchesDate
})
return {
active: base.filter(i => i.status === 'active').length,
investigating: base.filter(i => i.status === 'investigating').length,
closed: base.filter(i => i.status === 'closed').length,
}
}, [incidents, searchTerm, selectedRegion, dateFrom, dateTo])
// Pagination
const pageSize = 6
const [currentPage, setCurrentPage] = useState(1)
const totalPages = Math.max(1, Math.ceil(filteredIncidents.length / pageSize))
const safePage = Math.min(currentPage, totalPages)
const pagedIncidents = filteredIncidents.slice((safePage - 1) * pageSize, safePage * pageSize)
const resetPage = () => setCurrentPage(1)
const handlePeriodClick = (preset: string) => {
setSelectedPeriod(preset)
setDateFrom(getPresetStartDate(preset))
setDateTo(today)
resetPage()
}
return (
<div className="flex flex-col bg-bg-1 border-r border-border overflow-hidden" style={{ width: '360px', flexShrink: 0 }}>
{/* Search */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--bd)', flexShrink: 0 }}>
<div style={{ position: 'relative' }}>
<span style={{ position: 'absolute', left: '10px', top: '50%', transform: 'translateY(-50%)', fontSize: '12px' }}>🔍</span>
<input
type="text"
placeholder="사고명, 선박명 검색..."
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); resetPage() }}
style={{
width: '100%', padding: '8px 12px 8px 32px', background: 'var(--bg0)',
border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)',
fontFamily: 'var(--fK)', fontSize: '12px', outline: 'none',
}}
/>
</div>
</div>
{/* Date Range */}
<div style={{ padding: '8px 16px', borderBottom: '1px solid var(--bd)', display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0 }}>
<input type="date" value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setSelectedPeriod(''); resetPage() }}
style={{ padding: '5px 8px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: '11px', outline: 'none', flex: 1 }}
/>
<span style={{ color: 'var(--t3)', fontSize: '11px' }}>~</span>
<input type="date" value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setSelectedPeriod(''); resetPage() }}
style={{ padding: '5px 8px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: '11px', outline: 'none', flex: 1 }}
/>
<button onClick={resetPage} style={{ padding: '5px 12px', background: 'linear-gradient(135deg,var(--cyan),var(--blue))', color: '#fff', border: 'none', borderRadius: 'var(--rS)', fontSize: '11px', fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)', whiteSpace: 'nowrap' }}></button>
</div>
{/* Period Presets */}
<div style={{ padding: '6px 16px', borderBottom: '1px solid var(--bd)', display: 'flex', gap: '4px', flexShrink: 0 }}>
{PERIOD_PRESETS.map(p => (
<button key={p} onClick={() => handlePeriodClick(p)} style={{
padding: '3px 8px', borderRadius: '14px', border: selectedPeriod === p ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--bd)',
background: selectedPeriod === p ? 'rgba(6,182,212,0.1)' : 'transparent',
color: selectedPeriod === p ? 'var(--cyan)' : 'var(--t3)', fontSize: '10px', fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)',
}}>{p}</button>
))}
</div>
{/* Today Summary */}
<div style={{ padding: '10px 16px', borderBottom: '1px solid var(--bd)', background: 'rgba(6,182,212,0.03)', flexShrink: 0 }}>
<div style={{ fontSize: '10px', fontWeight: 700, color: 'var(--t3)', letterSpacing: '0.8px', marginBottom: '8px', fontFamily: 'var(--fK)' }}>
📅 ({todayLabel})
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{REGIONS.map(r => {
const count = regionCounts[r] ?? 0
const isActive = selectedRegion === r
return (
<button key={r} onClick={() => { setSelectedRegion(r); resetPage() }} style={{
padding: '4px 10px', borderRadius: 'var(--rS)', fontSize: '11px', fontFamily: 'var(--fK)', cursor: 'pointer',
background: isActive ? 'rgba(6,182,212,0.1)' : 'var(--bg3)',
border: isActive ? '1px solid rgba(6,182,212,0.25)' : '1px solid var(--bd)',
color: isActive ? 'var(--cyan)' : 'var(--t2)', fontWeight: isActive ? 700 : 400,
}}>
{r === '전체' ? '전체 ' : `${r} `}
<span style={{ fontWeight: 700, fontFamily: 'var(--fM)', color: isActive ? 'var(--cyan)' : 'var(--t1)' }}>
{r === '전체' ? count : `(${count})`}
</span>
</button>
)
})}
</div>
</div>
{/* Status Filter */}
<div style={{ display: 'flex', gap: '5px', padding: '8px 16px', borderBottom: '1px solid var(--bd)', flexShrink: 0 }}>
{[
{ id: '전체', label: '전체', dot: '' },
{ id: 'active', label: `대응중 (${statusCounts.active})`, dot: 'var(--red)' },
{ id: 'investigating', label: `조사중 (${statusCounts.investigating})`, dot: 'var(--orange)' },
{ id: 'closed', label: `종료 (${statusCounts.closed})`, dot: 'var(--t3)' },
].map(s => (
<button key={s.id} onClick={() => { setSelectedStatus(s.id); resetPage() }} style={{
padding: '4px 10px', borderRadius: '12px', border: selectedStatus === s.id ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--bd)',
background: 'transparent', color: selectedStatus === s.id ? 'var(--t2)' : 'var(--t3)',
fontSize: '10px', fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)',
display: 'flex', alignItems: 'center', gap: '4px',
}}>
{s.dot && <span style={{ width: '6px', height: '6px', borderRadius: '50%', background: s.dot }} />}
{s.label}
</button>
))}
</div>
{/* Count */}
<div style={{ padding: '6px 16px', fontSize: '11px', color: 'var(--t3)', fontFamily: 'var(--fK)', borderBottom: '1px solid rgba(30,42,74,0.3)', flexShrink: 0 }}>
{filteredIncidents.length}
</div>
{/* Incident List */}
<div style={{ flex: 1, overflowY: 'auto', scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
{pagedIncidents.length === 0 ? (
<div style={{ padding: '40px 16px', textAlign: 'center', color: 'var(--t3)', fontSize: '11px', fontFamily: 'var(--fK)' }}>
.
</div>
) : pagedIncidents.map(inc => {
const isSel = selectedIncidentId === inc.id
const dotStyle: Record<string, string> = {
active: 'var(--red)', investigating: 'var(--orange)', closed: 'var(--t3)',
}
const dotShadow: Record<string, string> = {
active: '0 0 6px var(--red)', investigating: '0 0 6px var(--orange)', closed: 'none',
}
const stBg: Record<string, string> = {
active: 'rgba(239,68,68,0.15)', investigating: 'rgba(249,115,22,0.15)', closed: 'rgba(100,116,139,0.15)',
}
const stColor: Record<string, string> = {
active: 'var(--red)', investigating: 'var(--orange)', closed: 'var(--t3)',
}
const stLabel: Record<string, string> = {
active: '대응중', investigating: '조사중', closed: '종료',
}
return (
<div key={inc.id} onClick={() => onIncidentSelect(inc.id)} style={{
padding: '12px 16px', borderBottom: '1px solid var(--bd)', cursor: 'pointer',
background: isSel ? 'rgba(6,182,212,0.04)' : undefined,
borderLeft: isSel ? '3px solid var(--cyan)' : '3px solid transparent',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => { if (!isSel) e.currentTarget.style.background = 'rgba(255,255,255,0.02)' }}
onMouseLeave={(e) => { if (!isSel) e.currentTarget.style.background = '' }}
>
{/* Row 1: name + status */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '5px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', fontWeight: 700, fontFamily: 'var(--fK)', color: 'var(--t1)' }}>
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: dotStyle[inc.status], boxShadow: dotShadow[inc.status], flexShrink: 0 }} />
{inc.name}
</div>
<span style={{ padding: '2px 10px', borderRadius: '10px', fontSize: '10px', fontWeight: 600, background: stBg[inc.status], color: stColor[inc.status], flexShrink: 0 }}>
{stLabel[inc.status]}
</span>
</div>
{/* Row 2: meta */}
<div style={{ fontSize: '10px', color: 'var(--t3)', marginBottom: '5px', fontFamily: 'var(--fK)', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span>📅 {inc.date} {inc.time}</span>
<span>🏛 {inc.office}</span>
</div>
{/* Row 3: tags + buttons */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{inc.causeType && (
<span style={{ padding: '2px 8px', borderRadius: '3px', fontSize: '10px', fontWeight: 500, fontFamily: 'var(--fK)', background: 'rgba(100,116,139,0.08)', border: '1px solid rgba(100,116,139,0.2)', color: 'var(--t2)' }}>
{inc.causeType}
</span>
)}
{inc.oilType && (
<span style={{ padding: '2px 8px', borderRadius: '3px', fontSize: '10px', fontWeight: 500, fontFamily: 'var(--fK)', background: 'rgba(249,115,22,0.08)', border: '1px solid rgba(249,115,22,0.2)', color: 'var(--orange)' }}>
{inc.oilType}
</span>
)}
{inc.prediction && (
<span style={{ padding: '2px 8px', borderRadius: '3px', fontSize: '10px', fontWeight: 500, fontFamily: 'var(--fK)', background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.2)', color: 'var(--green)' }}>
{inc.prediction}
</span>
)}
</div>
<div style={{ display: 'flex', gap: '4px' }}>
<button className="inc-wx-btn" onClick={(e) => handleWeatherClick(e, inc.id)} title="사고 위치 기상정보" style={{
padding: '3px 7px', borderRadius: '4px', fontSize: '11px', cursor: 'pointer', lineHeight: 1,
border: '1px solid rgba(59,130,246,0.25)', background: weatherPopupId === inc.id ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.08)', color: '#60a5fa',
transition: '0.15s',
}}>🌤</button>
{(inc.mediaCount ?? 0) > 0 && (
<button onClick={(e) => { e.stopPropagation(); setMediaModalIncident(inc) }} title="현장정보 조회" style={{
padding: '3px 7px', borderRadius: '4px', fontSize: '11px', cursor: 'pointer', lineHeight: 1,
border: '1px solid rgba(59,130,246,0.25)', background: 'rgba(59,130,246,0.08)', color: '#60a5fa',
transition: '0.15s',
}}>📹 <span style={{ fontSize: '8px' }}>{inc.mediaCount}</span></button>
)}
</div>
</div>
</div>
)
})}
</div>
{/* Media Modal */}
{mediaModalIncident && (
<MediaModal incident={mediaModalIncident} onClose={() => setMediaModalIncident(null)} />
)}
{/* Weather Popup (fixed position) */}
{weatherPopupId && weatherInfo && (
<WeatherPopup
ref={weatherRef}
data={weatherInfo}
position={weatherPos}
onClose={() => setWeatherPopupId(null)}
/>
)}
{/* Pagination */}
<div style={{
padding: '8px 12px', borderTop: '1px solid var(--bd)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, background: 'var(--bg1)',
}}>
<div style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
<b style={{ color: 'var(--t1)' }}>{filteredIncidents.length}</b> {(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filteredIncidents.length)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '3px' }}>
<PgBtn label="⏮" disabled={safePage <= 1} onClick={() => setCurrentPage(1)} />
<PgBtn label="◀" disabled={safePage <= 1} onClick={() => setCurrentPage(Math.max(1, safePage - 1))} />
{Array.from({ length: totalPages }, (_, i) => i + 1).filter(p => Math.abs(p - safePage) <= 2).map(p => (
<PgBtn key={p} label={String(p)} active={p === safePage} onClick={() => setCurrentPage(p)} />
))}
<PgBtn label="▶" disabled={safePage >= totalPages} onClick={() => setCurrentPage(Math.min(totalPages, safePage + 1))} />
<PgBtn label="⏭" disabled={safePage >= totalPages} onClick={() => setCurrentPage(totalPages)} />
</div>
<select onChange={(e) => { /* page size change placeholder */ void e }} style={{
padding: '3px 6px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: '4px', color: 'var(--t2)', fontSize: '9px', fontFamily: 'var(--fK)', outline: 'none',
}}>
<option>6</option><option>10</option><option>20</option>
</select>
</div>
</div>
)
}
function PgBtn({ label, active, disabled, onClick }: { label: string; active?: boolean; disabled?: boolean; onClick: () => void }) {
return (
<button onClick={onClick} disabled={disabled} style={{
minWidth: '24px', height: '24px', padding: '0 5px', borderRadius: '4px', fontSize: '9px', fontWeight: active ? 700 : 600,
fontFamily: 'var(--fM)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: disabled ? 'default' : 'pointer',
background: active ? 'rgba(6,182,212,0.15)' : 'var(--bg3)',
border: active ? '1px solid rgba(6,182,212,0.4)' : '1px solid var(--bd)',
color: active ? 'var(--cyan)' : 'var(--t3)',
opacity: disabled ? 0.4 : 1, pointerEvents: disabled ? 'none' : undefined,
}}>{label}</button>
)
}
/* ════════════════════════════════════════════════════
WeatherPopup 사고 위치 기상정보 팝업
════════════════════════════════════════════════════ */
const WeatherPopup = forwardRef<HTMLDivElement, {
data: WeatherInfo
position: { top: number; left: number }
onClose: () => void
}>(({ data, position, onClose }, ref) => {
return (
<div ref={ref} style={{
position: 'fixed', zIndex: 9990, width: 280,
top: position.top, left: position.left,
background: 'var(--bg1)', border: '1px solid rgba(59,130,246,0.3)', borderRadius: 12,
boxShadow: '0 12px 40px rgba(0,0,0,0.5)', overflow: 'hidden', backdropFilter: 'blur(12px)',
}}>
{/* Header */}
<div style={{
padding: '10px 14px',
background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(6,182,212,0.04))',
borderBottom: '1px solid var(--bd)', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 14 }}>🌤</span>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>{data.locNm}</div>
<div style={{ fontSize: 8, color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{data.obsDtm}</div>
</div>
</div>
<span onClick={onClose} style={{ fontSize: 14, cursor: 'pointer', color: 'var(--t3)', padding: 2 }}></span>
</div>
{/* Body */}
<div style={{ padding: '12px 14px' }}>
{/* Main weather */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
<div style={{ fontSize: 28 }}>{data.icon}</div>
<div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{data.temp}</div>
<div style={{ fontSize: 9, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{data.weatherDc}</div>
</div>
</div>
{/* Detail grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, fontSize: 9, fontFamily: 'var(--fK)' }}>
<WxCell icon="💨" label="풍향/풍속" value={data.wind} />
<WxCell icon="🌊" label="파고" value={data.wave} />
<WxCell icon="💧" label="습도" value={data.humid} />
<WxCell icon="👁" label="시정" value={data.vis} />
<WxCell icon="🌡" label="수온" value={data.sst} />
<WxCell icon="🔄" label="조류" value={data.tide} />
</div>
{/* Tide info */}
<div style={{ marginTop: 8, display: 'flex', gap: 6 }}>
<div style={{
flex: 1, padding: '6px 8px', background: 'rgba(59,130,246,0.06)',
border: '1px solid rgba(59,130,246,0.1)', borderRadius: 6,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ fontSize: 12 }}></span>
<div>
<div style={{ color: 'var(--t3)', fontSize: 7, fontFamily: 'var(--fK)' }}> ()</div>
<div style={{ color: '#60a5fa', fontWeight: 700, fontFamily: 'var(--fM)', fontSize: 10 }}>{data.highTide}</div>
</div>
</div>
<div style={{
flex: 1, padding: '6px 8px', background: 'rgba(6,182,212,0.06)',
border: '1px solid rgba(6,182,212,0.1)', borderRadius: 6,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ fontSize: 12 }}></span>
<div>
<div style={{ color: 'var(--t3)', fontSize: 7, fontFamily: 'var(--fK)' }}> ()</div>
<div style={{ color: 'var(--cyan)', fontWeight: 700, fontFamily: 'var(--fM)', fontSize: 10 }}>{data.lowTide}</div>
</div>
</div>
</div>
{/* 24h Forecast */}
<div style={{ marginTop: 10, padding: '8px 10px', background: 'var(--bg0)', borderRadius: 6 }}>
<div style={{ fontSize: 8, fontWeight: 700, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: 6 }}>24h </div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 8, fontFamily: 'var(--fM)', color: 'var(--t2)' }}>
{data.forecast.map((f, i) => (
<div key={i} style={{ textAlign: 'center' }}>
<div>{f.hour}</div>
<div style={{ fontSize: 12, margin: '2px 0' }}>{f.icon}</div>
<div style={{ fontWeight: 600 }}>{f.temp}</div>
</div>
))}
</div>
</div>
{/* Impact */}
<div style={{
marginTop: 8, padding: '6px 10px',
background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)', borderRadius: 6,
}}>
<div style={{ fontSize: 8, fontWeight: 700, color: 'var(--orange)', fontFamily: 'var(--fK)', marginBottom: 3 }}> </div>
<div style={{ fontSize: 8, color: 'var(--t2)', fontFamily: 'var(--fK)', lineHeight: 1.5 }}>{data.impactDc}</div>
</div>
</div>
</div>
)
})
WeatherPopup.displayName = 'WeatherPopup'
function WxCell({ icon, label, value }: { icon: string; label: string; value: string }) {
return (
<div style={{
padding: '6px 8px', background: 'var(--bg0)', borderRadius: 6,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ fontSize: 12 }}>{icon}</span>
<div>
<div style={{ color: 'var(--t3)', fontSize: 7 }}>{label}</div>
<div style={{ color: 'var(--t1)', fontWeight: 600, fontFamily: 'var(--fM)' }}>{value}</div>
</div>
</div>
)
}