develop #56
@ -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,
|
||||
|
||||
@ -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<Vessel | null>(null)
|
||||
const [vesselPopup, setVesselPopup] = useState<VesselPopupInfo | null>(null)
|
||||
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null)
|
||||
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null)
|
||||
|
||||
// Analysis view mode
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('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 = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="12" viewBox="0 0 10 12">
|
||||
<polygon points="5,0 10,12 0,12" fill="${color}" opacity="${isAccident ? '1' : '0.85'}"/>
|
||||
</svg>`
|
||||
const opacity = isAccident ? '1' : '0.85'
|
||||
const glowOpacity = isAccident ? '0.9' : '0.75'
|
||||
const svgStr = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="20" viewBox="0 0 16 20">',
|
||||
'<defs><filter id="g" x="-50%" y="-50%" width="200%" height="200%">',
|
||||
'<feGaussianBlur stdDeviation="1.2"/></filter></defs>',
|
||||
`<polygon points="8,0 15,20 1,20" fill="${color}" opacity="${glowOpacity}" filter="url(#g)"/>`,
|
||||
`<polygon points="8,1 14,19 2,19" fill="${color}" opacity="${opacity}" stroke="${color}" stroke-width="0.5"/>`,
|
||||
'</svg>',
|
||||
].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() {
|
||||
)}
|
||||
</Map>
|
||||
|
||||
{/* 호버 툴팁 */}
|
||||
{hoverInfo && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: hoverInfo.x + 12,
|
||||
top: hoverInfo.y - 12,
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
fontFamily: 'var(--fK)',
|
||||
minWidth: 150,
|
||||
}}
|
||||
>
|
||||
{hoverInfo.type === 'vessel' ? (
|
||||
<VesselTooltipContent vessel={hoverInfo.object as Vessel} />
|
||||
) : (
|
||||
<IncidentTooltipContent incident={hoverInfo.object as IncidentCompat} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분석 오버레이 (지도 위 시각효과) */}
|
||||
{analysisActive && viewMode === 'overlay' && (
|
||||
<div
|
||||
@ -1942,3 +1975,49 @@ function ActionBtn({
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
호버 툴팁 컴포넌트
|
||||
════════════════════════════════════════════════════ */
|
||||
function VesselTooltipContent({ vessel: v }: { vessel: Vessel }) {
|
||||
return (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#f0f6fc', marginBottom: 3 }}>{v.name}</div>
|
||||
<div style={{ fontSize: 9, color: '#8b949e', marginBottom: 4 }}>
|
||||
{v.typS} · {v.flag}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 9 }}>
|
||||
<span style={{ color: '#22d3ee', fontWeight: 600 }}>{v.speed} kn</span>
|
||||
<span style={{ color: '#8b949e' }}>HDG {v.heading}°</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function IncidentTooltipContent({ incident: i }: { incident: IncidentCompat }) {
|
||||
const statusColor =
|
||||
i.status === 'active' ? '#ef4444' : i.status === 'investigating' ? '#f59e0b' : '#6b7280'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#f0f6fc', marginBottom: 3 }}>{i.name}</div>
|
||||
<div style={{ fontSize: 9, color: '#8b949e', marginBottom: 4 }}>
|
||||
{i.date} {i.time}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: statusColor,
|
||||
}}
|
||||
>
|
||||
{getStatusLabel(i.status)}
|
||||
</span>
|
||||
<span style={{ fontSize: 9, color: '#58a6ff', fontFamily: 'monospace' }}>
|
||||
{i.location.lat.toFixed(3)}°N, {i.location.lon.toFixed(3)}°E
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<PredictionModel, { bearing: number; speed: number; spread: number; seed: number }> = {
|
||||
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) {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user