wing-ops/frontend/src/tabs/scat/components/ScatMap.tsx
htlee c727afd1ba refactor(frontend): 대형 View 서브탭 단위 분할 + FEATURE_ID 체계 도입
6개 대형 View(AerialView, AssetsView, ReportsView, PreScatView, AdminView, LeftPanel)를
서브탭 단위로 분할하여 모듈 경계를 명확히 함.

- AerialView (2,526줄 → 8파일): MediaManagement, OilAreaAnalysis, RealtimeDrone 등
- AssetsView (2,047줄 → 8파일): AssetManagement, AssetMap, ShipInsurance 등
- ReportsView (1,596줄 → 5파일): TemplateFormEditor, ReportGenerator 등
- PreScatView (1,390줄 → 7파일): ScatLeftPanel, ScatMap, ScatPopup 등
- AdminView (1,306줄 → 7파일): UsersPanel, PermissionsPanel, MenusPanel 등
- LeftPanel (1,237줄 → 5파일): PredictionInputSection, InfoLayerSection, OilBoomSection 등

FEATURE_ID 레지스트리(common/constants/featureIds.ts) 및
감사로그 서브탭 추적 훅(useFeatureTracking) 추가.

.gitignore의 scat/ → /scat/ 수정 (scat 탭 파일 추적 누락 수정)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:19:22 +09:00

277 lines
12 KiB
TypeScript

import { useState, useEffect, useRef } from 'react'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { ScatSegment } from './scatTypes'
import { esiColor, jejuCoastCoords, scatDetailData } from './scatConstants'
interface ScatMapProps {
segments: ScatSegment[]
selectedSeg: ScatSegment
onSelectSeg: (s: ScatSegment) => void
onOpenPopup: (idx: number) => void
}
function ScatMap({
segments,
selectedSeg,
onSelectSeg,
onOpenPopup,
}: ScatMapProps) {
const mapContainerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const markersRef = useRef<L.LayerGroup | null>(null)
const [zoom, setZoom] = useState(10)
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) return
const map = L.map(mapContainerRef.current, {
center: [33.38, 126.55],
zoom: 10,
zoomControl: false,
attributionControl: false,
})
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map)
L.control.zoom({ position: 'bottomright' }).addTo(map)
L.control.attribution({ position: 'bottomleft' }).addAttribution(
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>'
).addTo(map)
map.on('zoomend', () => setZoom(map.getZoom()))
mapRef.current = map
markersRef.current = L.layerGroup().addTo(map)
setTimeout(() => map.invalidateSize(), 100)
return () => {
map.remove()
mapRef.current = null
markersRef.current = null
}
}, [])
useEffect(() => {
if (!mapRef.current || !markersRef.current) return
markersRef.current.clearLayers()
// 줌 기반 스케일 계수 (zoom 10=아일랜드뷰 → 14+=클로즈업)
const zScale = Math.max(0, (zoom - 9)) / 5 // 0 at z9, 1 at z14
const polyWeight = 1 + zScale * 4 // 1 ~ 5
const selPolyWeight = 2 + zScale * 5 // 2 ~ 7
const glowWeight = 4 + zScale * 14 // 4 ~ 18
const halfLenScale = 0.15 + zScale * 0.85 // 0.15 ~ 1.0
const markerSize = Math.round(6 + zScale * 16) // 6px ~ 22px
const markerBorder = zoom >= 13 ? 2 : 1
const markerFontSize = Math.round(4 + zScale * 6) // 4px ~ 10px
const showStatusMarker = zoom >= 11
const showStatusText = zoom >= 13
// 제주도 해안선 레퍼런스 라인
const coastline = L.polyline(jejuCoastCoords as [number, number][], {
color: 'rgba(6, 182, 212, 0.18)',
weight: 1.5,
dashArray: '8, 6',
})
markersRef.current.addLayer(coastline)
segments.forEach(seg => {
const isSelected = selectedSeg.id === seg.id
const color = esiColor(seg.esiNum)
// 해안선 방향 계산 (세그먼트 폴리라인 각도 결정)
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
// 해안선 방향을 따라 폴리라인 좌표 생성
const segCoords: [number, number][] = [
[seg.lat - nDlat * halfLen, seg.lng - nDlng * halfLen],
[seg.lat, seg.lng],
[seg.lat + nDlat * halfLen, seg.lng + nDlng * halfLen],
]
// 선택된 구간 글로우 효과
if (isSelected) {
const glow = L.polyline(segCoords, {
color: '#22c55e',
weight: glowWeight,
opacity: 0.15,
lineCap: 'round',
})
markersRef.current!.addLayer(glow)
}
// ESI 색상 구간 폴리라인
const polyline = L.polyline(segCoords, {
color: isSelected ? '#22c55e' : color,
weight: isSelected ? selPolyWeight : polyWeight,
opacity: isSelected ? 0.95 : 0.7,
lineCap: 'round',
lineJoin: 'round',
})
const statusIcon = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—'
polyline.bindTooltip(
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
<div style="font-weight:700;font-size:11px;">${seg.code} ${seg.area}</div>
<div style="font-size:10px;opacity:0.7;">ESI ${seg.esi} · ${seg.length} · ${statusIcon} ${seg.status}</div>
</div>`,
{
permanent: isSelected,
direction: 'top',
offset: [0, -10],
className: 'scat-map-tooltip',
}
)
polyline.on('click', () => {
onSelectSeg(seg)
onOpenPopup(seg.id % scatDetailData.length)
})
markersRef.current!.addLayer(polyline)
// 조사 상태 마커 (DivIcon) — 줌 레벨에 따라 표시/크기 조절
if (showStatusMarker) {
const stColor = seg.status === '완료' ? '#22c55e' : seg.status === '진행중' ? '#eab308' : '#64748b'
const stBg = seg.status === '완료' ? 'rgba(34,197,94,0.2)' : seg.status === '진행중' ? 'rgba(234,179,8,0.2)' : 'rgba(100,116,139,0.2)'
const stText = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—'
const half = Math.round(markerSize / 2)
const statusMarker = L.marker([seg.lat, seg.lng], {
icon: L.divIcon({
className: '',
html: `<div style="width:${markerSize}px;height:${markerSize}px;border-radius:50%;background:${stBg};border:${markerBorder}px solid ${stColor};display:flex;align-items:center;justify-content:center;font-size:${markerFontSize}px;color:${stColor};transform:translate(-${half}px,-${half}px);backdrop-filter:blur(4px);box-shadow:0 0 ${Math.round(markerSize / 3)}px ${stBg}">${showStatusText ? stText : ''}</div>`,
iconSize: [0, 0],
}),
})
statusMarker.on('click', () => {
onSelectSeg(seg)
onOpenPopup(seg.id % scatDetailData.length)
})
markersRef.current!.addLayer(statusMarker)
}
})
}, [segments, selectedSeg, onSelectSeg, onOpenPopup, zoom])
useEffect(() => {
if (!mapRef.current) return
mapRef.current.flyTo([selectedSeg.lat, selectedSeg.lng], 12, { duration: 0.6 })
}, [selectedSeg])
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">
<style>{`
.scat-map-tooltip {
background: rgba(15,21,36,0.92) !important;
border: 1px solid rgba(30,42,66,0.8) !important;
color: #e4e8f1 !important;
border-radius: 6px !important;
padding: 4px 8px !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important;
}
.scat-map-tooltip::before {
border-top-color: rgba(15,21,36,0.92) !important;
}
`}</style>
<div ref={mapContainerRef} className="w-full h-full" />
{/* 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">
· {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" style={{ color: 'var(--green)' }}> {donePct}%</span>
<span className="text-[9px] font-mono" style={{ color: 'var(--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