From 9384290bf32a7764b0eedb46ac6e3218f07aee13 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 1 Mar 2026 10:01:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=ED=99=95=EC=82=B0=20?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20SSW=20=EC=88=98=EC=A0=95=20+=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=EC=A1=B0=ED=9A=8C=20=ED=98=B8=EB=B2=84=20=ED=88=B4?= =?UTF-8?q?=ED=8C=81=20+=20=EC=84=A0=EB=B0=95=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 확산분석: 오일 확산 방향 NE→SSW(200°)로 수정, 민감자원 여수 실제 좌표 적용 - 해류 화살표: 아이콘 ➤, 크기 22px, 투명도 증가, SSW 방향 동기화 - 통합조회: 선박/사고 마커 hover 시 다크 테마 툴팁 표시 (이름, 유형, 속도, 좌표) - 선박 아이콘: SVG 삼각형 16×20 확대 + 글로우 효과 + pickable 전환 - vesselLayer(ScatterplotLayer 원형) 제거, vesselIconLayer로 통합 Co-Authored-By: Claude Opus 4.6 --- .../src/common/components/map/MapView.tsx | 10 +- .../incidents/components/IncidentsView.tsx | 173 +++++++++++++----- .../prediction/components/OilSpillView.tsx | 16 +- 3 files changed, 139 insertions(+), 60 deletions(-) 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) {