248 lines
8.7 KiB
TypeScript
248 lines
8.7 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Map, useControl } from '@vis.gl/react-maplibre';
|
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
|
import { GeoJsonLayer } from '@deck.gl/layers';
|
|
import type { Layer } from '@deck.gl/core';
|
|
import type { StyleSpecification } from 'maplibre-gl';
|
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
|
|
// CartoDB Dark Matter 스타일
|
|
const MAP_STYLE: StyleSpecification = {
|
|
version: 8,
|
|
sources: {
|
|
'carto-dark': {
|
|
type: 'raster',
|
|
tiles: [
|
|
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
|
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
|
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
|
],
|
|
tileSize: 256,
|
|
attribution:
|
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
|
},
|
|
},
|
|
layers: [
|
|
{
|
|
id: 'carto-dark-layer',
|
|
type: 'raster',
|
|
source: 'carto-dark',
|
|
minzoom: 0,
|
|
maxzoom: 22,
|
|
},
|
|
],
|
|
};
|
|
|
|
const MAP_CENTER: [number, number] = [127.5, 36.0];
|
|
const MAP_ZOOM = 5.5;
|
|
|
|
const CONSIDER_FILL: [number, number, number, number] = [59, 130, 246, 60];
|
|
const CONSIDER_LINE: [number, number, number, number] = [59, 130, 246, 220];
|
|
const RESTRICT_FILL: [number, number, number, number] = [239, 68, 68, 60];
|
|
const RESTRICT_LINE: [number, number, number, number] = [239, 68, 68, 220];
|
|
|
|
type ZoneKey = 'consider' | 'restrict';
|
|
|
|
// deck.gl 오버레이 컴포넌트
|
|
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
|
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
|
overlay.setProps({ layers });
|
|
return null;
|
|
}
|
|
|
|
// 구역 설명 데이터
|
|
const ZONE_INFO: Record<ZoneKey, { label: string; rows: { key: string; value: string }[] }> = {
|
|
consider: {
|
|
label: '사용고려해역',
|
|
rows: [
|
|
{ key: '수심', value: '20m 이상 ※ (IMO) 대형 20m, 중소형 10m 이상' },
|
|
{
|
|
key: '사용거리',
|
|
value:
|
|
'해안 2km, 중요 민감자원으로부터 5km 이상 떨어진 경우 ※ (IMO) 대형 1km, 중소형 0.5km 이상',
|
|
},
|
|
{ key: '사용승인(절차)', value: '현장 방제책임자 재량 사용 ※ (IMO) 의결정 절차 지침' },
|
|
],
|
|
},
|
|
restrict: {
|
|
label: '사용제한해역',
|
|
rows: [
|
|
{ key: '수심', value: '수심 10m 이하' },
|
|
{
|
|
key: '사용거리',
|
|
value:
|
|
'어장·양식장, 발전소 취수구, 종묘배양장 및 폐쇄성 해역 특정해역중 수자원 보호구역',
|
|
},
|
|
{
|
|
key: '사용승인(절차)',
|
|
value:
|
|
'심의위원회 승인을 받아 관할 방제책임기관 또는 방제대책 본부장이 결정 ※ 긴급한 경우 先사용, 後심의',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const DispersingZonePanel = () => {
|
|
const [showConsider, setShowConsider] = useState(true);
|
|
const [showRestrict, setShowRestrict] = useState(true);
|
|
const [expandedZone, setExpandedZone] = useState<ZoneKey | null>(null);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const [considerData, setConsiderData] = useState<any>(null);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const [restrictData, setRestrictData] = useState<any>(null);
|
|
|
|
useEffect(() => {
|
|
fetch('/dispersant-consider.geojson')
|
|
.then(r => r.json())
|
|
.then(setConsiderData)
|
|
.catch(() => {/* GeoJSON 없을 때 빈 상태 유지 */});
|
|
|
|
fetch('/dispersant-restrict.geojson')
|
|
.then(r => r.json())
|
|
.then(setRestrictData)
|
|
.catch(() => {/* GeoJSON 없을 때 빈 상태 유지 */});
|
|
}, []);
|
|
|
|
const layers: Layer[] = [
|
|
...(showConsider && considerData
|
|
? [
|
|
new GeoJsonLayer({
|
|
id: 'dispersant-consider',
|
|
data: considerData,
|
|
getFillColor: CONSIDER_FILL,
|
|
getLineColor: CONSIDER_LINE,
|
|
lineWidthMinPixels: 1.5,
|
|
pickable: false,
|
|
}),
|
|
]
|
|
: []),
|
|
...(showRestrict && restrictData
|
|
? [
|
|
new GeoJsonLayer({
|
|
id: 'dispersant-restrict',
|
|
data: restrictData,
|
|
getFillColor: RESTRICT_FILL,
|
|
getLineColor: RESTRICT_LINE,
|
|
lineWidthMinPixels: 1.5,
|
|
pickable: false,
|
|
}),
|
|
]
|
|
: []),
|
|
];
|
|
|
|
const handleToggleExpand = (zone: ZoneKey) => {
|
|
setExpandedZone(prev => (prev === zone ? null : zone));
|
|
};
|
|
|
|
const renderZoneCard = (zone: ZoneKey) => {
|
|
const info = ZONE_INFO[zone];
|
|
const isConsider = zone === 'consider';
|
|
const showLayer = isConsider ? showConsider : showRestrict;
|
|
const setShowLayer = isConsider ? setShowConsider : setShowRestrict;
|
|
const swatchColor = isConsider ? 'bg-blue-500' : 'bg-red-500';
|
|
const isExpanded = expandedZone === zone;
|
|
|
|
return (
|
|
<div key={zone} className="border border-border rounded-lg overflow-hidden">
|
|
{/* 카드 헤더 */}
|
|
<div
|
|
className="flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-[rgba(255,255,255,0.03)] transition-colors"
|
|
onClick={() => handleToggleExpand(zone)}
|
|
>
|
|
<span className={`w-3 h-3 rounded-sm shrink-0 ${swatchColor}`} />
|
|
<span className="flex-1 text-xs font-semibold text-text-1 font-korean">{info.label}</span>
|
|
{/* 토글 스위치 */}
|
|
<button
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
setShowLayer(prev => !prev);
|
|
}}
|
|
title={showLayer ? '레이어 숨기기' : '레이어 표시'}
|
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 ${
|
|
showLayer
|
|
? 'bg-primary-cyan'
|
|
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
|
showLayer ? 'translate-x-[18px]' : 'translate-x-0.5'
|
|
}`}
|
|
/>
|
|
</button>
|
|
{/* 펼침 화살표 */}
|
|
<span className="text-text-3 text-[10px] shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
|
</div>
|
|
|
|
{/* 펼침 영역 */}
|
|
{isExpanded && (
|
|
<div className="border-t border-border px-3 py-3">
|
|
<table className="w-full">
|
|
<tbody>
|
|
{info.rows.map(row => (
|
|
<tr key={row.key} className="border-b border-border last:border-0">
|
|
<td className="py-2 pr-2 text-[11px] text-text-3 font-korean whitespace-nowrap align-top w-24">
|
|
{row.key}
|
|
</td>
|
|
<td className="py-2 text-[11px] text-text-2 font-korean leading-relaxed">
|
|
{row.value}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* 지도 영역 */}
|
|
<div className="flex-1 relative">
|
|
<Map
|
|
initialViewState={{
|
|
longitude: MAP_CENTER[0],
|
|
latitude: MAP_CENTER[1],
|
|
zoom: MAP_ZOOM,
|
|
}}
|
|
style={{ width: '100%', height: '100%' }}
|
|
mapStyle={MAP_STYLE}
|
|
>
|
|
<DeckGLOverlay layers={layers} />
|
|
</Map>
|
|
|
|
{/* 범례 */}
|
|
<div className="absolute bottom-4 left-4 bg-bg-1 border border-border rounded-lg px-3 py-2 flex flex-col gap-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-3 h-3 rounded-sm bg-blue-500 opacity-80" />
|
|
<span className="text-[11px] text-text-2 font-korean">사용고려해역</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-3 h-3 rounded-sm bg-red-500 opacity-80" />
|
|
<span className="text-[11px] text-text-2 font-korean">사용제한해역</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 우측 패널 */}
|
|
<div className="w-[280px] bg-bg-1 border-l border-border flex flex-col overflow-hidden shrink-0">
|
|
{/* 헤더 */}
|
|
<div className="px-4 py-4 border-b border-border shrink-0">
|
|
<h1 className="text-sm font-bold text-text-1 font-korean">유처리제 제한구역</h1>
|
|
<p className="text-[11px] text-text-3 mt-0.5 font-korean">해양환경관리법 기준</p>
|
|
</div>
|
|
|
|
{/* 구역 카드 목록 */}
|
|
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
|
|
{renderZoneCard('consider')}
|
|
{renderZoneCard('restrict')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DispersingZonePanel;
|