SCAT 좌측패널 리팩토링, 해안조사 뷰 기능 보강, 기상 우측패널 중복 코드 제거 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
386 lines
16 KiB
TypeScript
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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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
|