@@ -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 (
diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css
index 373fe49..7593fa5 100644
--- a/frontend/src/common/styles/components.css
+++ b/frontend/src/common/styles/components.css
@@ -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;
}
diff --git a/frontend/src/tabs/incidents/components/DischargeZonePanel.tsx b/frontend/src/tabs/incidents/components/DischargeZonePanel.tsx
new file mode 100644
index 0000000..34d1ea6
--- /dev/null
+++ b/frontend/src/tabs/incidents/components/DischargeZonePanel.tsx
@@ -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
배출불가
+ if (status === 'allowed') return
배출가능
+ return
조건부
+}
+
+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
(null)
+
+ const categories = [...new Set(RULES.map(r => r.category))]
+
+ return (
+
+ {/* Header */}
+
+
+
🚢 오염물 배출 규정
+
해양환경관리법 제22조
+
+
✕
+
+
+ {/* Location Info */}
+
+
+ 선택 위치
+ {lat.toFixed(4)}°N, {lon.toFixed(4)}°E
+
+
+ 영해기선 거리 (추정)
+
+ {distanceNm.toFixed(1)} NM
+
+
+ {/* Zone indicator */}
+
+ {ZONE_LABELS.map((label, i) => (
+
+ {label}
+
+ ))}
+
+
+
+ {/* Rules */}
+
+ {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 (
+
+
setExpandedCat(isExpanded ? null : cat)}
+ style={{ padding: '8px 14px' }}
+ >
+
+
+
+ {allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'}
+
+ {isExpanded ? '▾' : '▸'}
+
+
+
+ {isExpanded && (
+
+ {catRules.map((rule, i) => (
+
+ {rule.item}
+
+
+ ))}
+ {catRules.some(r => r.condition && r.zones[zoneIdx] !== 'forbidden') && (
+
+ {catRules.filter(r => r.condition && r.zones[zoneIdx] !== 'forbidden').map((r, i) => (
+
+ 💡 {r.item}: {r.condition}
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+ })}
+
+
+ {/* Footer */}
+
+
+ ※ 거리는 최근접 해안선 기준 추정치입니다. 실제 영해기선과 차이가 있을 수 있습니다.
+
+
+
+ )
+}
diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx
index 29c41b2..77d6787 100755
--- a/frontend/src/tabs/incidents/components/IncidentsView.tsx
+++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx
@@ -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: '© OpenStreetMap',
},
},
- 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(null)
const [hoverInfo, setHoverInfo] = useState(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('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() {
+
+ {/* 오염물 배출 규정 패널 */}
+ {dischargeMode && dischargeInfo && (
+