409 lines
16 KiB
TypeScript
409 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 'maplibre-gl/dist/maplibre-gl.css';
|
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
|
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
|
import { useMapStore } from '@common/store/mapStore';
|
|
import type { ScatSegment } from './scatTypes';
|
|
import type { ApiZoneItem } from '../services/scatApi';
|
|
import { esiColor } from './scatConstants';
|
|
import { hexToRgba } from '@common/components/map/mapUtils';
|
|
|
|
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,
|
|
segments: ScatSegment[],
|
|
): [number, number][] {
|
|
const idx = segments.indexOf(seg);
|
|
const prev = idx > 0 ? segments[idx - 1] : seg;
|
|
const next = idx < segments.length - 1 ? segments[idx + 1] : seg;
|
|
const dlat = next.lat - prev.lat;
|
|
const dlng = next.lng - prev.lng;
|
|
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 currentMapStyle = useBaseMapStyle();
|
|
const mapToggles = useMapStore((s) => s.mapToggles);
|
|
|
|
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]);
|
|
|
|
// 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정
|
|
// 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, segments),
|
|
getColor: [34, 197, 94, 38],
|
|
getWidth: zs.glowWidth,
|
|
capRounded: true,
|
|
jointRounded: true,
|
|
widthMinPixels: 4,
|
|
updateTriggers: {
|
|
getPath: [zs.halfLenScale],
|
|
getWidth: [zs.glowWidth],
|
|
},
|
|
}),
|
|
[selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
|
|
);
|
|
|
|
// ESI 색상 세그먼트 폴리라인
|
|
const segPathLayer = useMemo(
|
|
() =>
|
|
new PathLayer({
|
|
id: 'scat-segments',
|
|
data: segments,
|
|
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
|
|
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[] = [glowLayer, segPathLayer];
|
|
if (markerLayer) layers.push(markerLayer);
|
|
return layers;
|
|
}, [glowLayer, segPathLayer, markerLayer]);
|
|
|
|
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);
|
|
|
|
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={currentMapStyle}
|
|
className="w-full h-full"
|
|
attributionControl={false}
|
|
onZoom={(e) => setZoom(e.viewState.zoom)}
|
|
>
|
|
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
|
<DeckGLOverlay layers={deckLayers} />
|
|
<FlyToController selectedSeg={selectedSeg} zones={zones} />
|
|
</Map>
|
|
|
|
{/* 호버 툴팁 */}
|
|
{tooltip && (
|
|
<div
|
|
className="pointer-events-none text-fg rounded-[6px] px-2 py-1 font-korean text-label-2"
|
|
style={{
|
|
position: 'absolute',
|
|
left: tooltip.x + 12,
|
|
top: tooltip.y - 48,
|
|
background: 'color-mix(in srgb, var(--bg-base) 92%, transparent)',
|
|
border: '1px solid var(--stroke-default)',
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
|
zIndex: 1000,
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
<div className="font-bold">
|
|
{tooltip.seg.code} {tooltip.seg.area}
|
|
</div>
|
|
<div className="text-caption opacity-70">
|
|
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-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub font-korean">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-color-success shadow-[0_0_6px_var(--color-success)]" />
|
|
Pre-SCAT 사전조사
|
|
</div>
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub 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-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
|
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled 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-label-2">
|
|
<span
|
|
className="w-3.5 h-1.5 rounded-sm flex-shrink-0"
|
|
style={{ background: item.color }}
|
|
/>
|
|
<span className="text-fg-sub font-korean">{item.label}</span>
|
|
<span className="ml-auto font-mono text-caption text-fg">{item.esi}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Progress */}
|
|
<div className="bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
|
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled 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(--color-success)' }}
|
|
/>
|
|
<div
|
|
className="h-full transition-all duration-500"
|
|
style={{ width: `${progPct}%`, background: 'var(--color-warning)' }}
|
|
/>
|
|
<div
|
|
className="h-full transition-all duration-500"
|
|
style={{ width: `${notPct}%`, background: 'var(--stroke-default)' }}
|
|
/>
|
|
</div> */}
|
|
{/* <div className="flex justify-between mt-1">
|
|
<span className="text-caption font-mono text-color-success">완료 {donePct}%</span>
|
|
<span className="text-caption font-mono text-color-warning">진행 {progPct}%</span>
|
|
<span className="text-caption font-mono text-fg-disabled">미조사 {notPct}%</span>
|
|
</div> */}
|
|
<div className="mt-2.5">
|
|
{[
|
|
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
|
|
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--color-success)'],
|
|
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--color-danger)'],
|
|
[
|
|
'방제 우선 구간',
|
|
`${segments.filter((s) => s.sensitivity === '최상').length}개`,
|
|
'var(--color-warning)',
|
|
],
|
|
].map(([label, val, color], i) => (
|
|
<div
|
|
key={i}
|
|
className="flex justify-between py-1.5 border-b border-stroke last:border-b-0 text-label-2"
|
|
>
|
|
<span className="text-fg-sub font-korean">{label}</span>
|
|
<span
|
|
className="font-mono font-medium text-label-2"
|
|
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-stroke rounded-sm px-3 py-1.5 font-mono text-label-2 text-fg-sub flex gap-3.5">
|
|
<span>
|
|
위도 <span className="text-color-success font-medium">{selectedSeg.lat.toFixed(4)}°N</span>
|
|
</span>
|
|
<span>
|
|
경도 <span className="text-color-success font-medium">{selectedSeg.lng.toFixed(4)}°E</span>
|
|
</span>
|
|
<span>
|
|
축척 <span className="text-color-success font-medium">1:25,000</span>
|
|
</span>
|
|
</div> */}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ScatMap;
|