334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import { useState } from 'react';
|
|
import { DndContext } from '@dnd-kit/core';
|
|
import { useDraggable } from '@dnd-kit/core';
|
|
import type { DragEndEvent } from '@dnd-kit/core';
|
|
|
|
/**
|
|
* 해양환경관리법 제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, Status]; // [~3NM, 3~12NM, 12~25NM, 25~50NM, 50NM+]
|
|
condition?: string;
|
|
}
|
|
|
|
const RULES: DischargeRule[] = [
|
|
// 분뇨
|
|
{
|
|
category: '분뇨',
|
|
item: '분뇨마쇄소독장치',
|
|
zones: ['forbidden', 'conditional', 'conditional', 'conditional', 'conditional'],
|
|
condition: '항속 4노트 이상시 서서히 배출 / 400톤 미만 국내항해 선박은 3해리 이내 가능',
|
|
},
|
|
{
|
|
category: '분뇨',
|
|
item: '분뇨저장탱크',
|
|
zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'],
|
|
condition: '항속 4노트 이상시 서서히 배출',
|
|
},
|
|
{
|
|
category: '분뇨',
|
|
item: '분뇨처리장치',
|
|
zones: ['allowed', 'allowed', 'allowed', 'allowed', 'allowed'],
|
|
condition: '수산자원보호구역, 보호수면 및 육성수면은 불가',
|
|
},
|
|
// 음식물찌꺼기
|
|
{
|
|
category: '음식물찌꺼기',
|
|
item: '미분쇄 음식물',
|
|
zones: ['forbidden', 'forbidden', 'allowed', 'allowed', 'allowed'],
|
|
},
|
|
{
|
|
category: '음식물찌꺼기',
|
|
item: '분쇄·연마 음식물 (25mm 이하)',
|
|
zones: ['forbidden', 'conditional', 'allowed', 'allowed', 'allowed'],
|
|
condition: '25mm 이하 개구 스크린 통과 가능시',
|
|
},
|
|
// 화물잔류물
|
|
{
|
|
category: '화물잔류물',
|
|
item: '부유성 화물잔류물',
|
|
zones: ['forbidden', 'forbidden', 'forbidden', 'allowed', 'allowed'],
|
|
},
|
|
{
|
|
category: '화물잔류물',
|
|
item: '침강성 화물잔류물',
|
|
zones: ['forbidden', 'forbidden', 'allowed', 'allowed', 'allowed'],
|
|
},
|
|
{
|
|
category: '화물잔류물',
|
|
item: '화물창 세정수',
|
|
zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'],
|
|
condition: '해양환경에 해롭지 않은 일반세제 사용시',
|
|
},
|
|
// 화물유
|
|
{
|
|
category: '화물유',
|
|
item: '화물유 섞인 평형수·세정수·선저폐수',
|
|
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'conditional'],
|
|
condition: '항해 중, 순간배출률 1해리당 30L 이하, 기름오염방지설비 작동 중',
|
|
},
|
|
// 유해액체물질
|
|
{
|
|
category: '유해액체물질',
|
|
item: '유해액체물질 섞인 세정수',
|
|
zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'],
|
|
condition: '자항선 7노트/비자항선 4노트 이상, 수심 25m 이상, 수면하 배출구 사용',
|
|
},
|
|
// 폐기물
|
|
{
|
|
category: '폐기물',
|
|
item: '플라스틱 제품',
|
|
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
|
},
|
|
{
|
|
category: '폐기물',
|
|
item: '포장유해물질·용기',
|
|
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
|
},
|
|
{
|
|
category: '폐기물',
|
|
item: '중금속 포함 쓰레기',
|
|
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
|
},
|
|
];
|
|
|
|
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25~50해리', '50해리+'];
|
|
const ZONE_COLORS = [
|
|
'var(--color-danger)',
|
|
'var(--color-warning)',
|
|
'var(--color-caution)',
|
|
'var(--color-success)',
|
|
'var(--fg-disabled)',
|
|
];
|
|
|
|
function StatusBadge({ status }: { status: Status }) {
|
|
if (status === 'forbidden')
|
|
return (
|
|
<span
|
|
className="text-caption px-1.5 py-0.5 rounded"
|
|
style={{
|
|
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
|
|
color: 'var(--color-danger)',
|
|
}}
|
|
>
|
|
배출불가
|
|
</span>
|
|
);
|
|
return (
|
|
<span className="text-caption px-1.5 py-0.5 rounded text-fg-sub">
|
|
{status === 'allowed' ? '배출가능' : '조건부'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
interface DischargeZonePanelProps {
|
|
lat: number;
|
|
lon: number;
|
|
distanceNm: number;
|
|
zoneIndex: number;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function DischargeZonePanel(props: DischargeZonePanelProps) {
|
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
|
|
|
function handleDragEnd(event: DragEndEvent) {
|
|
setOffset((prev) => ({ x: prev.x + event.delta.x, y: prev.y + event.delta.y }));
|
|
}
|
|
|
|
return (
|
|
<DndContext onDragEnd={handleDragEnd}>
|
|
<DraggablePanel {...props} offset={offset} />
|
|
</DndContext>
|
|
);
|
|
}
|
|
|
|
function DraggablePanel({
|
|
lat,
|
|
lon,
|
|
distanceNm,
|
|
zoneIndex,
|
|
onClose,
|
|
offset,
|
|
}: DischargeZonePanelProps & { offset: { x: number; y: number } }) {
|
|
const zoneIdx = zoneIndex;
|
|
const [expandedCat, setExpandedCat] = useState<string | null>(null);
|
|
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
|
id: 'discharge-panel',
|
|
});
|
|
|
|
const tx = offset.x + (transform?.x ?? 0);
|
|
const ty = offset.y + (transform?.y ?? 0);
|
|
|
|
const categories = [...new Set(RULES.map((r) => r.category))];
|
|
|
|
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: 'var(--bg-base)',
|
|
border: '1px solid var(--stroke-default)',
|
|
boxShadow: '0 16px 48px rgba(0,0,0,0.5)',
|
|
backdropFilter: 'blur(12px)',
|
|
transform: `translate(${tx}px, ${ty}px)`,
|
|
}}
|
|
>
|
|
{/* Header — drag handle */}
|
|
<div
|
|
ref={setNodeRef}
|
|
{...listeners}
|
|
{...attributes}
|
|
className="shrink-0 flex items-center justify-between"
|
|
style={{
|
|
padding: '10px 14px',
|
|
borderBottom: '1px solid var(--stroke-default)',
|
|
background: 'var(--bg-elevated)',
|
|
cursor: 'grab',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
<div>
|
|
<div className="text-label-2 text-fg font-korean">🚢 오염물 배출 규정</div>
|
|
<div className="text-caption text-fg-sub font-korean">해양환경관리법 제22조</div>
|
|
</div>
|
|
<span
|
|
onClick={onClose}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
className="text-title-3 cursor-pointer text-fg-sub hover:text-fg"
|
|
style={{ pointerEvents: 'all' }}
|
|
>
|
|
✕
|
|
</span>
|
|
</div>
|
|
|
|
{/* Location Info */}
|
|
<div
|
|
className="shrink-0"
|
|
style={{ padding: '8px 14px', borderBottom: '1px solid var(--stroke-light)' }}
|
|
>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-caption text-fg-sub font-korean">선택 위치</span>
|
|
<span className="text-caption text-fg font-mono">
|
|
{lat.toFixed(4)}°N, {lon.toFixed(4)}°E
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-caption text-fg-sub font-korean">영해기선 거리</span>
|
|
<span className="text-label-2 font-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 text-[10px]"
|
|
style={{
|
|
padding: '2px 0',
|
|
color: i === zoneIdx ? '#000' : 'var(--fg-sub)',
|
|
background: i === zoneIdx ? ZONE_COLORS[i] : 'var(--hover-overlay)',
|
|
border: i === zoneIdx ? 'none' : '1px solid var(--stroke-light)',
|
|
}}
|
|
>
|
|
{label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rules */}
|
|
<div
|
|
className="flex-1 overflow-y-auto"
|
|
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-default) 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 summaryColor = allForbidden ? 'var(--color-danger)' : 'var(--fg-sub)';
|
|
|
|
return (
|
|
<div key={cat} style={{ borderBottom: '1px solid var(--stroke-light)' }}>
|
|
<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-caption text-fg font-korean">{cat}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-caption" style={{ color: summaryColor }}>
|
|
{allForbidden ? '전체 불가' : '허용'}
|
|
</span>
|
|
<span className="text-caption text-fg-sub">{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: 'var(--hover-overlay)',
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
<span className="text-caption text-fg-sub 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-caption text-fg-sub 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 var(--stroke-light)' }}
|
|
>
|
|
<div className="text-caption text-fg-sub font-korean leading-relaxed">
|
|
※ 거리는 영해기선 폴리곤 기준입니다. 구역은 버퍼 폴리곤 포함 여부로 판별됩니다.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|