wing-ops/frontend/src/tabs/admin/components/DispersingZonePanel.tsx

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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <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;