Merge pull request 'feat(map): Phase 6 MapLibre + deck.gl 시각화 개선' (#55) from feature/phase6-maplibre-deckgl into develop
Reviewed-on: #55
This commit is contained in:
커밋
e27cdcdf85
@ -497,7 +497,7 @@ export function MapView({
|
|||||||
const currentArrows: Array<{ lon: number; lat: number; bearing: number; speed: number }> = []
|
const currentArrows: Array<{ lon: number; lat: number; bearing: number; speed: number }> = []
|
||||||
const gridSize = 5
|
const gridSize = 5
|
||||||
const spacing = 0.04 // 약 4km 간격
|
const spacing = 0.04 // 약 4km 간격
|
||||||
const mainBearing = 42 // NE 방향 (도)
|
const mainBearing = 200 // SSW 방향 (도)
|
||||||
|
|
||||||
for (let row = -gridSize; row <= gridSize; row++) {
|
for (let row = -gridSize; row <= gridSize; row++) {
|
||||||
for (let col = -gridSize; col <= gridSize; col++) {
|
for (let col = -gridSize; col <= gridSize; col++) {
|
||||||
@ -516,10 +516,10 @@ export function MapView({
|
|||||||
id: 'current-arrows',
|
id: 'current-arrows',
|
||||||
data: currentArrows,
|
data: currentArrows,
|
||||||
getPosition: (d: (typeof currentArrows)[0]) => [d.lon, d.lat],
|
getPosition: (d: (typeof currentArrows)[0]) => [d.lon, d.lat],
|
||||||
getText: () => '→',
|
getText: () => '➤',
|
||||||
getAngle: (d: (typeof currentArrows)[0]) => -d.bearing,
|
getAngle: (d: (typeof currentArrows)[0]) => -d.bearing + 90,
|
||||||
getSize: 14,
|
getSize: 22,
|
||||||
getColor: [6, 182, 212, 70],
|
getColor: [6, 182, 212, 100],
|
||||||
characterSet: 'auto',
|
characterSet: 'auto',
|
||||||
sizeUnits: 'pixels' as const,
|
sizeUnits: 'pixels' as const,
|
||||||
billboard: true,
|
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 { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'
|
||||||
import { fetchIncidents } from '../services/incidentsApi'
|
import { fetchIncidents } from '../services/incidentsApi'
|
||||||
import type { IncidentCompat } from '../services/incidentsApi'
|
import type { IncidentCompat } from '../services/incidentsApi'
|
||||||
import { hexToRgba } from '@common/components/map/mapUtils'
|
|
||||||
|
|
||||||
// ── CartoDB Dark Matter 베이스맵 ────────────────────────
|
// ── CartoDB Dark Matter 베이스맵 ────────────────────────
|
||||||
const BASE_STYLE: StyleSpecification = {
|
const BASE_STYLE: StyleSpecification = {
|
||||||
@ -71,6 +70,14 @@ interface IncidentPopupInfo {
|
|||||||
incident: IncidentCompat
|
incident: IncidentCompat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 호버 툴팁 정보
|
||||||
|
interface HoverInfo {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
object: Vessel | IncidentCompat
|
||||||
|
type: 'vessel' | 'incident'
|
||||||
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════
|
||||||
IncidentsView
|
IncidentsView
|
||||||
════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════ */
|
||||||
@ -81,6 +88,7 @@ export function IncidentsView() {
|
|||||||
const [detailVessel, setDetailVessel] = useState<Vessel | null>(null)
|
const [detailVessel, setDetailVessel] = useState<Vessel | null>(null)
|
||||||
const [vesselPopup, setVesselPopup] = useState<VesselPopupInfo | null>(null)
|
const [vesselPopup, setVesselPopup] = useState<VesselPopupInfo | null>(null)
|
||||||
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null)
|
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null)
|
||||||
|
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null)
|
||||||
|
|
||||||
// Analysis view mode
|
// Analysis view mode
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('overlay')
|
const [viewMode, setViewMode] = useState<ViewMode>('overlay')
|
||||||
@ -144,6 +152,13 @@ export function IncidentsView() {
|
|||||||
setVesselPopup(null)
|
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: {
|
updateTriggers: {
|
||||||
getRadius: [selectedIncidentId],
|
getRadius: [selectedIncidentId],
|
||||||
getLineColor: [selectedIncidentId],
|
getLineColor: [selectedIncidentId],
|
||||||
@ -153,47 +168,20 @@ export function IncidentsView() {
|
|||||||
[incidents, selectedIncidentId],
|
[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) ──────
|
// ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ──────
|
||||||
// IconLayer는 atlas 이미지가 필요하여, 대신 HTML overlay로 선박 방향 표현
|
// 글로우 효과 + 드롭쉐도우가 있는 개선된 SVG 삼각형
|
||||||
// 실제 지도 위 선박 방향은 ScatterplotLayer + 별도 SVG 오버레이로 처리 가능하나
|
|
||||||
// deck.gl에서 가장 간단한 방법은 커스텀 SVG를 data URL로 활용
|
|
||||||
const vesselIconLayer = useMemo(() => {
|
const vesselIconLayer = useMemo(() => {
|
||||||
const makeTriangleSvg = (color: string, isAccident: boolean) => {
|
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">
|
const opacity = isAccident ? '1' : '0.85'
|
||||||
<polygon points="5,0 10,12 0,12" fill="${color}" opacity="${isAccident ? '1' : '0.85'}"/>
|
const glowOpacity = isAccident ? '0.9' : '0.75'
|
||||||
</svg>`
|
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)}`
|
return `data:image/svg+xml;base64,${btoa(svgStr)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,23 +191,42 @@ export function IncidentsView() {
|
|||||||
getPosition: (d: Vessel) => [d.lng, d.lat],
|
getPosition: (d: Vessel) => [d.lng, d.lat],
|
||||||
getIcon: (d: Vessel) => ({
|
getIcon: (d: Vessel) => ({
|
||||||
url: makeTriangleSvg(d.color, d.status.includes('사고')),
|
url: makeTriangleSvg(d.color, d.status.includes('사고')),
|
||||||
width: 10,
|
width: 16,
|
||||||
height: 12,
|
height: 20,
|
||||||
anchorX: 5,
|
anchorX: 8,
|
||||||
anchorY: 6,
|
anchorY: 10,
|
||||||
}),
|
}),
|
||||||
getSize: 12,
|
getSize: 16,
|
||||||
getAngle: (d: Vessel) => -d.heading,
|
getAngle: (d: Vessel) => -d.heading,
|
||||||
sizeUnits: 'pixels',
|
sizeUnits: 'pixels',
|
||||||
sizeScale: 1,
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const deckLayers: any[] = useMemo(
|
const deckLayers: any[] = useMemo(
|
||||||
() => [incidentLayer, vesselIconLayer, vesselLayer],
|
() => [incidentLayer, vesselIconLayer],
|
||||||
[incidentLayer, vesselIconLayer, vesselLayer],
|
[incidentLayer, vesselIconLayer],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -368,6 +375,32 @@ export function IncidentsView() {
|
|||||||
)}
|
)}
|
||||||
</Map>
|
</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' && (
|
{analysisActive && viewMode === 'overlay' && (
|
||||||
<div
|
<div
|
||||||
@ -1942,3 +1975,49 @@ function ActionBtn({
|
|||||||
</button>
|
</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[] = [
|
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.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 },
|
||||||
{ id: 'bc-1', name: '만성리 해수욕장', type: 'beach', lat: 34.765, lon: 127.765, radiusM: 400, arrivalTimeH: 6 },
|
{ id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 },
|
||||||
{ id: 'ec-1', name: '오동도 생태보호구역', type: 'ecology', lat: 34.745, lon: 127.78, radiusM: 600, arrivalTimeH: 12 },
|
{ id: 'ec-1', name: '여자만 습지보호구역', type: 'ecology', lat: 34.758, lon: 127.614, radiusM: 1200, arrivalTimeH: 6 },
|
||||||
{ id: 'aq-2', name: '금오도 전복 양식장', type: 'aquaculture', lat: 34.70, lon: 127.75, radiusM: 700, arrivalTimeH: 8 },
|
{ id: 'aq-2', name: '화태도 김 양식장', type: 'aquaculture', lat: 34.648, lon: 127.652, radiusM: 800, arrivalTimeH: 10 },
|
||||||
{ id: 'bc-2', name: '방죽포 해수욕장', type: 'beach', lat: 34.72, lon: 127.81, radiusM: 350, 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 TIME_STEP = 3 // hours
|
||||||
|
|
||||||
const modelParams: Record<PredictionModel, { bearing: number; speed: number; spread: number; seed: number }> = {
|
const modelParams: Record<PredictionModel, { bearing: number; speed: number; spread: number; seed: number }> = {
|
||||||
KOSPS: { bearing: 42, speed: 0.003, spread: 0.008, seed: 42 },
|
KOSPS: { bearing: 200, speed: 0.003, spread: 0.008, seed: 42 },
|
||||||
POSEIDON: { bearing: 55, speed: 0.0025, spread: 0.01, seed: 137 },
|
POSEIDON: { bearing: 210, speed: 0.0025, spread: 0.01, seed: 137 },
|
||||||
OpenDrift: { bearing: 35, speed: 0.0035, spread: 0.006, seed: 271 },
|
OpenDrift: { bearing: 190, speed: 0.0035, spread: 0.006, seed: 271 },
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user