- prediction: 커스텀 다크 캘린더/시간 드롭다운, DMS 좌표 입력, 모델 버튼 3열 배치 - incidents: 밝은 지도 테마, 해양환경관리법 제22조 기반 오염물 배출규정 기능 - 지도 클릭시 영해기선 거리별 배출 가능 여부 표시 (OSM 실측 좌표 기반) - 3해리/12해리/25해리 경계선 표시 - weather: 기상 범례 사이즈 축소 + 폰트 축소 - map: 풍속/파고/수온/해류 패널 축소·투명화, 확대/축소 버튼 축소, 좌표 중앙 배치 - map: 범례 기본 접힌 상태 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
203 lines
9.8 KiB
TypeScript
203 lines
9.8 KiB
TypeScript
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>
|
|
)
|
|
}
|