diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index fb3bd80..40c8a75 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -1376,23 +1376,23 @@ function MapControls({ center, zoom }: { center: [number, number]; zoom: number const { current: map } = useMap() return ( -
-
+
+
@@ -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' }} + > +
+
+ {cat} +
+
+ + {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() { { + 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} > @@ -428,6 +464,57 @@ export function IncidentsView() {
)} + {/* 오염물 배출 규정 토글 */} + + + {/* 오염물 배출 규정 패널 */} + {dischargeMode && dischargeInfo && ( + setDischargeInfo(null)} + /> + )} + + {/* 배출규정 모드 안내 */} + {dischargeMode && !dischargeInfo && ( +
+ 📍 지도를 클릭하여 배출 규정을 확인하세요 +
+ )} + {/* AIS Live Badge */}
[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 +} diff --git a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx index c8d7a57..01a25fd 100644 --- a/frontend/src/tabs/prediction/components/PredictionInputSection.tsx +++ b/frontend/src/tabs/prediction/components/PredictionInputSection.tsx @@ -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 = ({ {/* 사고 발생 시각 */}
- onAccidentTimeChange(e.target.value)} - style={{ colorScheme: 'dark' }} + onChange={onAccidentTimeChange} />
- {/* Coordinates + Map Button */} + {/* Coordinates (DMS) + Map Button */}
-
- { - const value = e.target.value === '' ? 0 : parseFloat(e.target.value) - onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: isNaN(value) ? 0 : value }) - }} - placeholder="위도°" - /> - { - const value = e.target.value === '' ? 0 : parseFloat(e.target.value) - onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: isNaN(value) ? 0 : value }) - }} - placeholder="경도°" +
+ onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: val })} /> + style={{ gridRow: '1 / 3', gridColumn: 2, whiteSpace: 'nowrap', height: '100%', minWidth: 48, padding: '0 10px' }} + >📍
지도 + onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: val })} + />
- {/* 도분초 표시 */} - {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && ( -
- {decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)} -
- )}
{/* Oil Type + Oil Kind */} @@ -384,7 +362,7 @@ const PredictionInputSection = ({ {/* Model Selection (다중 선택) */} {/* POSEIDON: 엔진 연동 완료. KOSPS: 준비 중 (ready: false) */} -
+
{([ { 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 => (
{ 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(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 ( +
+ {/* 날짜 버튼 */} + + + {/* 시 */} + updateTime(v, mm)} /> + : + {/* 분 */} + updateTime(hh, v)} /> + + {/* 캘린더 팝업 */} + {showCal && ( +
+ {/* 헤더 */} +
+ + {viewYear}년 {viewMonth + 1}월 + +
+ {/* 요일 */} +
+ {['일', '월', '화', '수', '목', '금', '토'].map((d) => ( + {d} + ))} +
+ {/* 날짜 */} +
+ {days.map((day, i) => { + if (day === null) return + const isSelected = viewYear === selY && viewMonth === selM && day === selD + const isToday = viewYear === todayY && viewMonth === todayM && day === todayD + return ( + + ) + })} +
+ {/* 오늘 버튼 */} +
+ +
+
+ )} +
+ ) +} + +// ── 커스텀 시간 드롭다운 (다크 테마) ─────────────────── +function TimeDropdown({ value, max, onChange }: { value: number; max: number; onChange: (v: number) => void }) { + const [open, setOpen] = useState(false) + const dropRef = useRef(null) + const listRef = useRef(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 ( +
+ + {open && ( +
+ {Array.from({ length: max }, (_, i) => ( + + ))} +
+ )} +
+ ) +} + +// ── 도분초 좌표 입력 컴포넌트 ────────────────────────── +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 ( +
+ {label} +
+ + update(parseInt(e.target.value) || 0, m, s, dir)} style={fieldStyle} /> + ° + update(d, parseInt(e.target.value) || 0, s, dir)} style={fieldStyle} /> + ' + update(d, m, parseFloat(e.target.value) || 0, dir)} style={fieldStyle} /> + " +
+
+ ) +} + export default PredictionInputSection diff --git a/frontend/src/tabs/weather/components/WeatherView.tsx b/frontend/src/tabs/weather/components/WeatherView.tsx index 35414db..3619fe2 100755 --- a/frontend/src/tabs/weather/components/WeatherView.tsx +++ b/frontend/src/tabs/weather/components/WeatherView.tsx @@ -457,13 +457,13 @@ export function WeatherView() {
{/* 범례 */} -
-
기상 범례
-
- {/* 바람 (Windy 스타일) */} +
+
기상 범례
+
+ {/* 바람 */}
-
바람 (m/s)
-
+
바람 (m/s)
+
@@ -473,53 +473,38 @@ export function WeatherView() {
-
- 3 - 5 - 7 - 10 - 13 - 16 - 20+ +
+ 35710131620+
- {/* 해류 */} -
-
해류 (m/s)
-
+
+
해류 (m/s)
+
-
- 0.2 - 0.4 - 0.6 - 0.6+ +
+ 0.20.40.60.6+
- {/* 파고 */} -
-
파고 (m)
-
-
- < 1.5: 낮음 -
-
-
- 1.5-2.5: 보통 -
-
-
- > 2.5: 높음 +
+
파고 (m)
+
+
+ <1.5 낮음 +
+ ~2.5 +
+ >2.5
-
- 💡 지도를 클릭하여 해당 지점의 기상 예보를 확인하세요 +
+ 💡 지도 클릭 → 기상 예보 확인