wing-ops/frontend/src/tabs/scat/components/ScatMap.tsx
leedano 9881b99ee7 feat(scat): Pre-SCAT 해안조사 UI 개선 + WeatherRightPanel 정리
SCAT 좌측패널 리팩토링, 해안조사 뷰 기능 보강, 기상 우측패널 중복 코드 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:22:20 +09:00

386 lines
16 KiB
TypeScript

import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox'
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'
import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import type { ScatSegment } from './scatTypes'
import type { ApiZoneItem } from '../services/scatApi'
import { esiColor, jejuCoastCoords } from './scatConstants'
import { hexToRgba } from '@common/components/map/mapUtils'
const BASE_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
}
interface ScatMapProps {
segments: ScatSegment[]
zones: ApiZoneItem[]
selectedSeg: ScatSegment
jurisdictionFilter: string
onSelectSeg: (s: ScatSegment) => void
onOpenPopup: (idx: number) => void
}
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
function FlyToController({ selectedSeg, zones }: { selectedSeg: ScatSegment; zones: ApiZoneItem[] }) {
const { current: map } = useMap()
const prevIdRef = useRef<number | undefined>(undefined)
const prevZonesLenRef = useRef<number>(0)
// 선택 구간 변경 시
useEffect(() => {
if (!map) return
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) {
map.flyTo({ center: [selectedSeg.lng, selectedSeg.lat], zoom: 12, duration: 600 })
}
prevIdRef.current = selectedSeg.id
}, [map, selectedSeg])
// 관할해경(zones) 변경 시 지도 중심 이동
useEffect(() => {
if (!map || zones.length === 0) return
if (prevZonesLenRef.current === zones.length) return
prevZonesLenRef.current = zones.length
const validZones = zones.filter(z => z.latCenter && z.lngCenter)
if (validZones.length === 0) return
const avgLat = validZones.reduce((a, z) => a + z.latCenter, 0) / validZones.length
const avgLng = validZones.reduce((a, z) => a + z.lngCenter, 0) / validZones.length
map.flyTo({ center: [avgLng, avgLat], zoom: 9, duration: 800 })
}, [map, zones])
return null
}
// ── 줌 기반 스케일 계산 ─────────────────────────────────
function getZoomScale(zoom: number) {
const zScale = Math.max(0, zoom - 9) / 5
return {
polyWidth: 1 + zScale * 4,
selPolyWidth: 2 + zScale * 5,
glowWidth: 4 + zScale * 14,
halfLenScale: 0.15 + zScale * 0.85,
markerRadius: Math.round(6 + zScale * 16),
showStatusMarker: zoom >= 11,
}
}
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
function buildSegCoords(seg: ScatSegment, halfLenScale: number): [number, number][] {
const coastIdx = seg.id % (jejuCoastCoords.length - 1)
const [clat1, clng1] = jejuCoastCoords[coastIdx]
const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length]
const dlat = clat2 - clat1
const dlng = clng2 - clng1
const dist = Math.sqrt(dlat * dlat + dlng * dlng)
const nDlat = dist > 0 ? dlat / dist : 0
const nDlng = dist > 0 ? dlng / dist : 1
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale
return [
[seg.lng - nDlng * halfLen, seg.lat - nDlat * halfLen],
[seg.lng, seg.lat],
[seg.lng + nDlng * halfLen, seg.lat + nDlat * halfLen],
]
}
// ── 툴팁 상태 ───────────────────────────────────────────
interface TooltipState {
x: number
y: number
seg: ScatSegment
}
// ── ScatMap ─────────────────────────────────────────────
function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg, onOpenPopup }: ScatMapProps) {
const [zoom, setZoom] = useState(10)
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
const handleClick = useCallback(
(seg: ScatSegment) => {
onSelectSeg(seg)
onOpenPopup(seg.id)
},
[onSelectSeg, onOpenPopup],
)
const zs = useMemo(() => getZoomScale(zoom), [zoom])
// 제주도 해안선 레퍼런스 라인
const coastlineLayer = useMemo(
() =>
new PathLayer({
id: 'jeju-coastline',
data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [6, 182, 212, 46],
getWidth: 1.5,
getDashArray: [8, 6],
dashJustified: true,
widthMinPixels: 1,
}),
[],
)
// 선택된 구간 글로우 레이어
const glowLayer = useMemo(
() =>
new PathLayer({
id: 'scat-glow',
data: [selectedSeg],
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale),
getColor: [34, 197, 94, 38],
getWidth: zs.glowWidth,
capRounded: true,
jointRounded: true,
widthMinPixels: 4,
updateTriggers: {
getPath: [zs.halfLenScale],
getWidth: [zs.glowWidth],
},
}),
[selectedSeg, zs.glowWidth, zs.halfLenScale],
)
// ESI 색상 세그먼트 폴리라인
const segPathLayer = useMemo(
() =>
new PathLayer({
id: 'scat-segments',
data: segments,
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale),
getColor: (d: ScatSegment) => {
const isSelected = selectedSeg.id === d.id
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum)
return hexToRgba(hexCol, isSelected ? 242 : 178)
},
getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth),
capRounded: true,
jointRounded: true,
widthMinPixels: 1,
pickable: true,
onHover: (info: { object?: ScatSegment; x: number; y: number }) => {
if (info.object) {
setTooltip({ x: info.x, y: info.y, seg: info.object })
} else {
setTooltip(null)
}
},
onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object)
},
updateTriggers: {
getColor: [selectedSeg.id],
getWidth: [selectedSeg.id, zs.selPolyWidth, zs.polyWidth],
getPath: [zs.halfLenScale],
},
}),
[segments, selectedSeg, zs, handleClick],
)
// 조사 상태 마커 (줌 >= 11 시 표시)
const markerLayer = useMemo(() => {
if (!zs.showStatusMarker) return null
return new ScatterplotLayer({
id: 'scat-status-markers',
data: segments,
getPosition: (d: ScatSegment) => [d.lng, d.lat],
getRadius: zs.markerRadius,
getFillColor: (d: ScatSegment) => {
if (d.status === '완료') return [34, 197, 94, 51]
if (d.status === '진행중') return [234, 179, 8, 51]
return [100, 116, 139, 51]
},
getLineColor: (d: ScatSegment) => {
if (d.status === '완료') return [34, 197, 94, 200]
if (d.status === '진행중') return [234, 179, 8, 200]
return [100, 116, 139, 200]
},
getLineWidth: 1,
stroked: true,
radiusMinPixels: 4,
radiusMaxPixels: 22,
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: ScatSegment }) => {
if (info.object) handleClick(info.object)
},
updateTriggers: {
getRadius: [zs.markerRadius],
},
})
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers: any[] = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layers: any[] = [coastlineLayer, glowLayer, segPathLayer]
if (markerLayer) layers.push(markerLayer)
return layers
}, [coastlineLayer, glowLayer, segPathLayer, markerLayer])
const doneCount = segments.filter(s => s.status === '완료').length
const progCount = segments.filter(s => s.status === '진행중').length
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0)
const doneLen = segments.filter(s => s.status === '완료').reduce((a, s) => a + s.lengthM, 0)
const highSens = segments
.filter(s => s.sensitivity === '최상' || s.sensitivity === '상')
.reduce((a, s) => a + s.lengthM, 0)
const donePct = Math.round((doneCount / segments.length) * 100)
const progPct = Math.round((progCount / segments.length) * 100)
const notPct = 100 - donePct - progPct
return (
<div className="absolute inset-0 overflow-hidden">
<Map
initialViewState={(() => {
if (zones.length > 0) {
const avgLng = zones.reduce((a, z) => a + z.lngCenter, 0) / zones.length;
const avgLat = zones.reduce((a, z) => a + z.latCenter, 0) / zones.length;
return { longitude: avgLng, latitude: avgLat, zoom: 10 };
}
return { longitude: 126.55, latitude: 33.38, zoom: 10 };
})()}
mapStyle={BASE_STYLE}
className="w-full h-full"
attributionControl={false}
onZoom={e => setZoom(e.viewState.zoom)}
>
<DeckGLOverlay layers={deckLayers} />
<FlyToController selectedSeg={selectedSeg} zones={zones} />
</Map>
{/* 호버 툴팁 */}
{tooltip && (
<div
className="pointer-events-none"
style={{
position: 'absolute',
left: tooltip.x + 12,
top: tooltip.y - 48,
background: 'rgba(15,21,36,0.92)',
border: '1px solid rgba(30,42,66,0.8)',
color: '#e4e8f1',
borderRadius: 6,
padding: '4px 8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
fontSize: 11,
fontFamily: "'Noto Sans KR', sans-serif",
zIndex: 1000,
whiteSpace: 'nowrap',
}}
>
<div style={{ fontWeight: 700 }}>
{tooltip.seg.code} {tooltip.seg.area}
</div>
<div style={{ fontSize: 10, opacity: 0.7 }}>
ESI {tooltip.seg.esi} · {tooltip.seg.length} ·{' '}
{tooltip.seg.status === '완료' ? '✓' : tooltip.seg.status === '진행중' ? '⏳' : '—'}{' '}
{tooltip.seg.status}
</div>
</div>
)}
{/* Status chips */}
<div className="absolute top-3.5 left-3.5 flex gap-2 z-[1000]">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-full text-[11px] text-text-2 font-korean">
<span className="w-1.5 h-1.5 rounded-full bg-status-green shadow-[0_0_6px_var(--green)]" />
Pre-SCAT
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-full text-[11px] text-text-2 font-korean">
{jurisdictionFilter || '전체'} · {segments.length}
</div>
</div>
{/* Right info cards */}
<div className="absolute top-3.5 right-3.5 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
{/* ESI Legend */}
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">ESI </div>
{[
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
{ esi: 'ESI 8', label: '쉘터 암반 해안', color: '#dc2626' },
{ esi: 'ESI 7', label: '노출 갯벌', color: '#ef4444' },
{ esi: 'ESI 6', label: '자갈·혼합 해안', color: '#f97316' },
{ esi: 'ESI 5', label: '혼합 모래/자갈', color: '#fb923c' },
{ esi: 'ESI 3-4', label: '모래 해안', color: '#facc15' },
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
].map((item, i) => (
<div key={i} className="flex items-center gap-2 py-1 text-[11px]">
<span className="w-3.5 h-1.5 rounded-sm flex-shrink-0" style={{ background: item.color }} />
<span className="text-text-2 font-korean">{item.label}</span>
<span className="ml-auto font-mono text-[10px] text-text-1">{item.esi}</span>
</div>
))}
</div>
{/* Progress */}
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5"> </div>
<div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
<div className="h-full transition-all duration-500" style={{ width: `${donePct}%`, background: 'var(--green)' }} />
<div className="h-full transition-all duration-500" style={{ width: `${progPct}%`, background: 'var(--orange)' }} />
<div className="h-full transition-all duration-500" style={{ width: `${notPct}%`, background: 'var(--bd)' }} />
</div>
<div className="flex justify-between mt-1">
<span className="text-[9px] font-mono text-status-green"> {donePct}%</span>
<span className="text-[9px] font-mono text-status-orange"> {progPct}%</span>
<span className="text-[9px] font-mono text-text-3"> {notPct}%</span>
</div>
<div className="mt-2.5">
{[
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--green)'],
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--red)'],
['방제 우선 구간', `${segments.filter(s => s.sensitivity === '최상').length}`, 'var(--orange)'],
].map(([label, val, color], i) => (
<div key={i} className="flex justify-between py-1.5 border-b border-[rgba(30,42,74,0.3)] last:border-b-0 text-[11px]">
<span className="text-text-2 font-korean">{label}</span>
<span className="font-mono font-medium text-[11px]" style={{ color: color || undefined }}>
{val}
</span>
</div>
))}
</div>
</div>
</div>
{/* Coordinates */}
{/* <div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-border rounded-sm px-3 py-1.5 font-mono text-[11px] text-text-2 flex gap-3.5">
<span>
위도 <span className="text-status-green font-medium">{selectedSeg.lat.toFixed(4)}°N</span>
</span>
<span>
경도 <span className="text-status-green font-medium">{selectedSeg.lng.toFixed(4)}°E</span>
</span>
<span>
축척 <span className="text-status-green font-medium">1:25,000</span>
</span>
</div> */}
</div>
)
}
export default ScatMap