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>
277 lines
12 KiB
TypeScript
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(
|
|
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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
|