develop #56

병합
htlee develop 에서 main 로 2 commits 를 머지했습니다 2026-03-01 10:05:31 +09:00
3개의 변경된 파일139개의 추가작업 그리고 60개의 파일을 삭제
Showing only changes of commit 9384290bf3 - Show all commits

파일 보기

@ -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) {