diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 03914e8..9c5d951 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -497,7 +497,7 @@ export function MapView({ const currentArrows: Array<{ lon: number; lat: number; bearing: number; speed: number }> = [] const gridSize = 5 const spacing = 0.04 // 약 4km 간격 - const mainBearing = 42 // NE 방향 (도) + const mainBearing = 200 // SSW 방향 (도) for (let row = -gridSize; row <= gridSize; row++) { for (let col = -gridSize; col <= gridSize; col++) { @@ -516,10 +516,10 @@ export function MapView({ id: 'current-arrows', data: currentArrows, getPosition: (d: (typeof currentArrows)[0]) => [d.lon, d.lat], - getText: () => '→', - getAngle: (d: (typeof currentArrows)[0]) => -d.bearing, - getSize: 14, - getColor: [6, 182, 212, 70], + getText: () => '➤', + getAngle: (d: (typeof currentArrows)[0]) => -d.bearing + 90, + getSize: 22, + getColor: [6, 182, 212, 100], characterSet: 'auto', sizeUnits: 'pixels' as const, billboard: true, diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx index f5c4892..e4444e1 100755 --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -9,7 +9,6 @@ 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 { hexToRgba } from '@common/components/map/mapUtils' // ── CartoDB Dark Matter 베이스맵 ──────────────────────── const BASE_STYLE: StyleSpecification = { @@ -71,6 +70,14 @@ interface IncidentPopupInfo { incident: IncidentCompat } +// 호버 툴팁 정보 +interface HoverInfo { + x: number + y: number + object: Vessel | IncidentCompat + type: 'vessel' | 'incident' +} + /* ════════════════════════════════════════════════════ IncidentsView ════════════════════════════════════════════════════ */ @@ -81,6 +88,7 @@ export function IncidentsView() { const [detailVessel, setDetailVessel] = useState(null) const [vesselPopup, setVesselPopup] = useState(null) const [incidentPopup, setIncidentPopup] = useState(null) + const [hoverInfo, setHoverInfo] = useState(null) // Analysis view mode const [viewMode, setViewMode] = useState('overlay') @@ -144,6 +152,13 @@ export function IncidentsView() { setVesselPopup(null) } }, + onHover: (info: { object?: IncidentCompat; x?: number; y?: number }) => { + if (info.object && info.x !== undefined && info.y !== undefined) { + setHoverInfo({ x: info.x, y: info.y, object: info.object, type: 'incident' }) + } else { + setHoverInfo(h => (h?.type === 'incident' ? null : h)) + } + }, updateTriggers: { getRadius: [selectedIncidentId], getLineColor: [selectedIncidentId], @@ -153,47 +168,20 @@ export function IncidentsView() { [incidents, selectedIncidentId], ) - // ── 선박 마커: ScatterplotLayer (원) ───────────────── - const vesselLayer = useMemo( - () => - new ScatterplotLayer({ - id: 'vessels', - data: mockVessels, - getPosition: (d: Vessel) => [d.lng, d.lat], - getRadius: 5, - getFillColor: (d: Vessel) => hexToRgba(d.color, d.status.includes('사고') ? 255 : 200), - getLineColor: (d: Vessel) => hexToRgba(d.color, 255), - getLineWidth: 1, - stroked: true, - radiusMinPixels: 4, - radiusMaxPixels: 8, - radiusUnits: 'pixels', - pickable: true, - onClick: (info: { object?: Vessel; coordinate?: number[] }) => { - if (info.object && info.coordinate) { - setSelectedVessel(info.object) - setVesselPopup({ - longitude: info.coordinate[0], - latitude: info.coordinate[1], - vessel: info.object, - }) - setIncidentPopup(null) - setDetailVessel(null) - } - }, - }), - [], - ) - // ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ────── - // IconLayer는 atlas 이미지가 필요하여, 대신 HTML overlay로 선박 방향 표현 - // 실제 지도 위 선박 방향은 ScatterplotLayer + 별도 SVG 오버레이로 처리 가능하나 - // deck.gl에서 가장 간단한 방법은 커스텀 SVG를 data URL로 활용 + // 글로우 효과 + 드롭쉐도우가 있는 개선된 SVG 삼각형 const vesselIconLayer = useMemo(() => { const makeTriangleSvg = (color: string, isAccident: boolean) => { - const svgStr = ` - - ` + const opacity = isAccident ? '1' : '0.85' + const glowOpacity = isAccident ? '0.9' : '0.75' + const svgStr = [ + '', + '', + '', + ``, + ``, + '', + ].join('') return `data:image/svg+xml;base64,${btoa(svgStr)}` } @@ -203,23 +191,42 @@ export function IncidentsView() { getPosition: (d: Vessel) => [d.lng, d.lat], getIcon: (d: Vessel) => ({ url: makeTriangleSvg(d.color, d.status.includes('사고')), - width: 10, - height: 12, - anchorX: 5, - anchorY: 6, + width: 16, + height: 20, + anchorX: 8, + anchorY: 10, }), - getSize: 12, + getSize: 16, getAngle: (d: Vessel) => -d.heading, sizeUnits: 'pixels', sizeScale: 1, - pickable: false, + pickable: true, + onClick: (info: { object?: Vessel; coordinate?: number[] }) => { + if (info.object && info.coordinate) { + setSelectedVessel(info.object) + setVesselPopup({ + longitude: info.coordinate[0], + latitude: info.coordinate[1], + vessel: info.object, + }) + setIncidentPopup(null) + setDetailVessel(null) + } + }, + onHover: (info: { object?: Vessel; x?: number; y?: number }) => { + if (info.object && info.x !== undefined && info.y !== undefined) { + setHoverInfo({ x: info.x, y: info.y, object: info.object, type: 'vessel' }) + } else { + setHoverInfo(h => (h?.type === 'vessel' ? null : h)) + } + }, }) }, []) // eslint-disable-next-line @typescript-eslint/no-explicit-any const deckLayers: any[] = useMemo( - () => [incidentLayer, vesselIconLayer, vesselLayer], - [incidentLayer, vesselIconLayer, vesselLayer], + () => [incidentLayer, vesselIconLayer], + [incidentLayer, vesselIconLayer], ) return ( @@ -368,6 +375,32 @@ export function IncidentsView() { )} + {/* 호버 툴팁 */} + {hoverInfo && ( +
+ {hoverInfo.type === 'vessel' ? ( + + ) : ( + + )} +
+ )} + {/* 분석 오버레이 (지도 위 시각효과) */} {analysisActive && viewMode === 'overlay' && (
) } + +/* ════════════════════════════════════════════════════ + 호버 툴팁 컴포넌트 + ════════════════════════════════════════════════════ */ +function VesselTooltipContent({ vessel: v }: { vessel: Vessel }) { + return ( + <> +
{v.name}
+
+ {v.typS} · {v.flag} +
+
+ {v.speed} kn + HDG {v.heading}° +
+ + ) +} + +function IncidentTooltipContent({ incident: i }: { incident: IncidentCompat }) { + const statusColor = + i.status === 'active' ? '#ef4444' : i.status === 'investigating' ? '#f59e0b' : '#6b7280' + + return ( + <> +
{i.name}
+
+ {i.date} {i.time} +
+
+ + {getStatusLabel(i.status)} + + + {i.location.lat.toFixed(3)}°N, {i.location.lon.toFixed(3)}°E + +
+ + ) +} diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index d531d47..5d58530 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -33,11 +33,11 @@ export interface SensitiveResource { } const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [ - { id: 'aq-1', name: '여수 돌산 양식장', type: 'aquaculture', lat: 34.755, lon: 127.735, radiusM: 800, arrivalTimeH: 3 }, - { id: 'bc-1', name: '만성리 해수욕장', type: 'beach', lat: 34.765, lon: 127.765, radiusM: 400, arrivalTimeH: 6 }, - { id: 'ec-1', name: '오동도 생태보호구역', type: 'ecology', lat: 34.745, lon: 127.78, radiusM: 600, arrivalTimeH: 12 }, - { id: 'aq-2', name: '금오도 전복 양식장', type: 'aquaculture', lat: 34.70, lon: 127.75, radiusM: 700, arrivalTimeH: 8 }, - { id: 'bc-2', name: '방죽포 해수욕장', type: 'beach', lat: 34.72, lon: 127.81, radiusM: 350, arrivalTimeH: 10 }, + { id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 }, + { id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 }, + { id: 'ec-1', name: '여자만 습지보호구역', type: 'ecology', lat: 34.758, lon: 127.614, radiusM: 1200, arrivalTimeH: 6 }, + { id: 'aq-2', name: '화태도 김 양식장', type: 'aquaculture', lat: 34.648, lon: 127.652, radiusM: 800, arrivalTimeH: 10 }, + { id: 'aq-3', name: '개도 해안 양식장', type: 'aquaculture', lat: 34.612, lon: 127.636, radiusM: 600, arrivalTimeH: 18 }, ] // --------------------------------------------------------------------------- @@ -64,9 +64,9 @@ function generateDemoTrajectory( const TIME_STEP = 3 // hours const modelParams: Record = { - KOSPS: { bearing: 42, speed: 0.003, spread: 0.008, seed: 42 }, - POSEIDON: { bearing: 55, speed: 0.0025, spread: 0.01, seed: 137 }, - OpenDrift: { bearing: 35, speed: 0.0035, spread: 0.006, seed: 271 }, + KOSPS: { bearing: 200, speed: 0.003, spread: 0.008, seed: 42 }, + POSEIDON: { bearing: 210, speed: 0.0025, spread: 0.01, seed: 137 }, + OpenDrift: { bearing: 190, speed: 0.0035, spread: 0.006, seed: 271 }, } for (const model of models) {