Merge branch 'feature/prediction-pollution-analysis' of https://gitea.gc-si.dev/gc/wing-ops into feature/prediction-pollution-analysis
# Conflicts: # docs/RELEASE-NOTES.md # frontend/src/common/components/map/MapView.tsx # frontend/src/tabs/prediction/components/OilSpillView.tsx # frontend/src/tabs/prediction/components/RightPanel.tsx
This commit is contained in:
커밋
421f5f8b52
@ -5,29 +5,29 @@
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npx *)",
|
||||
"Bash(node *)",
|
||||
"Bash(git status)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(curl -s *)",
|
||||
"Bash(fnm *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git branch *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git config *)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git rev-parse *)",
|
||||
"Bash(git show *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git status)",
|
||||
"Bash(git tag *)",
|
||||
"Bash(curl -s *)",
|
||||
"Bash(fnm *)"
|
||||
"Bash(node *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npx *)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force*)",
|
||||
@ -84,4 +84,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-11",
|
||||
"applied_date": "2026-03-13",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
### 추가
|
||||
- 시뮬레이션 에러 모달 추가
|
||||
- 해류 캔버스 파티클 레이어 추가
|
||||
|
||||
### 변경
|
||||
- 보고서 해안부착 현황 개선
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer } from '@deck.gl/layers'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer } from '@deck.gl/layers'
|
||||
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||
@ -194,6 +194,10 @@ interface MapViewProps {
|
||||
showBeached?: boolean
|
||||
showTimeLabel?: boolean
|
||||
simulationStartTime?: string
|
||||
drawAnalysisMode?: 'polygon' | 'circle' | null
|
||||
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
|
||||
analysisCircleCenter?: { lat: number; lon: number } | null
|
||||
analysisCircleRadiusM?: number
|
||||
}
|
||||
|
||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
|
||||
@ -321,6 +325,10 @@ export function MapView({
|
||||
showBeached = false,
|
||||
showTimeLabel = false,
|
||||
simulationStartTime,
|
||||
drawAnalysisMode = null,
|
||||
analysisPolygonPoints = [],
|
||||
analysisCircleCenter,
|
||||
analysisCircleRadiusM = 0,
|
||||
}: MapViewProps) {
|
||||
const { mapToggles } = useMapStore()
|
||||
const isControlled = externalCurrentTime !== undefined
|
||||
@ -541,6 +549,91 @@ export function MapView({
|
||||
)
|
||||
}
|
||||
|
||||
// --- 오염분석 다각형 그리기 ---
|
||||
if (analysisPolygonPoints.length > 0) {
|
||||
if (analysisPolygonPoints.length >= 3) {
|
||||
result.push(
|
||||
new PolygonLayer({
|
||||
id: 'analysis-polygon-fill',
|
||||
data: [{ polygon: analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]) }],
|
||||
getPolygon: (d: { polygon: [number, number][] }) => d.polygon,
|
||||
getFillColor: [168, 85, 247, 40],
|
||||
getLineColor: [168, 85, 247, 220],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
lineWidthMinPixels: 2,
|
||||
})
|
||||
)
|
||||
}
|
||||
result.push(
|
||||
new PathLayer({
|
||||
id: 'analysis-polygon-outline',
|
||||
data: [{
|
||||
path: [
|
||||
...analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]),
|
||||
...(analysisPolygonPoints.length >= 3 ? [[analysisPolygonPoints[0].lon, analysisPolygonPoints[0].lat] as [number, number]] : []),
|
||||
],
|
||||
}],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: [168, 85, 247, 220],
|
||||
getWidth: 2,
|
||||
getDashArray: [8, 4],
|
||||
dashJustified: true,
|
||||
widthMinPixels: 2,
|
||||
})
|
||||
)
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'analysis-polygon-points',
|
||||
data: analysisPolygonPoints.map(p => ({ position: [p.lon, p.lat] as [number, number] })),
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: 5,
|
||||
getFillColor: [168, 85, 247, 255],
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
radiusMinPixels: 5,
|
||||
radiusMaxPixels: 8,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// --- 오염분석 원 그리기 ---
|
||||
if (analysisCircleCenter) {
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'analysis-circle-center',
|
||||
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: 6,
|
||||
getFillColor: [168, 85, 247, 255],
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
radiusMinPixels: 6,
|
||||
radiusMaxPixels: 9,
|
||||
})
|
||||
)
|
||||
if (analysisCircleRadiusM > 0) {
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'analysis-circle-area',
|
||||
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: analysisCircleRadiusM,
|
||||
radiusUnits: 'meters',
|
||||
getFillColor: [168, 85, 247, 35],
|
||||
getLineColor: [168, 85, 247, 200],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
lineWidthMinPixels: 2,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
|
||||
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
|
||||
const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration));
|
||||
@ -873,6 +966,7 @@ export function MapView({
|
||||
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||
sensitiveResources, centerPoints, windData,
|
||||
showWind, showBeached, showTimeLabel, simulationStartTime,
|
||||
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM,
|
||||
])
|
||||
|
||||
// 3D 모드에 따른 지도 스타일 전환
|
||||
@ -888,7 +982,7 @@ export function MapView({
|
||||
}}
|
||||
mapStyle={currentMapStyle}
|
||||
className="w-full h-full"
|
||||
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
||||
style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null) ? 'crosshair' : 'grab' }}
|
||||
onClick={handleMapClick}
|
||||
attributionControl={false}
|
||||
preserveDrawingBuffer={true}
|
||||
@ -972,6 +1066,16 @@ export function MapView({
|
||||
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
|
||||
</div>
|
||||
)}
|
||||
{drawAnalysisMode === 'polygon' && (
|
||||
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
|
||||
다각형 분석 모드 — 지도를 클릭하여 꼭짓점을 추가하세요 ({analysisPolygonPoints.length}개)
|
||||
</div>
|
||||
)}
|
||||
{drawAnalysisMode === 'circle' && (
|
||||
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
|
||||
{!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기상청 연계 정보 */}
|
||||
<WeatherInfoPanel position={currentPosition} />
|
||||
|
||||
@ -186,6 +186,47 @@ export function generateAIBoomLines(
|
||||
return boomLines
|
||||
}
|
||||
|
||||
/** Ray casting — 점이 다각형 내부인지 판정 */
|
||||
export function pointInPolygon(
|
||||
point: { lat: number; lon: number },
|
||||
polygon: { lat: number; lon: number }[]
|
||||
): boolean {
|
||||
if (polygon.length < 3) return false
|
||||
let inside = false
|
||||
const x = point.lon
|
||||
const y = point.lat
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].lon, yi = polygon[i].lat
|
||||
const xj = polygon[j].lon, yj = polygon[j].lat
|
||||
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)
|
||||
if (intersect) inside = !inside
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
/** 다각형 면적 (km²) — Shoelace formula, 구면 보정 포함 */
|
||||
export function polygonAreaKm2(polygon: { lat: number; lon: number }[]): number {
|
||||
if (polygon.length < 3) return 0
|
||||
const n = polygon.length
|
||||
const latCenter = polygon.reduce((s, p) => s + p.lat, 0) / n
|
||||
const cosLat = Math.cos(latCenter * DEG2RAD)
|
||||
let area = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
const x1 = polygon[i].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000
|
||||
const y1 = polygon[i].lat * EARTH_RADIUS * DEG2RAD / 1000
|
||||
const x2 = polygon[j].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000
|
||||
const y2 = polygon[j].lat * EARTH_RADIUS * DEG2RAD / 1000
|
||||
area += x1 * y2 - x2 * y1
|
||||
}
|
||||
return Math.abs(area) / 2
|
||||
}
|
||||
|
||||
/** 원 면적 (km²) */
|
||||
export function circleAreaKm2(radiusM: number): number {
|
||||
return Math.PI * (radiusM / 1000) ** 2
|
||||
}
|
||||
|
||||
/** 차단 시뮬레이션 실행 */
|
||||
export function runContainmentAnalysis(
|
||||
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,
|
||||
|
||||
@ -18,7 +18,7 @@ import { useSimulationStatus } from '../hooks/useSimulationStatus'
|
||||
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
||||
import SimulationErrorModal from './SimulationErrorModal'
|
||||
import { api } from '@common/services/api'
|
||||
import { generateAIBoomLines } from '@common/utils/geo'
|
||||
import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo'
|
||||
import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
|
||||
|
||||
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
||||
@ -192,6 +192,16 @@ export function OilSpillView() {
|
||||
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
|
||||
const { data: simStatus } = useSimulationStatus(currentExecSn)
|
||||
|
||||
// 오염분석 상태
|
||||
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
|
||||
const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
|
||||
const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([])
|
||||
const [circleRadiusNm, setCircleRadiusNm] = useState<number>(5)
|
||||
const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null)
|
||||
|
||||
// 원 분석용 derived 값 (state 아님)
|
||||
const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null
|
||||
const analysisCircleRadiusM = circleRadiusNm * 1852
|
||||
|
||||
const handleToggleLayer = (layerId: string, enabled: boolean) => {
|
||||
setEnabledLayers(prev => {
|
||||
@ -483,8 +493,7 @@ export function OilSpillView() {
|
||||
setCenterPoints(cp ?? [])
|
||||
setWindData(wd ?? [])
|
||||
setHydrData(hd ?? [])
|
||||
const booms = generateAIBoomLines(trajectory, coord, algorithmSettings)
|
||||
setBoomLines(booms)
|
||||
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||
@ -500,10 +509,9 @@ export function OilSpillView() {
|
||||
}
|
||||
|
||||
// 데모 궤적 생성 (fallback)
|
||||
const demoTrajectory = generateDemoTrajectory(coord, demoModels, parseInt(analysis.duration) || 48)
|
||||
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
|
||||
setOilTrajectory(demoTrajectory)
|
||||
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
|
||||
setBoomLines(demoBooms)
|
||||
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||
@ -516,12 +524,65 @@ export function OilSpillView() {
|
||||
const handleMapClick = (lon: number, lat: number) => {
|
||||
if (isDrawingBoom) {
|
||||
setDrawingPoints(prev => [...prev, { lat, lon }])
|
||||
} else if (drawAnalysisMode === 'polygon') {
|
||||
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
|
||||
} else {
|
||||
setIncidentCoord({ lon, lat })
|
||||
setIsSelectingLocation(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartPolygonDraw = () => {
|
||||
setDrawAnalysisMode('polygon')
|
||||
setAnalysisPolygonPoints([])
|
||||
setAnalysisResult(null)
|
||||
}
|
||||
|
||||
const handleRunPolygonAnalysis = () => {
|
||||
if (analysisPolygonPoints.length < 3) return
|
||||
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
||||
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
|
||||
const inside = currentParticles.filter(p => pointInPolygon({ lat: p.lat, lon: p.lon }, analysisPolygonPoints)).length
|
||||
const sensitiveCount = sensitiveResources.filter(r => pointInPolygon({ lat: r.lat, lon: r.lon }, analysisPolygonPoints)).length
|
||||
setAnalysisResult({
|
||||
area: polygonAreaKm2(analysisPolygonPoints),
|
||||
particleCount: inside,
|
||||
particlePercent: Math.round((inside / totalIds) * 100),
|
||||
sensitiveCount,
|
||||
})
|
||||
setDrawAnalysisMode(null)
|
||||
}
|
||||
|
||||
const handleRunCircleAnalysis = () => {
|
||||
if (!incidentCoord) return
|
||||
const radiusM = circleRadiusNm * 1852
|
||||
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
||||
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
|
||||
const inside = currentParticles.filter(p =>
|
||||
haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: p.lat, lon: p.lon }) <= radiusM
|
||||
).length
|
||||
const sensitiveCount = sensitiveResources.filter(r =>
|
||||
haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: r.lat, lon: r.lon }) <= radiusM
|
||||
).length
|
||||
setAnalysisResult({
|
||||
area: circleAreaKm2(radiusM),
|
||||
particleCount: inside,
|
||||
particlePercent: Math.round((inside / totalIds) * 100),
|
||||
sensitiveCount,
|
||||
})
|
||||
}
|
||||
|
||||
const handleCancelAnalysis = () => {
|
||||
setDrawAnalysisMode(null)
|
||||
setAnalysisPolygonPoints([])
|
||||
}
|
||||
|
||||
const handleClearAnalysis = () => {
|
||||
setDrawAnalysisMode(null)
|
||||
setAnalysisPolygonPoints([])
|
||||
setAnalysisResult(null)
|
||||
}
|
||||
|
||||
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
|
||||
setIncidentCoord({ lat: result.lat, lon: result.lon })
|
||||
setFlyToCoord({ lat: result.lat, lon: result.lon })
|
||||
@ -744,7 +805,7 @@ export function OilSpillView() {
|
||||
enabledLayers={enabledLayers}
|
||||
incidentCoord={incidentCoord ?? undefined}
|
||||
flyToIncident={flyToCoord}
|
||||
isSelectingLocation={isSelectingLocation || isDrawingBoom}
|
||||
isSelectingLocation={isSelectingLocation || isDrawingBoom || drawAnalysisMode === 'polygon'}
|
||||
onMapClick={handleMapClick}
|
||||
oilTrajectory={oilTrajectory}
|
||||
selectedModels={selectedModels}
|
||||
@ -760,6 +821,10 @@ export function OilSpillView() {
|
||||
flyToTarget={flyToTarget}
|
||||
fitBoundsTarget={fitBoundsTarget}
|
||||
onIncidentFlyEnd={handleFlyEnd}
|
||||
drawAnalysisMode={drawAnalysisMode}
|
||||
analysisPolygonPoints={analysisPolygonPoints}
|
||||
analysisCircleCenter={analysisCircleCenter}
|
||||
analysisCircleRadiusM={analysisCircleRadiusM}
|
||||
externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined}
|
||||
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
|
||||
isActive: true,
|
||||
@ -958,7 +1023,30 @@ export function OilSpillView() {
|
||||
</div>
|
||||
|
||||
{/* Right Panel */}
|
||||
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} displayControls={displayControls} onDisplayControlsChange={setDisplayControls} />}
|
||||
{activeSubTab === 'analysis' && (
|
||||
<RightPanel
|
||||
onOpenBacktrack={handleOpenBacktrack}
|
||||
onOpenRecalc={() => setRecalcModalOpen(true)}
|
||||
onOpenReport={handleOpenReport}
|
||||
detail={analysisDetail}
|
||||
summary={simulationSummary}
|
||||
displayControls={displayControls}
|
||||
onDisplayControlsChange={setDisplayControls}
|
||||
analysisTab={analysisTab}
|
||||
onSwitchAnalysisTab={setAnalysisTab}
|
||||
drawAnalysisMode={drawAnalysisMode}
|
||||
analysisPolygonPoints={analysisPolygonPoints}
|
||||
circleRadiusNm={circleRadiusNm}
|
||||
onCircleRadiusChange={setCircleRadiusNm}
|
||||
analysisResult={analysisResult}
|
||||
incidentCoord={incidentCoord}
|
||||
onStartPolygonDraw={handleStartPolygonDraw}
|
||||
onRunPolygonAnalysis={handleRunPolygonAnalysis}
|
||||
onRunCircleAnalysis={handleRunCircleAnalysis}
|
||||
onCancelAnalysis={handleCancelAnalysis}
|
||||
onClearAnalysis={handleClearAnalysis}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 확산 예측 실행 중 로딩 오버레이 */}
|
||||
{isRunningSimulation && (
|
||||
|
||||
@ -2,7 +2,46 @@ import { useState } from 'react'
|
||||
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
|
||||
import type { DisplayControls } from './OilSpillView'
|
||||
|
||||
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary, displayControls, onDisplayControlsChange }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null; summary?: SimulationSummary | null; displayControls?: DisplayControls; onDisplayControlsChange?: (controls: DisplayControls) => void }) {
|
||||
interface AnalysisResult {
|
||||
area: number
|
||||
particleCount: number
|
||||
particlePercent: number
|
||||
sensitiveCount: number
|
||||
}
|
||||
|
||||
interface RightPanelProps {
|
||||
onOpenBacktrack?: () => void
|
||||
onOpenRecalc?: () => void
|
||||
onOpenReport?: () => void
|
||||
detail?: PredictionDetail | null
|
||||
summary?: SimulationSummary | null
|
||||
displayControls?: DisplayControls
|
||||
onDisplayControlsChange?: (controls: DisplayControls) => void
|
||||
analysisTab?: 'polygon' | 'circle'
|
||||
onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void
|
||||
drawAnalysisMode?: 'polygon' | null
|
||||
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
|
||||
circleRadiusNm?: number
|
||||
onCircleRadiusChange?: (nm: number) => void
|
||||
analysisResult?: AnalysisResult | null
|
||||
incidentCoord?: { lat: number; lon: number } | null
|
||||
onStartPolygonDraw?: () => void
|
||||
onRunPolygonAnalysis?: () => void
|
||||
onRunCircleAnalysis?: () => void
|
||||
onCancelAnalysis?: () => void
|
||||
onClearAnalysis?: () => void
|
||||
}
|
||||
|
||||
export function RightPanel({
|
||||
onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary,
|
||||
displayControls, onDisplayControlsChange,
|
||||
analysisTab = 'polygon', onSwitchAnalysisTab,
|
||||
drawAnalysisMode, analysisPolygonPoints = [],
|
||||
circleRadiusNm = 5, onCircleRadiusChange,
|
||||
analysisResult,
|
||||
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
|
||||
onCancelAnalysis, onClearAnalysis,
|
||||
}: RightPanelProps) {
|
||||
const vessel = detail?.vessels?.[0]
|
||||
const vessel2 = detail?.vessels?.[1]
|
||||
const spill = detail?.spill
|
||||
@ -49,9 +88,116 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
|
||||
|
||||
{/* 오염분석 */}
|
||||
<Section title="오염분석">
|
||||
<button className="w-full py-2 px-3 bg-gradient-to-r from-purple-500 to-primary-cyan text-white rounded text-[10px] font-bold font-korean">
|
||||
📐 다각형 분석수행
|
||||
</button>
|
||||
{/* 탭 전환 */}
|
||||
<div className="flex gap-[3px] mb-2">
|
||||
{(['polygon', 'circle'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => { onSwitchAnalysisTab?.(tab); onClearAnalysis?.() }}
|
||||
className={`flex-1 py-1.5 px-1 rounded text-[9px] font-semibold font-korean border transition-colors ${
|
||||
analysisTab === tab
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.08)] text-primary-cyan'
|
||||
: 'border-border bg-bg-3 text-text-3 hover:text-text-2'
|
||||
}`}
|
||||
>
|
||||
{tab === 'polygon' ? '다각형 분석' : '원 분석'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 다각형 패널 */}
|
||||
{analysisTab === 'polygon' && (
|
||||
<div>
|
||||
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
|
||||
지도에서 다각형 영역을 지정하여 해당 범위 내 오염도를 분석합니다.
|
||||
</p>
|
||||
{!drawAnalysisMode && !analysisResult && (
|
||||
<button
|
||||
onClick={onStartPolygonDraw}
|
||||
className="w-full py-2 rounded text-[10px] font-bold font-korean text-white mb-0 transition-opacity hover:opacity-90"
|
||||
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
|
||||
>
|
||||
📐 다각형 분석수행
|
||||
</button>
|
||||
)}
|
||||
{drawAnalysisMode === 'polygon' && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[9px] text-purple-400 font-korean bg-[rgba(168,85,247,0.08)] rounded px-2 py-1.5 leading-relaxed">
|
||||
지도를 클릭하여 꼭짓점을 추가하세요<br />
|
||||
<span className="text-text-3">현재 {analysisPolygonPoints.length}개 선택됨</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={onRunPolygonAnalysis}
|
||||
disabled={analysisPolygonPoints.length < 3}
|
||||
className="flex-1 py-1.5 rounded text-[10px] font-bold font-korean text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
|
||||
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
|
||||
>
|
||||
분석 실행
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelAnalysis}
|
||||
className="py-1.5 px-2 rounded text-[10px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult && !drawAnalysisMode && (
|
||||
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} onRerun={onStartPolygonDraw} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 원 분석 패널 */}
|
||||
{analysisTab === 'circle' && (
|
||||
<div>
|
||||
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
|
||||
반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다.
|
||||
</p>
|
||||
<div className="text-[9px] font-semibold text-text-2 font-korean mb-1.5">반경 선택 (NM)</div>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => (
|
||||
<button
|
||||
key={nm}
|
||||
onClick={() => onCircleRadiusChange?.(nm)}
|
||||
className={`w-8 h-7 rounded text-[10px] font-semibold font-mono border transition-all ${
|
||||
circleRadiusNm === nm
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.1)] text-primary-cyan'
|
||||
: 'border-border bg-bg-0 text-text-3 hover:text-text-2'
|
||||
}`}
|
||||
>
|
||||
{nm}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mb-2.5">
|
||||
<span className="text-[9px] text-text-3 font-korean whitespace-nowrap">직접 입력</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={circleRadiusNm}
|
||||
onChange={(e) => onCircleRadiusChange?.(parseFloat(e.target.value) || 0.1)}
|
||||
className="w-14 text-center py-1 px-1 bg-bg-0 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-primary-cyan"
|
||||
style={{ colorScheme: 'dark' }}
|
||||
/>
|
||||
<span className="text-[9px] text-text-3 font-korean">NM</span>
|
||||
<button
|
||||
onClick={onRunCircleAnalysis}
|
||||
className="ml-auto py-1 px-3 rounded text-[9px] font-bold font-korean text-white transition-opacity hover:opacity-90"
|
||||
style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}
|
||||
>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
{analysisResult && (
|
||||
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} radiusNm={circleRadiusNm} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* 오염 종합 상황 */}
|
||||
@ -454,3 +600,78 @@ function InsuranceCard({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PollResult({
|
||||
result,
|
||||
summary,
|
||||
onClear,
|
||||
onRerun,
|
||||
radiusNm,
|
||||
}: {
|
||||
result: AnalysisResult
|
||||
summary?: SimulationSummary | null
|
||||
onClear?: () => void
|
||||
onRerun?: () => void
|
||||
radiusNm?: number
|
||||
}) {
|
||||
const pollutedArea = (result.area * result.particlePercent / 100).toFixed(2)
|
||||
return (
|
||||
<div className="mt-1 p-2.5 bg-bg-0 border border-[rgba(168,85,247,0.2)] rounded-md" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, var(--purple), var(--cyan))' }} />
|
||||
{radiusNm && (
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-[10px] font-semibold text-text-1 font-korean">분석 결과</span>
|
||||
<span className="text-[9px] font-semibold text-primary-cyan font-mono">반경 {radiusNm} NM</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-1 mb-2">
|
||||
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--red)' }}>{result.area.toFixed(2)}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean mt-0.5">분석면적(km²)</div>
|
||||
</div>
|
||||
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--orange)' }}>{result.particlePercent}%</div>
|
||||
<div className="text-[7px] text-text-3 font-korean mt-0.5">오염비율</div>
|
||||
</div>
|
||||
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--cyan)' }}>{pollutedArea}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean mt-0.5">오염면적(km²)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-[9px] font-korean">
|
||||
{summary && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">해상잔존량</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} kL</span>
|
||||
</div>
|
||||
)}
|
||||
{summary && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">연안부착량</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} kL</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">민감자원 포함</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--orange)' }}>{result.sensitiveCount}개소</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
{onRerun && (
|
||||
<button
|
||||
onClick={onRerun}
|
||||
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-[rgba(168,85,247,0.3)] text-purple-400 hover:bg-[rgba(168,85,247,0.08)] transition-colors"
|
||||
>
|
||||
재분석
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,324 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useMap } from '@vis.gl/react-maplibre'
|
||||
import type { Map as MapLibreMap } from 'maplibre-gl'
|
||||
|
||||
interface CurrentVectorPoint {
|
||||
lat: number
|
||||
lon: number
|
||||
u: number // 동서 방향 속도 (양수=동, 음수=서) [m/s]
|
||||
v: number // 남북 방향 속도 (양수=북, 음수=남) [m/s]
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
x: number
|
||||
y: number
|
||||
age: number
|
||||
maxAge: number
|
||||
}
|
||||
|
||||
interface OceanCurrentParticleLayerProps {
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
// 해류 속도 기반 색상
|
||||
function getCurrentColor(speed: number): string {
|
||||
if (speed < 0.2) return 'rgba(59, 130, 246, 0.8)' // 파랑
|
||||
if (speed < 0.4) return 'rgba(6, 182, 212, 0.8)' // 청록
|
||||
if (speed < 0.6) return 'rgba(34, 197, 94, 0.8)' // 초록
|
||||
return 'rgba(249, 115, 22, 0.8)' // 주황
|
||||
}
|
||||
|
||||
// 한반도 육지 영역 판별 (간략화된 폴리곤)
|
||||
const isOnLand = (lat: number, lon: number): boolean => {
|
||||
const peninsula: [number, number][] = [
|
||||
[38.5, 124.5], [38.5, 128.3],
|
||||
[37.8, 128.8], [37.0, 129.2],
|
||||
[36.0, 129.5], [35.1, 129.2],
|
||||
[34.8, 128.6], [34.5, 127.8],
|
||||
[34.3, 126.5], [34.8, 126.1],
|
||||
[35.5, 126.0], [36.0, 126.3],
|
||||
[36.8, 126.0], [37.5, 126.2],
|
||||
[38.5, 124.5],
|
||||
]
|
||||
|
||||
// 제주도 영역
|
||||
if (lat >= 33.1 && lat <= 33.7 && lon >= 126.1 && lon <= 127.0) return true
|
||||
|
||||
// Ray casting algorithm
|
||||
let inside = false
|
||||
for (let i = 0, j = peninsula.length - 1; i < peninsula.length; j = i++) {
|
||||
const [yi, xi] = peninsula[i]
|
||||
const [yj, xj] = peninsula[j]
|
||||
if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
// 한국 해역의 해류 u,v 벡터 데이터 생성 (Mock)
|
||||
const generateOceanCurrentData = (): CurrentVectorPoint[] => {
|
||||
const data: CurrentVectorPoint[] = []
|
||||
|
||||
for (let lat = 33.5; lat <= 38.0; lat += 0.8) {
|
||||
for (let lon = 125.0; lon <= 130.5; lon += 0.8) {
|
||||
if (isOnLand(lat, lon)) continue
|
||||
|
||||
let u = 0
|
||||
let v = 0
|
||||
|
||||
if (lon > 128.5) {
|
||||
// 동해 — 북동진하는 동한난류
|
||||
u = 0.2 + Math.random() * 0.2 // 동쪽 0.2~0.4
|
||||
v = 0.3 + Math.random() * 0.2 // 북쪽 0.3~0.5
|
||||
} else if (lon < 126.5) {
|
||||
// 서해 — 북진
|
||||
u = -0.05 + Math.random() * 0.1 // 동서 -0.05~0.05
|
||||
v = 0.15 + Math.random() * 0.15 // 북쪽 0.15~0.3
|
||||
} else {
|
||||
// 남해 — 동진
|
||||
u = 0.3 + Math.random() * 0.2 // 동쪽 0.3~0.5
|
||||
v = -0.05 + Math.random() * 0.15 // 남북 -0.05~0.1
|
||||
}
|
||||
|
||||
data.push({ lat, lon, u, v })
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// 해류 데이터는 한 번만 생성
|
||||
const CURRENT_DATA = generateOceanCurrentData()
|
||||
|
||||
// IDW 보간으로 특정 위치의 u,v 벡터 추정 → speed/direction 반환
|
||||
function interpolateCurrent(
|
||||
lat: number,
|
||||
lon: number,
|
||||
points: CurrentVectorPoint[]
|
||||
): { speed: number; direction: number } {
|
||||
if (points.length === 0) return { speed: 0.3, direction: 90 }
|
||||
|
||||
let totalWeight = 0
|
||||
let weightedU = 0
|
||||
let weightedV = 0
|
||||
|
||||
for (const point of points) {
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(point.lat - lat, 2) + Math.pow(point.lon - lon, 2)
|
||||
)
|
||||
const weight = 1 / Math.pow(Math.max(dist, 0.01), 2)
|
||||
totalWeight += weight
|
||||
weightedU += point.u * weight
|
||||
weightedV += point.v * weight
|
||||
}
|
||||
|
||||
const u = weightedU / totalWeight
|
||||
const v = weightedV / totalWeight
|
||||
const speed = Math.sqrt(u * u + v * v)
|
||||
// u=동(+), v=북(+) → 화면 방향: sin=동(+x), -cos=남(+y)
|
||||
const direction = (Math.atan2(u, v) * 180) / Math.PI
|
||||
return { speed, direction: (direction + 360) % 360 }
|
||||
}
|
||||
|
||||
// MapLibre map.unproject()를 통해 픽셀 → 경위도 변환
|
||||
function containerPointToLatLng(
|
||||
map: MapLibreMap,
|
||||
x: number,
|
||||
y: number
|
||||
): { lat: number; lng: number } {
|
||||
const lngLat = map.unproject([x, y])
|
||||
return { lat: lngLat.lat, lng: lngLat.lng }
|
||||
}
|
||||
|
||||
const PARTICLE_COUNT = 400
|
||||
const FADE_ALPHA = 0.93
|
||||
|
||||
/**
|
||||
* OceanCurrentParticleLayer
|
||||
*
|
||||
* Canvas 2D + requestAnimationFrame 패턴으로 해류 흐름 시각화
|
||||
* u,v 벡터 격자 데이터를 IDW 보간하여 파티클 애니메이션 렌더링
|
||||
* 바람 파티클 대비: 적은 입자, 느린 속도, 긴 트레일
|
||||
*/
|
||||
export function OceanCurrentParticleLayer({ visible }: OceanCurrentParticleLayerProps) {
|
||||
const { current: mapRef } = useMap()
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const particlesRef = useRef<Particle[]>([])
|
||||
const animFrameRef = useRef<number>(0)
|
||||
|
||||
const initParticles = useCallback((width: number, height: number) => {
|
||||
particlesRef.current = []
|
||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||
particlesRef.current.push({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
age: Math.floor(Math.random() * 150),
|
||||
maxAge: 120 + Math.floor(Math.random() * 60),
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef?.getMap()
|
||||
if (!map) return
|
||||
|
||||
if (!visible) {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.remove()
|
||||
canvasRef.current = null
|
||||
}
|
||||
cancelAnimationFrame(animFrameRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
const container = map.getContainer()
|
||||
|
||||
// Canvas 생성 또는 재사용
|
||||
let canvas = canvasRef.current
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas')
|
||||
canvas.style.position = 'absolute'
|
||||
canvas.style.top = '0'
|
||||
canvas.style.left = '0'
|
||||
canvas.style.pointerEvents = 'none'
|
||||
canvas.style.zIndex = '440'
|
||||
container.appendChild(canvas)
|
||||
canvasRef.current = canvas
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
if (!canvas) return
|
||||
const { clientWidth: w, clientHeight: h } = container
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
}
|
||||
resize()
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
initParticles(canvas.width, canvas.height)
|
||||
|
||||
// 오프스크린 캔버스 (트레일 효과)
|
||||
let offCanvas: HTMLCanvasElement | null = null
|
||||
let offCtx: CanvasRenderingContext2D | null = null
|
||||
|
||||
function animate() {
|
||||
if (!ctx || !canvas) return
|
||||
|
||||
// 오프스크린 캔버스 크기 동기화
|
||||
if (!offCanvas || offCanvas.width !== canvas.width || offCanvas.height !== canvas.height) {
|
||||
offCanvas = document.createElement('canvas')
|
||||
offCanvas.width = canvas.width
|
||||
offCanvas.height = canvas.height
|
||||
offCtx = offCanvas.getContext('2d')
|
||||
}
|
||||
|
||||
if (!offCtx) return
|
||||
|
||||
// 트레일 페이드 효과 (느린 페이드 = 부드러운 흐름)
|
||||
offCtx.globalCompositeOperation = 'destination-in'
|
||||
offCtx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})`
|
||||
offCtx.fillRect(0, 0, offCanvas.width, offCanvas.height)
|
||||
offCtx.globalCompositeOperation = 'source-over'
|
||||
|
||||
// 현재 지도 bounds 확인
|
||||
const bounds = map!.getBounds()
|
||||
|
||||
for (const particle of particlesRef.current) {
|
||||
particle.age++
|
||||
|
||||
// 수명 초과 시 리셋
|
||||
if (particle.age > particle.maxAge) {
|
||||
particle.x = Math.random() * canvas.width
|
||||
particle.y = Math.random() * canvas.height
|
||||
particle.age = 0
|
||||
particle.maxAge = 120 + Math.floor(Math.random() * 60)
|
||||
continue
|
||||
}
|
||||
|
||||
const { lat, lng } = containerPointToLatLng(map!, particle.x, particle.y)
|
||||
|
||||
// 화면 밖이면 리셋
|
||||
if (!bounds.contains([lng, lat])) {
|
||||
particle.x = Math.random() * canvas.width
|
||||
particle.y = Math.random() * canvas.height
|
||||
particle.age = 0
|
||||
continue
|
||||
}
|
||||
|
||||
// 육지 위이면 리셋
|
||||
if (isOnLand(lat, lng)) {
|
||||
particle.x = Math.random() * canvas.width
|
||||
particle.y = Math.random() * canvas.height
|
||||
particle.age = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const current = interpolateCurrent(lat, lng, CURRENT_DATA)
|
||||
const rad = (current.direction * Math.PI) / 180
|
||||
const pixelSpeed = current.speed * 2.0
|
||||
|
||||
const newX = particle.x + Math.sin(rad) * pixelSpeed
|
||||
const newY = particle.y + -Math.cos(rad) * pixelSpeed
|
||||
|
||||
// 다음 위치가 육지이면 리셋
|
||||
const nextPos = containerPointToLatLng(map!, newX, newY)
|
||||
if (isOnLand(nextPos.lat, nextPos.lng)) {
|
||||
particle.x = Math.random() * canvas.width
|
||||
particle.y = Math.random() * canvas.height
|
||||
particle.age = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const oldX = particle.x
|
||||
const oldY = particle.y
|
||||
particle.x = newX
|
||||
particle.y = newY
|
||||
|
||||
// 파티클 트레일 그리기
|
||||
const alpha = 1 - particle.age / particle.maxAge
|
||||
offCtx.strokeStyle = getCurrentColor(current.speed).replace('0.8', String(alpha * 0.8))
|
||||
offCtx.lineWidth = 0.8
|
||||
offCtx.beginPath()
|
||||
offCtx.moveTo(oldX, oldY)
|
||||
offCtx.lineTo(particle.x, particle.y)
|
||||
offCtx.stroke()
|
||||
}
|
||||
|
||||
// 메인 캔버스에 합성 (배경 오버레이 없이 파티클만)
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.drawImage(offCanvas, 0, 0)
|
||||
|
||||
animFrameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animate()
|
||||
|
||||
// 지도 이동/줌 시 리셋
|
||||
const onMoveEnd = () => {
|
||||
resize()
|
||||
if (canvas) initParticles(canvas.width, canvas.height)
|
||||
if (offCanvas && canvas) {
|
||||
offCanvas.width = canvas.width
|
||||
offCanvas.height = canvas.height
|
||||
}
|
||||
}
|
||||
map.on('moveend', onMoveEnd)
|
||||
map.on('zoomend', onMoveEnd)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animFrameRef.current)
|
||||
map.off('moveend', onMoveEnd)
|
||||
map.off('zoomend', onMoveEnd)
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.remove()
|
||||
canvasRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mapRef, visible, initParticles])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -6,12 +6,13 @@ import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { WeatherRightPanel } from './WeatherRightPanel'
|
||||
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
|
||||
import { OceanForecastOverlay } from './OceanForecastOverlay'
|
||||
import { useOceanCurrentLayers } from './OceanCurrentLayer'
|
||||
// import { OceanForecastOverlay } from './OceanForecastOverlay'
|
||||
// import { useOceanCurrentLayers } from './OceanCurrentLayer'
|
||||
import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
|
||||
import { WindParticleLayer } from './WindParticleLayer'
|
||||
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
|
||||
import { useWeatherData } from '../hooks/useWeatherData'
|
||||
import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||
import { WeatherMapControls } from './WeatherMapControls'
|
||||
|
||||
type TimeOffset = '0' | '3' | '6' | '9'
|
||||
@ -125,8 +126,6 @@ interface WeatherMapInnerProps {
|
||||
weatherStations: WeatherStation[]
|
||||
enabledLayers: Set<string>
|
||||
selectedStationId: string | null
|
||||
oceanForecastOpacity: number
|
||||
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
|
||||
onStationClick: (station: WeatherStation) => void
|
||||
mapCenter: [number, number]
|
||||
mapZoom: number
|
||||
@ -137,8 +136,6 @@ function WeatherMapInner({
|
||||
weatherStations,
|
||||
enabledLayers,
|
||||
selectedStationId,
|
||||
oceanForecastOpacity,
|
||||
selectedForecast,
|
||||
onStationClick,
|
||||
mapCenter,
|
||||
mapZoom,
|
||||
@ -151,18 +148,18 @@ function WeatherMapInner({
|
||||
selectedStationId,
|
||||
onStationClick
|
||||
)
|
||||
const oceanCurrentLayers = useOceanCurrentLayers({
|
||||
visible: enabledLayers.has('oceanCurrent'),
|
||||
opacity: 0.7,
|
||||
})
|
||||
// const oceanCurrentLayers = useOceanCurrentLayers({
|
||||
// visible: enabledLayers.has('oceanCurrent'),
|
||||
// opacity: 0.7,
|
||||
// })
|
||||
const waterTempLayers = useWaterTemperatureLayers({
|
||||
visible: enabledLayers.has('waterTemperature'),
|
||||
opacity: 0.5,
|
||||
})
|
||||
|
||||
const deckLayers = useMemo(
|
||||
() => [...oceanCurrentLayers, ...waterTempLayers, ...weatherDeckLayers],
|
||||
[oceanCurrentLayers, waterTempLayers, weatherDeckLayers]
|
||||
() => [...waterTempLayers, ...weatherDeckLayers],
|
||||
[waterTempLayers, weatherDeckLayers]
|
||||
)
|
||||
|
||||
return (
|
||||
@ -170,11 +167,16 @@ function WeatherMapInner({
|
||||
{/* deck.gl 오버레이 */}
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
|
||||
{/* 해황예보도 — MapLibre image source + raster layer */}
|
||||
{/* 해황예보도 — 임시 비활성화
|
||||
<OceanForecastOverlay
|
||||
forecast={selectedForecast}
|
||||
opacity={oceanForecastOpacity}
|
||||
visible={enabledLayers.has('oceanForecast')}
|
||||
/> */}
|
||||
|
||||
{/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */}
|
||||
<OceanCurrentParticleLayer
|
||||
visible={enabledLayers.has('oceanCurrentParticle')}
|
||||
/>
|
||||
|
||||
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
|
||||
@ -224,13 +226,13 @@ export function WeatherView() {
|
||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
||||
|
||||
|
||||
const {
|
||||
selectedForecast,
|
||||
availableTimes,
|
||||
loading: oceanLoading,
|
||||
error: oceanError,
|
||||
selectForecast,
|
||||
} = useOceanForecast('KOREA')
|
||||
// const {
|
||||
// selectedForecast,
|
||||
// availableTimes,
|
||||
// loading: oceanLoading,
|
||||
// error: oceanError,
|
||||
// selectForecast,
|
||||
// } = useOceanForecast('KOREA')
|
||||
|
||||
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0')
|
||||
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null)
|
||||
@ -238,7 +240,7 @@ export function WeatherView() {
|
||||
null
|
||||
)
|
||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
|
||||
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
||||
// const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
||||
|
||||
// 첫 관측소 자동 선택 (파생 값)
|
||||
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
|
||||
@ -361,8 +363,6 @@ export function WeatherView() {
|
||||
weatherStations={weatherStations}
|
||||
enabledLayers={enabledLayers}
|
||||
selectedStationId={selectedStation?.id || null}
|
||||
oceanForecastOpacity={oceanForecastOpacity}
|
||||
selectedForecast={selectedForecast}
|
||||
onStationClick={handleStationClick}
|
||||
mapCenter={WEATHER_MAP_CENTER}
|
||||
mapZoom={WEATHER_MAP_ZOOM}
|
||||
@ -424,11 +424,11 @@ export function WeatherView() {
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledLayers.has('oceanCurrent')}
|
||||
onChange={() => toggleLayer('oceanCurrent')}
|
||||
checked={enabledLayers.has('oceanCurrentParticle')}
|
||||
onChange={() => toggleLayer('oceanCurrentParticle')}
|
||||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||||
/>
|
||||
<span className="text-xs text-text-2">🌊 해류 방향</span>
|
||||
<span className="text-xs text-text-2">🌊 해류 흐름</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@ -440,7 +440,7 @@ export function WeatherView() {
|
||||
<span className="text-xs text-text-2">🌡️ 수온 색상도</span>
|
||||
</label>
|
||||
|
||||
{/* 해황예보도 레이어 */}
|
||||
{/* 해황예보도 레이어 — 임시 비활성화
|
||||
<div className="pt-2 mt-2 border-t border-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@ -451,60 +451,8 @@ export function WeatherView() {
|
||||
/>
|
||||
<span className="text-xs text-text-2">🌊 해황예보도</span>
|
||||
</label>
|
||||
|
||||
{enabledLayers.has('oceanForecast') && (
|
||||
<div className="mt-2 ml-6 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-3">투명도:</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={oceanForecastOpacity * 100}
|
||||
onChange={(e) =>
|
||||
setOceanForecastOpacity(Number(e.target.value) / 100)
|
||||
}
|
||||
className="flex-1 h-1 bg-bg-3 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-text-3 w-8">
|
||||
{Math.round(oceanForecastOpacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{availableTimes.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-3">예보 시간:</div>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{availableTimes.map((time) => (
|
||||
<button
|
||||
key={`${time.day}-${time.hour}`}
|
||||
onClick={() => selectForecast(time.day, time.hour)}
|
||||
className={`w-full px-2 py-1 text-xs rounded transition-colors ${
|
||||
selectedForecast?.ofcFrcstYmd === time.day &&
|
||||
selectedForecast?.ofcFrcstTm === time.hour
|
||||
? 'bg-primary-cyan text-bg-0 font-semibold'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3'
|
||||
}`}
|
||||
>
|
||||
{time.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oceanLoading && <div className="text-xs text-text-3">로딩 중...</div>}
|
||||
{oceanError && <div className="text-xs text-status-red">오류 발생</div>}
|
||||
{selectedForecast && (
|
||||
<div className="text-xs text-text-3 pt-2 border-t border-border">
|
||||
현재: {selectedForecast.ofcBrnchNm} •{' '}
|
||||
{selectedForecast.ofcFrcstYmd.slice(4, 6)}/{selectedForecast.ofcFrcstYmd.slice(6, 8)}{' '}
|
||||
{selectedForecast.ofcFrcstTm}:00
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -536,6 +484,23 @@ export function WeatherView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 해류 */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-1">해류 (m/s)</div>
|
||||
<div className="flex items-center gap-1 h-3 rounded-sm overflow-hidden mb-1">
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(34, 197, 94)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-text-3 text-[9px]">
|
||||
<span>0.2</span>
|
||||
<span>0.4</span>
|
||||
<span>0.6</span>
|
||||
<span>0.6+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파고 */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-1">파고 (m)</div>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user