- className 중복 속성 31건 수정 (12파일) - KOSPS codeBox spread TypeError 해결 - HNS 페놀(C₆H₅OH) 물질 데이터 추가 - ScatRightPanel 280px 우측 패널 신규 구현 (3탭+액션버튼) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
519 lines
24 KiB
TypeScript
Executable File
519 lines
24 KiB
TypeScript
Executable File
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 shrink-0 w-[360px]">
|
||
{/* Search */}
|
||
<div className="px-4 py-3 border-b border-border shrink-0">
|
||
<div className="relative">
|
||
<span className="absolute left-[10px] top-1/2 -translate-y-1/2 text-xs">🔍</span>
|
||
<input
|
||
type="text"
|
||
placeholder="사고명, 선박명 검색..."
|
||
value={searchTerm}
|
||
onChange={(e) => { setSearchTerm(e.target.value); resetPage() }}
|
||
className="w-full py-2 pr-3 pl-8 bg-bg-0 border border-border text-xs outline-none"
|
||
style={{ borderRadius: 'var(--rS)' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Date Range */}
|
||
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border shrink-0">
|
||
<input type="date" value={dateFrom}
|
||
onChange={(e) => { setDateFrom(e.target.value); setSelectedPeriod(''); resetPage() }}
|
||
className="bg-bg-0 border border-border font-mono text-[11px] outline-none flex-1"
|
||
style={{ padding: '5px 8px', borderRadius: 'var(--rS)' }}
|
||
/>
|
||
<span className="text-text-3 text-[11px]">~</span>
|
||
<input type="date" value={dateTo}
|
||
onChange={(e) => { setDateTo(e.target.value); setSelectedPeriod(''); resetPage() }}
|
||
className="bg-bg-0 border border-border font-mono text-[11px] outline-none flex-1"
|
||
style={{ padding: '5px 8px', borderRadius: 'var(--rS)' }}
|
||
/>
|
||
<button onClick={resetPage} className="text-[11px] font-semibold cursor-pointer whitespace-nowrap text-white border-none" style={{ padding: '5px 12px', background: 'linear-gradient(135deg,var(--cyan),var(--blue))', borderRadius: 'var(--rS)' }}>조회</button>
|
||
</div>
|
||
|
||
{/* Period Presets */}
|
||
<div className="flex gap-1 px-4 py-1.5 border-b border-border shrink-0">
|
||
{PERIOD_PRESETS.map(p => (
|
||
<button key={p} onClick={() => handlePeriodClick(p)} className="text-[10px] font-semibold cursor-pointer"
|
||
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)',
|
||
}}>{p}</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Today Summary */}
|
||
<div className="px-4 py-2.5 border-b border-border shrink-0" style={{ background: 'rgba(6,182,212,0.03)' }}>
|
||
<div className="text-[10px] font-bold text-text-3 mb-2" style={{ letterSpacing: '0.8px' }}>
|
||
📅 오늘 ({todayLabel}) 사고 현황
|
||
</div>
|
||
<div className="flex gap-1.5 flex-wrap">
|
||
{REGIONS.map(r => {
|
||
const count = regionCounts[r] ?? 0
|
||
const isActive = selectedRegion === r
|
||
return (
|
||
<button key={r} onClick={() => { setSelectedRegion(r); resetPage() }} className="text-[11px] cursor-pointer"
|
||
style={{
|
||
padding: '4px 10px', borderRadius: 'var(--rS)',
|
||
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 className="font-bold font-mono" style={{ color: isActive ? 'var(--cyan)' : undefined }}>
|
||
{r === '전체' ? count : `(${count})`}
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Status Filter */}
|
||
<div className="flex gap-[5px] px-4 py-2 border-b border-border shrink-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() }}
|
||
className="flex items-center gap-1 text-[10px] font-semibold cursor-pointer"
|
||
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)',
|
||
}}>
|
||
{s.dot && <span style={{ width: '6px', height: '6px', borderRadius: '50%', background: s.dot }} />}
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Count */}
|
||
<div className="px-4 py-1.5 text-[11px] text-text-3 shrink-0 border-b border-border">
|
||
총 {filteredIncidents.length}건
|
||
</div>
|
||
|
||
{/* Incident List */}
|
||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin' as const, scrollbarColor: 'var(--bdL) transparent' }}>
|
||
{pagedIncidents.length === 0 ? (
|
||
<div className="px-4 py-10 text-center text-text-3 text-[11px]">
|
||
검색 결과가 없습니다.
|
||
</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)}
|
||
className="px-4 py-3 border-b border-border cursor-pointer"
|
||
style={{
|
||
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 className="flex items-center justify-between mb-[5px]">
|
||
<div className="flex items-center gap-1.5 text-xs font-bold">
|
||
<span className="shrink-0" style={{ width: '8px', height: '8px', borderRadius: '50%', background: dotStyle[inc.status], boxShadow: dotShadow[inc.status] }} />
|
||
{inc.name}
|
||
</div>
|
||
<span className="shrink-0 text-[10px] font-semibold" style={{ padding: '2px 10px', borderRadius: '10px', background: stBg[inc.status], color: stColor[inc.status] }}>
|
||
{stLabel[inc.status]}
|
||
</span>
|
||
</div>
|
||
{/* Row 2: meta */}
|
||
<div className="flex items-center gap-2 text-[10px] text-text-3 mb-[5px]">
|
||
<span>📅 {inc.date} {inc.time}</span>
|
||
<span>🏛 {inc.office}</span>
|
||
</div>
|
||
{/* Row 3: tags + buttons */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex flex-wrap gap-1">
|
||
{inc.causeType && (
|
||
<span className="text-[10px] font-medium text-text-2" style={{ padding: '2px 8px', borderRadius: '3px', background: 'rgba(100,116,139,0.08)', border: '1px solid rgba(100,116,139,0.2)' }}>
|
||
{inc.causeType}
|
||
</span>
|
||
)}
|
||
{inc.oilType && (
|
||
<span className="text-[10px] font-medium text-status-orange" style={{ padding: '2px 8px', borderRadius: '3px', background: 'rgba(249,115,22,0.08)', border: '1px solid rgba(249,115,22,0.2)' }}>
|
||
{inc.oilType}
|
||
</span>
|
||
)}
|
||
{inc.prediction && (
|
||
<span className="text-[10px] font-medium text-status-green" style={{ padding: '2px 8px', borderRadius: '3px', background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.2)' }}>
|
||
{inc.prediction}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-1">
|
||
<button className="inc-wx-btn cursor-pointer text-[11px]" onClick={(e) => handleWeatherClick(e, inc.id)} title="사고 위치 기상정보"
|
||
style={{
|
||
padding: '3px 7px', borderRadius: '4px', 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="현장정보 조회" className="cursor-pointer text-[11px]"
|
||
style={{
|
||
padding: '3px 7px', borderRadius: '4px', lineHeight: 1,
|
||
border: '1px solid rgba(59,130,246,0.25)', background: 'rgba(59,130,246,0.08)', color: '#60a5fa',
|
||
transition: '0.15s',
|
||
}}>📹 <span className="text-[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 className="flex items-center justify-between bg-bg-1 shrink-0 border-t border-border px-3 py-2">
|
||
<div className="text-[9px] text-text-3">
|
||
총 <b>{filteredIncidents.length}</b>건 중 {(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filteredIncidents.length)}
|
||
</div>
|
||
<div className="flex items-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 }}
|
||
className="bg-bg-0 border border-border text-text-2 text-[9px] outline-none rounded px-1.5 py-[3px]">
|
||
<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}
|
||
className="flex items-center justify-center font-mono text-[9px]"
|
||
style={{
|
||
minWidth: '24px', height: '24px', padding: '0 5px', borderRadius: '4px', fontWeight: active ? 700 : 600,
|
||
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,
|
||
cursor: disabled ? 'default' : 'pointer',
|
||
}}>{label}</button>
|
||
)
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════
|
||
WeatherPopup – 사고 위치 기상정보 팝업
|
||
════════════════════════════════════════════════════ */
|
||
const WeatherPopup = forwardRef<HTMLDivElement, {
|
||
data: WeatherInfo
|
||
position: { top: number; left: number }
|
||
onClose: () => void
|
||
}>(({ data, position, onClose }, ref) => {
|
||
return (
|
||
<div ref={ref} className="fixed overflow-hidden rounded-xl border border-border bg-bg-1" style={{
|
||
zIndex: 9990, width: 280,
|
||
top: position.top, left: position.left,
|
||
borderColor: 'rgba(59,130,246,0.3)',
|
||
boxShadow: '0 12px 40px rgba(0,0,0,0.5)', backdropFilter: 'blur(12px)',
|
||
}}>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between border-b border-border px-3.5 py-2.5"
|
||
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(6,182,212,0.04))' }}>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-sm">🌤</span>
|
||
<div>
|
||
<div className="text-[11px] font-bold">{data.locNm}</div>
|
||
<div className="text-text-3 font-mono text-[8px]">{data.obsDtm}</div>
|
||
</div>
|
||
</div>
|
||
<span onClick={onClose} className="cursor-pointer text-text-3 text-sm p-0.5">✕</span>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div className="px-3.5 py-3">
|
||
{/* Main weather */}
|
||
<div className="flex items-center gap-3 mb-2.5">
|
||
<div className="text-[28px]">{data.icon}</div>
|
||
<div>
|
||
<div className="font-bold font-mono text-[20px]">{data.temp}</div>
|
||
<div className="text-text-3 text-[9px]">{data.weatherDc}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Detail grid */}
|
||
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
|
||
<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 className="flex gap-1.5 mt-2">
|
||
<div className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
|
||
style={{ background: 'rgba(59,130,246,0.06)', border: '1px solid rgba(59,130,246,0.1)' }}>
|
||
<span className="text-xs">⬆</span>
|
||
<div>
|
||
<div className="text-text-3 text-[7px]">고조 (만조)</div>
|
||
<div className="font-bold font-mono text-[10px] text-[#60a5fa]">{data.highTide}</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
|
||
style={{ background: 'rgba(6,182,212,0.06)', border: '1px solid rgba(6,182,212,0.1)' }}>
|
||
<span className="text-xs">⬇</span>
|
||
<div>
|
||
<div className="text-text-3 text-[7px]">저조 (간조)</div>
|
||
<div className="text-primary-cyan font-bold font-mono text-[10px]">{data.lowTide}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 24h Forecast */}
|
||
<div className="bg-bg-0 mt-2.5 px-2.5 py-2 rounded-md">
|
||
<div className="font-bold text-text-3 text-[8px] mb-1.5">24h 예보</div>
|
||
<div className="flex justify-between font-mono text-text-2 text-[8px]">
|
||
{data.forecast.map((f, i) => (
|
||
<div key={i} className="text-center">
|
||
<div>{f.hour}</div>
|
||
<div className="text-xs my-0.5">{f.icon}</div>
|
||
<div className="font-semibold">{f.temp}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Impact */}
|
||
<div className="mt-2 rounded" style={{
|
||
padding: '6px 10px',
|
||
background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)',
|
||
}}>
|
||
<div className="font-bold text-status-orange text-[8px] mb-[3px]">⚠ 방제 작업 영향</div>
|
||
<div className="text-text-2 text-[8px] leading-[1.5]">{data.impactDc}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
WeatherPopup.displayName = 'WeatherPopup'
|
||
|
||
function WxCell({ icon, label, value }: { icon: string; label: string; value: string }) {
|
||
return (
|
||
<div className="flex items-center bg-bg-0 rounded gap-[6px] py-1.5 px-2">
|
||
<span className="text-[12px]">{icon}</span>
|
||
<div>
|
||
<div className="text-text-3 text-[7px]">{label}</div>
|
||
<div className="font-semibold font-mono">{value}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|