release: 2026-03-19 (26건 커밋) #105
@ -1376,23 +1376,23 @@ function MapControls({ center, zoom }: { center: [number, number]; zoom: number
|
||||
const { current: map } = useMap()
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="absolute top-[80px] left-[10px] z-10">
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => map?.zoomIn()}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||||
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-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-xs"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => map?.zoomOut()}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||||
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-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-xs"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
|
||||
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-text-2 flex items-center justify-center hover:text-text-1 transition-all text-[10px]"
|
||||
>
|
||||
🎯
|
||||
</button>
|
||||
@ -1411,7 +1411,7 @@ interface MapLegendProps {
|
||||
}
|
||||
|
||||
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
const [minimized, setMinimized] = useState(true)
|
||||
|
||||
if (dispersionResult && incidentCoord) {
|
||||
return (
|
||||
|
||||
@ -69,6 +69,76 @@
|
||||
color: var(--t3);
|
||||
}
|
||||
|
||||
/* Date/Time picker custom styling */
|
||||
.prd-date-input::-webkit-calendar-picker-indicator,
|
||||
.prd-time-input::-webkit-calendar-picker-indicator {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 28px;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prd-date-input,
|
||||
.prd-time-input {
|
||||
font-size: 10px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit,
|
||||
.prd-time-input::-webkit-datetime-edit {
|
||||
color: var(--t2);
|
||||
font-family: var(--fM);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit-fields-wrapper,
|
||||
.prd-time-input::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit-year-field,
|
||||
.prd-date-input::-webkit-datetime-edit-month-field,
|
||||
.prd-date-input::-webkit-datetime-edit-day-field,
|
||||
.prd-time-input::-webkit-datetime-edit-hour-field,
|
||||
.prd-time-input::-webkit-datetime-edit-minute-field,
|
||||
.prd-time-input::-webkit-datetime-edit-ampm-field {
|
||||
color: var(--t2);
|
||||
background: transparent;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit-year-field:focus,
|
||||
.prd-date-input::-webkit-datetime-edit-month-field:focus,
|
||||
.prd-date-input::-webkit-datetime-edit-day-field:focus,
|
||||
.prd-time-input::-webkit-datetime-edit-hour-field:focus,
|
||||
.prd-time-input::-webkit-datetime-edit-minute-field:focus,
|
||||
.prd-time-input::-webkit-datetime-edit-ampm-field:focus {
|
||||
background: rgba(6, 182, 212, 0.12);
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit-text,
|
||||
.prd-time-input::-webkit-datetime-edit-text {
|
||||
color: var(--t3);
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
/* Time hour/minute select (dark dropdown) */
|
||||
select.prd-i.prd-time-select {
|
||||
color-scheme: dark;
|
||||
-webkit-appearance: menulist !important;
|
||||
appearance: menulist !important;
|
||||
background: var(--bg3) !important;
|
||||
background-image: none !important;
|
||||
padding-right: 4px;
|
||||
color: var(--t1);
|
||||
border-color: var(--bd);
|
||||
}
|
||||
|
||||
/* Select Dropdown */
|
||||
select.prd-i {
|
||||
cursor: pointer;
|
||||
@ -210,10 +280,11 @@
|
||||
.prd-mc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 13px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 5px 4px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
cursor: pointer;
|
||||
@ -294,18 +365,20 @@
|
||||
.cod {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
left: 16px;
|
||||
background: rgba(18, 25, 41, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--bd);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(18, 25, 41, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(30, 42, 66, 0.4);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
padding: 5px 14px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--t2);
|
||||
font-size: 10px;
|
||||
color: #1a1a2e;
|
||||
font-weight: 600;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cov {
|
||||
@ -316,40 +389,41 @@
|
||||
/* ═══ Weather Info Panel ═══ */
|
||||
.wip {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
background: rgba(18, 25, 41, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(18, 25, 41, 0.65);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(30, 42, 66, 0.5);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wii {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wii-icon {
|
||||
font-size: 18px;
|
||||
opacity: 0.6;
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.wii-value {
|
||||
font-size: 15px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--t1);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.wii-label {
|
||||
font-size: 9px;
|
||||
color: var(--t3);
|
||||
font-size: 7px;
|
||||
color: #1a1a2e;
|
||||
font-weight: 700;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
202
frontend/src/tabs/incidents/components/DischargeZonePanel.tsx
Normal file
202
frontend/src/tabs/incidents/components/DischargeZonePanel.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* 해양환경관리법 제22조 기반 선박 발생 오염물 배출 규정
|
||||
* 영해기선으로부터의 거리에 따라 배출 가능 여부 결정
|
||||
*
|
||||
* 법률 근거:
|
||||
* https://lbox.kr/v2/statute/%ED%95%B4%EC%96%91%ED%99%98%EA%B2%BD%EA%B4%80%EB%A6%AC%EB%B2%95/%EB%B3%B8%EB%AC%B8%20%3E%20%EC%A0%9C3%EC%9E%A5%20%3E%20%EC%A0%9C1%EC%A0%88%20%3E%20%EC%A0%9C22%EC%A1%B0
|
||||
* 선박에서의 오염방지에 관한 규칙 제8조[별표 2] 및 제14조
|
||||
*/
|
||||
|
||||
type Status = 'forbidden' | 'allowed' | 'conditional'
|
||||
|
||||
interface DischargeRule {
|
||||
category: string
|
||||
item: string
|
||||
zones: [Status, Status, Status, Status] // [~3NM, 3~12NM, 12~25NM, 25NM+]
|
||||
condition?: string
|
||||
}
|
||||
|
||||
const RULES: DischargeRule[] = [
|
||||
// 폐기물
|
||||
{ category: '폐기물', item: '플라스틱 제품', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
||||
{ category: '폐기물', item: '포장유해물질·용기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
||||
{ category: '폐기물', item: '중금속 포함 쓰레기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
||||
// 화물잔류물
|
||||
{ category: '화물잔류물', item: '부유성 화물잔류물', zones: ['forbidden', 'forbidden', 'forbidden', 'allowed'] },
|
||||
{ category: '화물잔류물', item: '침강성 화물잔류물', zones: ['forbidden', 'forbidden', 'allowed', 'allowed'] },
|
||||
{ category: '화물잔류물', item: '화물창 세정수', zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], condition: '해양환경에 해롭지 않은 일반세제 사용시' },
|
||||
// 음식물 찌꺼기
|
||||
{ category: '음식물찌꺼기', item: '미분쇄', zones: ['forbidden', 'forbidden', 'allowed', 'allowed'] },
|
||||
{ category: '음식물찌꺼기', item: '분쇄·연마', zones: ['forbidden', 'conditional', 'allowed', 'allowed'], condition: '크기 25mm 이하시' },
|
||||
// 분뇨
|
||||
{ category: '분뇨', item: '분뇨저장장치', zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], condition: '항속 4노트 이상시 서서히 배출' },
|
||||
{ category: '분뇨', item: '분뇨마쇄소독장치', zones: ['forbidden', 'conditional', 'conditional', 'conditional'], condition: '항속 4노트 이상시 / 400톤 미만 국내항해 선박은 3해리 이내 가능' },
|
||||
{ category: '분뇨', item: '분뇨처리장치', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면 및 육성수면은 불가' },
|
||||
// 중수
|
||||
{ category: '중수', item: '거주구역 중수', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면, 수산자원관리수면, 지정해역 등은 불가' },
|
||||
// 수산동식물
|
||||
{ category: '수산동식물', item: '자연기원물질', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '면허 또는 허가를 득한 자에 한하여 어업활동 수면' },
|
||||
]
|
||||
|
||||
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25해리+']
|
||||
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e']
|
||||
|
||||
function getZoneIndex(distanceNm: number): number {
|
||||
if (distanceNm < 3) return 0
|
||||
if (distanceNm < 12) return 1
|
||||
if (distanceNm < 25) return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: Status }) {
|
||||
if (status === 'forbidden') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(239,68,68,0.15)', color: '#ef4444' }}>배출불가</span>
|
||||
if (status === 'allowed') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(34,197,94,0.15)', color: '#22c55e' }}>배출가능</span>
|
||||
return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(234,179,8,0.15)', color: '#eab308' }}>조건부</span>
|
||||
}
|
||||
|
||||
interface DischargeZonePanelProps {
|
||||
lat: number
|
||||
lon: number
|
||||
distanceNm: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZonePanelProps) {
|
||||
const zoneIdx = getZoneIndex(distanceNm)
|
||||
const [expandedCat, setExpandedCat] = useState<string | null>(null)
|
||||
|
||||
const categories = [...new Set(RULES.map(r => r.category))]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-4 right-4 z-[1000] rounded-lg overflow-hidden flex flex-col"
|
||||
style={{
|
||||
width: 320,
|
||||
maxHeight: 'calc(100% - 32px)',
|
||||
background: 'rgba(13,17,23,0.95)',
|
||||
border: '1px solid #30363d',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.5)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between"
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid #30363d',
|
||||
background: 'linear-gradient(135deg, #1c2333, #161b22)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-[#f0f6fc] font-korean">🚢 오염물 배출 규정</div>
|
||||
<div className="text-[8px] text-[#8b949e] font-korean">해양환경관리법 제22조</div>
|
||||
</div>
|
||||
<span onClick={onClose} className="text-[14px] cursor-pointer text-[#8b949e] hover:text-[#f0f6fc]">✕</span>
|
||||
</div>
|
||||
|
||||
{/* Location Info */}
|
||||
<div className="shrink-0" style={{ padding: '8px 14px', borderBottom: '1px solid #21262d' }}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[9px] text-[#8b949e] font-korean">선택 위치</span>
|
||||
<span className="text-[9px] text-[#c9d1d9] font-mono">{lat.toFixed(4)}°N, {lon.toFixed(4)}°E</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[9px] text-[#8b949e] font-korean">영해기선 거리 (추정)</span>
|
||||
<span className="text-[11px] font-bold font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
|
||||
{distanceNm.toFixed(1)} NM
|
||||
</span>
|
||||
</div>
|
||||
{/* Zone indicator */}
|
||||
<div className="flex gap-1">
|
||||
{ZONE_LABELS.map((label, i) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex-1 text-center rounded-sm"
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
fontSize: 8,
|
||||
fontWeight: i === zoneIdx ? 700 : 400,
|
||||
color: i === zoneIdx ? '#fff' : '#8b949e',
|
||||
background: i === zoneIdx ? ZONE_COLORS[i] : 'rgba(255,255,255,0.04)',
|
||||
border: i === zoneIdx ? 'none' : '1px solid #21262d',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: '#30363d transparent' }}>
|
||||
{categories.map(cat => {
|
||||
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'
|
||||
|
||||
return (
|
||||
<div key={cat} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => setExpandedCat(isExpanded ? null : cat)}
|
||||
style={{ padding: '8px 14px' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ width: 6, height: 6, borderRadius: '50%', background: summaryColor }} />
|
||||
<span className="text-[10px] font-bold text-[#c9d1d9] font-korean">{cat}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[8px] font-semibold" style={{ color: summaryColor }}>
|
||||
{allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'}
|
||||
</span>
|
||||
<span className="text-[9px] text-[#8b949e]">{isExpanded ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '0 14px 10px' }}>
|
||||
{catRules.map((rule, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between"
|
||||
style={{
|
||||
padding: '5px 8px',
|
||||
marginBottom: 2,
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px] text-[#c9d1d9] font-korean">{rule.item}</span>
|
||||
<StatusBadge status={rule.zones[zoneIdx]} />
|
||||
</div>
|
||||
))}
|
||||
{catRules.some(r => r.condition && r.zones[zoneIdx] !== 'forbidden') && (
|
||||
<div className="mt-1" style={{ padding: '4px 8px' }}>
|
||||
{catRules.filter(r => r.condition && r.zones[zoneIdx] !== 'forbidden').map((r, i) => (
|
||||
<div key={i} className="text-[7px] text-[#8b949e] font-korean leading-relaxed">
|
||||
💡 {r.item}: {r.condition}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="shrink-0" style={{ padding: '6px 14px', borderTop: '1px solid #21262d' }}>
|
||||
<div className="text-[7px] text-[#8b949e] font-korean leading-relaxed">
|
||||
※ 거리는 최근접 해안선 기준 추정치입니다. 실제 영해기선과 차이가 있을 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Map, Popup, useControl } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { ScatterplotLayer, IconLayer } from '@deck.gl/layers'
|
||||
import { ScatterplotLayer, IconLayer, PathLayer } from '@deck.gl/layers'
|
||||
import { PathStyleExtension } from '@deck.gl/extensions'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'
|
||||
@ -9,23 +10,25 @@ import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './Inci
|
||||
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'
|
||||
import { fetchIncidents } from '../services/incidentsApi'
|
||||
import type { IncidentCompat } from '../services/incidentsApi'
|
||||
import { DischargeZonePanel } from './DischargeZonePanel'
|
||||
import { estimateDistanceFromCoast, getDischargeZoneLines } from '../utils/dischargeZoneData'
|
||||
|
||||
// ── CartoDB Dark Matter 베이스맵 ────────────────────────
|
||||
// ── CartoDB Positron 베이스맵 (밝은 테마) ────────────────
|
||||
const BASE_STYLE: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'carto-dark': {
|
||||
'carto-light': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
|
||||
layers: [{ id: 'carto-light-layer', type: 'raster', source: 'carto-light' }],
|
||||
}
|
||||
|
||||
// ── DeckGLOverlay ──────────────────────────────────────
|
||||
@ -90,6 +93,10 @@ export function IncidentsView() {
|
||||
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null)
|
||||
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null)
|
||||
|
||||
// Discharge zone mode
|
||||
const [dischargeMode, setDischargeMode] = useState(false)
|
||||
const [dischargeInfo, setDischargeInfo] = useState<{ lat: number; lon: number; distanceNm: number } | null>(null)
|
||||
|
||||
// Analysis view mode
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('overlay')
|
||||
const [analysisActive, setAnalysisActive] = useState(false)
|
||||
@ -223,10 +230,30 @@ export function IncidentsView() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── 배출 구역 경계선 레이어 ──
|
||||
const dischargeZoneLayers = useMemo(() => {
|
||||
if (!dischargeMode) return []
|
||||
const zoneLines = getDischargeZoneLines()
|
||||
return zoneLines.map((line, i) =>
|
||||
new PathLayer({
|
||||
id: `discharge-zone-${i}`,
|
||||
data: [line],
|
||||
getPath: (d: typeof line) => d.path,
|
||||
getColor: (d: typeof line) => d.color,
|
||||
getWidth: 2,
|
||||
widthUnits: 'pixels',
|
||||
getDashArray: [6, 3],
|
||||
dashJustified: true,
|
||||
extensions: [new PathStyleExtension({ dash: true })],
|
||||
pickable: false,
|
||||
})
|
||||
)
|
||||
}, [dischargeMode])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deckLayers: any[] = useMemo(
|
||||
() => [incidentLayer, vesselIconLayer],
|
||||
[incidentLayer, vesselIconLayer],
|
||||
() => [incidentLayer, vesselIconLayer, ...dischargeZoneLayers],
|
||||
[incidentLayer, vesselIconLayer, dischargeZoneLayers],
|
||||
)
|
||||
|
||||
return (
|
||||
@ -320,8 +347,17 @@ export function IncidentsView() {
|
||||
<Map
|
||||
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
|
||||
mapStyle={BASE_STYLE}
|
||||
style={{ width: '100%', height: '100%', background: '#0a0e1a' }}
|
||||
style={{ width: '100%', height: '100%', background: '#f0f0f0' }}
|
||||
attributionControl={false}
|
||||
onClick={(e) => {
|
||||
if (dischargeMode && e.lngLat) {
|
||||
const lat = e.lngLat.lat
|
||||
const lon = e.lngLat.lng
|
||||
const distanceNm = estimateDistanceFromCoast(lat, lon)
|
||||
setDischargeInfo({ lat, lon, distanceNm })
|
||||
}
|
||||
}}
|
||||
cursor={dischargeMode ? 'crosshair' : undefined}
|
||||
>
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
|
||||
@ -428,6 +464,57 @@ export function IncidentsView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 오염물 배출 규정 토글 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setDischargeMode(!dischargeMode)
|
||||
if (dischargeMode) setDischargeInfo(null)
|
||||
}}
|
||||
className="absolute z-[500] cursor-pointer rounded-md text-[10px] font-bold font-korean"
|
||||
style={{
|
||||
top: 10,
|
||||
right: dischargeMode ? 340 : 180,
|
||||
padding: '6px 10px',
|
||||
background: dischargeMode ? 'rgba(6,182,212,0.15)' : 'rgba(13,17,23,0.88)',
|
||||
border: dischargeMode ? '1px solid rgba(6,182,212,0.4)' : '1px solid #30363d',
|
||||
color: dischargeMode ? '#22d3ee' : '#8b949e',
|
||||
backdropFilter: 'blur(8px)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
🚢 배출규정 {dischargeMode ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
|
||||
{/* 오염물 배출 규정 패널 */}
|
||||
{dischargeMode && dischargeInfo && (
|
||||
<DischargeZonePanel
|
||||
lat={dischargeInfo.lat}
|
||||
lon={dischargeInfo.lon}
|
||||
distanceNm={dischargeInfo.distanceNm}
|
||||
onClose={() => setDischargeInfo(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 배출규정 모드 안내 */}
|
||||
{dischargeMode && !dischargeInfo && (
|
||||
<div
|
||||
className="absolute z-[500] rounded-md text-[11px] font-korean font-semibold"
|
||||
style={{
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
padding: '12px 20px',
|
||||
background: 'rgba(13,17,23,0.9)',
|
||||
border: '1px solid rgba(6,182,212,0.3)',
|
||||
color: '#22d3ee',
|
||||
backdropFilter: 'blur(8px)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
📍 지도를 클릭하여 배출 규정을 확인하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AIS Live Badge */}
|
||||
<div
|
||||
className="absolute top-[10px] right-[10px] z-[500] rounded-md"
|
||||
|
||||
166
frontend/src/tabs/incidents/utils/dischargeZoneData.ts
Normal file
166
frontend/src/tabs/incidents/utils/dischargeZoneData.ts
Normal file
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 해양환경관리법 제22조 기반 오염물 배출 구역 데이터 및 유틸리티
|
||||
*
|
||||
* 법률 근거:
|
||||
* https://lbox.kr/v2/statute/%ED%95%B4%EC%96%91%ED%99%98%EA%B2%BD%EA%B4%80%EB%A6%AC%EB%B2%95/%EB%B3%B8%EB%AC%B8%20%3E%20%EC%A0%9C3%EC%9E%A5%20%3E%20%EC%A0%9C1%EC%A0%88%20%3E%20%EC%A0%9C22%EC%A1%B0
|
||||
* 선박에서의 오염방지에 관한 규칙 제8조[별표 2] 및 제14조
|
||||
*/
|
||||
|
||||
// 한국 해안선 — OpenStreetMap Nominatim 기반 실측 좌표
|
||||
// [lat, lon] 형식, 시계방향 (동해→남해→서해→DMZ)
|
||||
const COASTLINE_POINTS: [number, number][] = [
|
||||
// 동해안 (북→남)
|
||||
[38.6177, 128.6560], [38.5504, 128.4092], [38.4032, 128.7767],
|
||||
[38.1904, 128.8902], [38.0681, 128.9977], [37.9726, 129.0715],
|
||||
[37.8794, 129.1721], [37.8179, 129.2397], [37.6258, 129.3669],
|
||||
[37.5053, 129.4577], [37.3617, 129.5700], [37.1579, 129.6538],
|
||||
[37.0087, 129.6706], [36.6618, 129.7210], [36.3944, 129.6827],
|
||||
[36.2052, 129.7641], [35.9397, 129.8124], [35.6272, 129.7121],
|
||||
[35.4732, 129.6908], [35.2843, 129.5924], [35.1410, 129.4656],
|
||||
[35.0829, 129.2125],
|
||||
// 남해안 (부산→여수→목포)
|
||||
[34.8950, 129.0658], [34.2050, 128.3063], [35.0220, 128.0362],
|
||||
[34.9663, 127.8732], [34.9547, 127.7148], [34.8434, 127.6625],
|
||||
[34.7826, 127.7422], [34.6902, 127.6324], [34.8401, 127.5236],
|
||||
[34.8230, 127.4043], [34.6882, 127.4234], [34.6252, 127.4791],
|
||||
[34.5525, 127.4012], [34.4633, 127.3246], [34.5461, 127.1734],
|
||||
[34.6617, 127.2605], [34.7551, 127.2471], [34.6069, 127.0308],
|
||||
[34.4389, 126.8975], [34.4511, 126.8263], [34.4949, 126.7965],
|
||||
[34.5119, 126.7548], [34.4035, 126.6108], [34.3175, 126.5844],
|
||||
[34.3143, 126.5314], [34.3506, 126.5083], [34.4284, 126.5064],
|
||||
[34.4939, 126.4817], [34.5896, 126.3326], [34.6732, 126.2645],
|
||||
// 서해안 (목포→인천)
|
||||
[34.7200, 126.3011], [34.6946, 126.4256], [34.6979, 126.5245],
|
||||
[34.7787, 126.5386], [34.8244, 126.5934], [34.8104, 126.4785],
|
||||
[34.8234, 126.4207], [34.9328, 126.3979], [35.0451, 126.3274],
|
||||
[35.1542, 126.2911], [35.2169, 126.3605], [35.3144, 126.3959],
|
||||
[35.4556, 126.4604], [35.5013, 126.4928], [35.5345, 126.5822],
|
||||
[35.5710, 126.6141], [35.5897, 126.5649], [35.6063, 126.4865],
|
||||
[35.6471, 126.4885], [35.6693, 126.5419], [35.7142, 126.6016],
|
||||
[35.7688, 126.7174], [35.8720, 126.7530], [35.8979, 126.7196],
|
||||
[35.9225, 126.6475], [35.9745, 126.6637], [36.0142, 126.6935],
|
||||
[36.0379, 126.6823], [36.1050, 126.5971], [36.1662, 126.5404],
|
||||
[36.2358, 126.5572], [36.3412, 126.5442], [36.4297, 126.5520],
|
||||
[36.4776, 126.5482], [36.5856, 126.5066], [36.6938, 126.4877],
|
||||
[36.6780, 126.4330], [36.6512, 126.3888], [36.6893, 126.2307],
|
||||
[36.6916, 126.1809], [36.7719, 126.1605], [36.8709, 126.2172],
|
||||
[36.9582, 126.3516], [36.9690, 126.4287], [37.0075, 126.4870],
|
||||
[37.0196, 126.5777], [36.9604, 126.6867], [36.9484, 126.7845],
|
||||
[36.8461, 126.8388], [36.8245, 126.8721], [36.8621, 126.8791],
|
||||
[36.9062, 126.9580], [36.9394, 126.9769], [36.9576, 126.9598],
|
||||
[36.9757, 126.8689], [37.1027, 126.7874], [37.1582, 126.7761],
|
||||
[37.1936, 126.7464], [37.2949, 126.7905], [37.4107, 126.6962],
|
||||
[37.4471, 126.6503], [37.5512, 126.6568], [37.6174, 126.6076],
|
||||
[37.6538, 126.5802], [37.7165, 126.5634], [37.7447, 126.5777],
|
||||
[37.7555, 126.6207], [37.7818, 126.6339], [37.8007, 126.6646],
|
||||
[37.8279, 126.6665], [37.9172, 126.6668], [37.9790, 126.7543],
|
||||
// DMZ (간소화)
|
||||
[38.1066, 126.8789], [38.1756, 126.9400], [38.2405, 127.0097],
|
||||
[38.2839, 127.0903], [38.3045, 127.1695], [38.3133, 127.2940],
|
||||
[38.3244, 127.5469], [38.3353, 127.7299], [38.3469, 127.7858],
|
||||
[38.3066, 127.8207], [38.3250, 127.9001], [38.3150, 128.0083],
|
||||
[38.3107, 128.0314], [38.3189, 128.0887], [38.3317, 128.1269],
|
||||
[38.3481, 128.1606], [38.3748, 128.2054], [38.4032, 128.2347],
|
||||
[38.4797, 128.3064], [38.5339, 128.6952], [38.6177, 128.6560],
|
||||
]
|
||||
|
||||
// 제주도 — OpenStreetMap 기반 (26 points)
|
||||
const JEJU_POINTS: [number, number][] = [
|
||||
[33.5168, 126.0128], [33.5067, 126.0073], [33.1190, 126.0102],
|
||||
[33.0938, 126.0176], [33.0748, 126.0305], [33.0556, 126.0355],
|
||||
[33.0280, 126.0492], [33.0159, 126.4783], [33.0115, 126.5186],
|
||||
[33.0143, 126.5572], [33.0231, 126.5970], [33.0182, 126.6432],
|
||||
[33.0201, 126.7129], [33.0458, 126.7847], [33.0662, 126.8169],
|
||||
[33.0979, 126.8512], [33.1192, 126.9292], [33.1445, 126.9783],
|
||||
[33.1683, 127.0129], [33.1974, 127.0430], [33.2226, 127.0634],
|
||||
[33.2436, 127.0723], [33.4646, 127.2106], [33.5440, 126.0355],
|
||||
[33.5808, 126.0814], [33.5168, 126.0128],
|
||||
]
|
||||
|
||||
const ALL_COASTLINE = [...COASTLINE_POINTS, ...JEJU_POINTS]
|
||||
|
||||
/** 두 좌표 간 대략적 해리(NM) 계산 (Haversine) */
|
||||
function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 3440.065
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(a))
|
||||
}
|
||||
|
||||
/** 클릭 지점에서 가장 가까운 해안선까지의 거리 (NM) */
|
||||
export function estimateDistanceFromCoast(lat: number, lon: number): number {
|
||||
let minDist = Infinity
|
||||
for (const [cLat, cLon] of ALL_COASTLINE) {
|
||||
const dist = haversineNm(lat, lon, cLat, cLon)
|
||||
if (dist < minDist) minDist = dist
|
||||
}
|
||||
return minDist
|
||||
}
|
||||
|
||||
/**
|
||||
* 해안선을 주어진 해리(NM) 만큼 바깥(바다쪽)으로 오프셋한 경계선 생성
|
||||
*/
|
||||
function offsetCoastline(points: [number, number][], distanceNm: number, outwardSign: number = 1): [number, number][] {
|
||||
const degPerNm = 1 / 60
|
||||
const result: [number, number][] = []
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const prev = points[(i - 1 + points.length) % points.length]
|
||||
const curr = points[i]
|
||||
const next = points[(i + 1) % points.length]
|
||||
|
||||
const cosLat = Math.cos(curr[0] * Math.PI / 180)
|
||||
const dx0 = (curr[1] - prev[1]) * cosLat
|
||||
const dy0 = curr[0] - prev[0]
|
||||
const dx1 = (next[1] - curr[1]) * cosLat
|
||||
const dy1 = next[0] - curr[0]
|
||||
|
||||
let nx = -(dy0 + dy1) / 2
|
||||
let ny = (dx0 + dx1) / 2
|
||||
const len = Math.sqrt(nx * nx + ny * ny) || 1
|
||||
nx /= len
|
||||
ny /= len
|
||||
|
||||
const latOff = outwardSign * nx * distanceNm * degPerNm
|
||||
const lonOff = outwardSign * ny * distanceNm * degPerNm / cosLat
|
||||
|
||||
result.push([curr[0] + latOff, curr[1] + lonOff])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export interface ZoneLine {
|
||||
path: [number, number][]
|
||||
color: [number, number, number, number]
|
||||
label: string
|
||||
distanceNm: number
|
||||
}
|
||||
|
||||
export function getDischargeZoneLines(): ZoneLine[] {
|
||||
const zones = [
|
||||
{ nm: 3, color: [239, 68, 68, 180] as [number, number, number, number], label: '3해리' },
|
||||
{ nm: 12, color: [249, 115, 22, 160] as [number, number, number, number], label: '12해리' },
|
||||
{ nm: 25, color: [234, 179, 8, 140] as [number, number, number, number], label: '25해리' },
|
||||
]
|
||||
|
||||
const lines: ZoneLine[] = []
|
||||
for (const zone of zones) {
|
||||
const mainOffset = offsetCoastline(COASTLINE_POINTS, zone.nm, -1)
|
||||
lines.push({
|
||||
path: mainOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
||||
color: zone.color,
|
||||
label: zone.label,
|
||||
distanceNm: zone.nm,
|
||||
})
|
||||
const jejuOffset = offsetCoastline(JEJU_POINTS, zone.nm, +1)
|
||||
lines.push({
|
||||
path: jejuOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
||||
color: zone.color,
|
||||
label: `${zone.label} (제주)`,
|
||||
distanceNm: zone.nm,
|
||||
})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { decimalToDMS } from '@common/utils/coordinates'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { ComboBox } from '@common/components/ui/ComboBox'
|
||||
import type { PredictionModel } from './OilSpillView'
|
||||
import { analyzeImage } from '../services/predictionApi'
|
||||
@ -267,54 +266,33 @@ const PredictionInputSection = ({
|
||||
{/* 사고 발생 시각 */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-text-3 font-korean">사고 발생 시각 (KST)</label>
|
||||
<input
|
||||
className="prd-i"
|
||||
type="datetime-local"
|
||||
<DateTimeInput
|
||||
value={accidentTime}
|
||||
onChange={(e) => onAccidentTimeChange(e.target.value)}
|
||||
style={{ colorScheme: 'dark' }}
|
||||
onChange={onAccidentTimeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Coordinates + Map Button */}
|
||||
{/* Coordinates (DMS) + Map Button */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
|
||||
<input
|
||||
className="prd-i"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lat ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
|
||||
onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: isNaN(value) ? 0 : value })
|
||||
}}
|
||||
placeholder="위도°"
|
||||
/>
|
||||
<input
|
||||
className="prd-i"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lon ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
|
||||
onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: isNaN(value) ? 0 : value })
|
||||
}}
|
||||
placeholder="경도°"
|
||||
<div className="grid grid-cols-[1fr_auto] gap-x-1 gap-y-1">
|
||||
<DmsCoordInput
|
||||
label="위도"
|
||||
isLatitude={true}
|
||||
decimal={incidentCoord?.lat ?? 0}
|
||||
onChange={(val) => onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: val })}
|
||||
/>
|
||||
<button
|
||||
className={`prd-map-btn${isSelectingLocation ? ' active' : ''}`}
|
||||
onClick={onMapSelectClick}
|
||||
>📍 지도</button>
|
||||
style={{ gridRow: '1 / 3', gridColumn: 2, whiteSpace: 'nowrap', height: '100%', minWidth: 48, padding: '0 10px' }}
|
||||
>📍<br/>지도</button>
|
||||
<DmsCoordInput
|
||||
label="경도"
|
||||
isLatitude={false}
|
||||
decimal={incidentCoord?.lon ?? 0}
|
||||
onChange={(val) => onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: val })}
|
||||
/>
|
||||
</div>
|
||||
{/* 도분초 표시 */}
|
||||
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
|
||||
<div
|
||||
className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
||||
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}
|
||||
>
|
||||
{decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Oil Type + Oil Kind */}
|
||||
@ -384,7 +362,7 @@ const PredictionInputSection = ({
|
||||
|
||||
{/* Model Selection (다중 선택) */}
|
||||
{/* POSEIDON: 엔진 연동 완료. KOSPS: 준비 중 (ready: false) */}
|
||||
<div className="flex flex-wrap gap-[3px]">
|
||||
<div className="grid grid-cols-3 gap-[3px]">
|
||||
{([
|
||||
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)', ready: false },
|
||||
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)', ready: true },
|
||||
@ -392,7 +370,7 @@ const PredictionInputSection = ({
|
||||
] as const).map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''} cursor-pointer`}
|
||||
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''} cursor-pointer text-center`}
|
||||
onClick={() => {
|
||||
if (!m.ready) {
|
||||
alert(`${m.id} 모델은 현재 준비중입니다.`)
|
||||
@ -445,4 +423,294 @@ const PredictionInputSection = ({
|
||||
)
|
||||
}
|
||||
|
||||
// ── 커스텀 날짜/시간 선택 컴포넌트 ─────────────────────
|
||||
function DateTimeInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [showCal, setShowCal] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const datePart = value ? value.split('T')[0] : ''
|
||||
const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00'
|
||||
const [hh, mm] = timePart.split(':').map(Number)
|
||||
|
||||
const parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date()
|
||||
const [viewYear, setViewYear] = useState(parsed.getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed.getMonth())
|
||||
|
||||
const selY = datePart ? parsed.getFullYear() : -1
|
||||
const selM = datePart ? parsed.getMonth() : -1
|
||||
const selD = datePart ? parsed.getDate() : -1
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setShowCal(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
|
||||
const firstDay = new Date(viewYear, viewMonth, 1).getDay()
|
||||
const days: (number | null)[] = []
|
||||
for (let i = 0; i < firstDay; i++) days.push(null)
|
||||
for (let i = 1; i <= daysInMonth; i++) days.push(i)
|
||||
|
||||
const pickDate = (day: number) => {
|
||||
const m = String(viewMonth + 1).padStart(2, '0')
|
||||
const d = String(day).padStart(2, '0')
|
||||
onChange(`${viewYear}-${m}-${d}T${timePart}`)
|
||||
setShowCal(false)
|
||||
}
|
||||
|
||||
const updateTime = (newHH: number, newMM: number) => {
|
||||
const date = datePart || new Date().toISOString().split('T')[0]
|
||||
onChange(`${date}T${String(newHH).padStart(2, '0')}:${String(newMM).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) { setViewYear(viewYear - 1); setViewMonth(11) }
|
||||
else setViewMonth(viewMonth - 1)
|
||||
}
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) { setViewYear(viewYear + 1); setViewMonth(0) }
|
||||
else setViewMonth(viewMonth + 1)
|
||||
}
|
||||
|
||||
const displayDate = datePart
|
||||
? `${selY}.${String(selM + 1).padStart(2, '0')}.${String(selD).padStart(2, '0')}`
|
||||
: '날짜 선택'
|
||||
|
||||
const today = new Date()
|
||||
const todayY = today.getFullYear()
|
||||
const todayM = today.getMonth()
|
||||
const todayD = today.getDate()
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex items-center gap-1 relative">
|
||||
{/* 날짜 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCal(!showCal)}
|
||||
className="prd-i flex-1 flex items-center justify-between cursor-pointer"
|
||||
style={{ padding: '5px 8px', fontSize: 10 }}
|
||||
>
|
||||
<span className="font-mono" style={{ color: datePart ? 'var(--t1)' : 'var(--t3)' }}>{displayDate}</span>
|
||||
<span className="text-[9px] opacity-60">📅</span>
|
||||
</button>
|
||||
|
||||
{/* 시 */}
|
||||
<TimeDropdown value={hh} max={24} onChange={(v) => updateTime(v, mm)} />
|
||||
<span className="text-[8px] text-text-3 font-bold">:</span>
|
||||
{/* 분 */}
|
||||
<TimeDropdown value={mm} max={60} onChange={(v) => updateTime(hh, v)} />
|
||||
|
||||
{/* 캘린더 팝업 */}
|
||||
{showCal && (
|
||||
<div
|
||||
className="absolute z-[9999] rounded-md overflow-hidden"
|
||||
style={{
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: 4,
|
||||
width: 200,
|
||||
background: 'var(--bg3)',
|
||||
border: '1px solid var(--bd)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between" style={{ padding: '6px 8px', borderBottom: '1px solid var(--bd)' }}>
|
||||
<button type="button" onClick={prevMonth} className="text-[10px] text-text-3 cursor-pointer px-1 hover:text-text-1">◀</button>
|
||||
<span className="text-[10px] font-bold text-text-1 font-korean">{viewYear}년 {viewMonth + 1}월</span>
|
||||
<button type="button" onClick={nextMonth} className="text-[10px] text-text-3 cursor-pointer px-1 hover:text-text-1">▶</button>
|
||||
</div>
|
||||
{/* 요일 */}
|
||||
<div className="grid grid-cols-7 text-center" style={{ padding: '3px 4px 0' }}>
|
||||
{['일', '월', '화', '수', '목', '금', '토'].map((d) => (
|
||||
<span key={d} className="text-[8px] text-text-3 font-korean" style={{ padding: '2px 0' }}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
{/* 날짜 */}
|
||||
<div className="grid grid-cols-7 text-center" style={{ padding: '2px 4px 6px' }}>
|
||||
{days.map((day, i) => {
|
||||
if (day === null) return <span key={`e-${i}`} />
|
||||
const isSelected = viewYear === selY && viewMonth === selM && day === selD
|
||||
const isToday = viewYear === todayY && viewMonth === todayM && day === todayD
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => pickDate(day)}
|
||||
className="cursor-pointer rounded-sm"
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
fontSize: 9,
|
||||
fontFamily: 'var(--fM)',
|
||||
fontWeight: isSelected ? 700 : 400,
|
||||
color: isSelected ? '#fff' : isToday ? 'var(--cyan)' : 'var(--t2)',
|
||||
background: isSelected ? 'var(--cyan)' : 'transparent',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* 오늘 버튼 */}
|
||||
<div style={{ padding: '0 8px 6px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewYear(todayY)
|
||||
setViewMonth(todayM)
|
||||
pickDate(todayD)
|
||||
}}
|
||||
className="w-full text-[8px] font-korean font-semibold cursor-pointer rounded-sm"
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
background: 'rgba(6,182,212,0.08)',
|
||||
border: '1px solid rgba(6,182,212,0.2)',
|
||||
color: 'var(--cyan)',
|
||||
}}
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 커스텀 시간 드롭다운 (다크 테마) ───────────────────
|
||||
function TimeDropdown({ value, max, onChange }: { value: number; max: number; onChange: (v: number) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && listRef.current) {
|
||||
const activeEl = listRef.current.querySelector('[data-active="true"]')
|
||||
if (activeEl) activeEl.scrollIntoView({ block: 'center' })
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={dropRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="prd-i text-center font-mono cursor-pointer"
|
||||
style={{ width: 38, padding: '5px 2px', fontSize: 9 }}
|
||||
>
|
||||
{String(value).padStart(2, '0')}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-[9999] overflow-y-auto rounded-md"
|
||||
style={{
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginTop: 2,
|
||||
width: 42,
|
||||
maxHeight: 160,
|
||||
background: 'var(--bg3)',
|
||||
border: '1px solid var(--bd)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'var(--bd) transparent',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: max }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
data-active={i === value}
|
||||
onClick={() => { onChange(i); setOpen(false) }}
|
||||
className="w-full text-center font-mono cursor-pointer"
|
||||
style={{
|
||||
padding: '4px 0',
|
||||
fontSize: 9,
|
||||
color: i === value ? 'var(--cyan)' : 'var(--t2)',
|
||||
background: i === value ? 'rgba(6,182,212,0.15)' : 'transparent',
|
||||
fontWeight: i === value ? 700 : 400,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{String(i).padStart(2, '0')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 도분초 좌표 입력 컴포넌트 ──────────────────────────
|
||||
function DmsCoordInput({
|
||||
label,
|
||||
isLatitude,
|
||||
decimal,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
isLatitude: boolean
|
||||
decimal: number
|
||||
onChange: (val: number) => void
|
||||
}) {
|
||||
const abs = Math.abs(decimal)
|
||||
const d = Math.floor(abs)
|
||||
const mDec = (abs - d) * 60
|
||||
const m = Math.floor(mDec)
|
||||
const s = parseFloat(((mDec - m) * 60).toFixed(2))
|
||||
const dir = isLatitude ? (decimal >= 0 ? 'N' : 'S') : (decimal >= 0 ? 'E' : 'W')
|
||||
|
||||
const update = (deg: number, min: number, sec: number, direction: string) => {
|
||||
let val = deg + min / 60 + sec / 3600
|
||||
if (direction === 'S' || direction === 'W') val = -val
|
||||
onChange(val)
|
||||
}
|
||||
|
||||
const fieldStyle = { padding: '5px 2px', fontSize: 10, minWidth: 0 }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[8px] text-text-3 font-korean">{label}</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<select
|
||||
className="prd-i text-center"
|
||||
value={dir}
|
||||
onChange={(e) => update(d, m, s, e.target.value)}
|
||||
style={{ width: 32, padding: '5px 1px', fontSize: 10, appearance: 'none', WebkitAppearance: 'none', backgroundImage: 'none' }}
|
||||
>
|
||||
{isLatitude ? (
|
||||
<><option value="N">N</option><option value="S">S</option></>
|
||||
) : (
|
||||
<><option value="E">E</option><option value="W">W</option></>
|
||||
)}
|
||||
</select>
|
||||
<input className="prd-i text-center flex-1" type="number" min={0} max={isLatitude ? 90 : 180}
|
||||
value={d} onChange={(e) => update(parseInt(e.target.value) || 0, m, s, dir)} style={fieldStyle} />
|
||||
<span className="text-[9px] text-text-3">°</span>
|
||||
<input className="prd-i text-center flex-1" type="number" min={0} max={59}
|
||||
value={m} onChange={(e) => update(d, parseInt(e.target.value) || 0, s, dir)} style={fieldStyle} />
|
||||
<span className="text-[9px] text-text-3">'</span>
|
||||
<input className="prd-i text-center flex-1" type="number" min={0} max={59.99} step={0.01}
|
||||
value={s} onChange={(e) => update(d, m, parseFloat(e.target.value) || 0, dir)} style={fieldStyle} />
|
||||
<span className="text-[9px] text-text-3">"</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PredictionInputSection
|
||||
|
||||
@ -457,13 +457,13 @@ export function WeatherView() {
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="absolute bottom-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm z-10">
|
||||
<div className="text-sm font-semibold text-text-1 mb-3">기상 범례</div>
|
||||
<div className="space-y-3 text-xs">
|
||||
{/* 바람 (Windy 스타일) */}
|
||||
<div className="absolute bottom-4 left-4 bg-bg-1/85 border border-border rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px', maxWidth: 180 }}>
|
||||
<div className="text-[9px] font-semibold text-text-1 mb-1.5 font-korean">기상 범례</div>
|
||||
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
|
||||
{/* 바람 */}
|
||||
<div>
|
||||
<div className="font-semibold text-text-2 mb-1">바람 (m/s)</div>
|
||||
<div className="flex items-center gap-1 h-3 rounded-sm overflow-hidden mb-1">
|
||||
<div className="font-semibold text-text-2 mb-0.5" style={{ fontSize: 8 }}>바람 (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' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#50d591' }} />
|
||||
@ -473,53 +473,38 @@ 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-text-3 text-[9px]">
|
||||
<span>3</span>
|
||||
<span>5</span>
|
||||
<span>7</span>
|
||||
<span>10</span>
|
||||
<span>13</span>
|
||||
<span>16</span>
|
||||
<span>20+</span>
|
||||
<div className="flex justify-between text-text-3" style={{ fontSize: 7 }}>
|
||||
<span>3</span><span>5</span><span>7</span><span>10</span><span>13</span><span>16</span><span>20+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 해류 */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-1">해류 (m/s)</div>
|
||||
<div className="flex items-center gap-1 h-3 rounded-sm overflow-hidden mb-1">
|
||||
<div className="pt-1 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-0.5" style={{ fontSize: 8 }}>해류 (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-text-3 text-[9px]">
|
||||
<span>0.2</span>
|
||||
<span>0.4</span>
|
||||
<span>0.6</span>
|
||||
<span>0.6+</span>
|
||||
<div className="flex justify-between text-text-3" style={{ fontSize: 7 }}>
|
||||
<span>0.2</span><span>0.4</span><span>0.6</span><span>0.6+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파고 */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-1">파고 (m)</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
<span className="text-text-3">< 1.5: 낮음</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500" />
|
||||
<span className="text-text-3">1.5-2.5: 보통</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<span className="text-text-3">> 2.5: 높음</span>
|
||||
<div className="pt-1 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-0.5" style={{ fontSize: 8 }}>파고 (m)</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-text-3"><1.5 낮음</span>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 ml-1" />
|
||||
<span className="text-text-3">~2.5</span>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 ml-1" />
|
||||
<span className="text-text-3">>2.5</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-border text-xs text-text-3">
|
||||
💡 지도를 클릭하여 해당 지점의 기상 예보를 확인하세요
|
||||
<div className="mt-1 pt-1 border-t border-border text-text-3 font-korean" style={{ fontSize: 7 }}>
|
||||
💡 지도 클릭 → 기상 예보 확인
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user