React 19 + TypeScript + Vite + MapLibre 기반 해양 모니터링 대시보드. 선박 AIS, 항공기, CCTV, 위성, 해양 인프라 등 다중 레이어 지원. ESLint React Compiler 규칙 조정 및 lint 에러 수정 포함.
172 lines
6.9 KiB
TypeScript
172 lines
6.9 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
|
import type { PowerFacility } from '../services/infra';
|
|
|
|
// SVG Wind Turbine Icon
|
|
function WindTurbineIcon({ color, size = 14 }: { color: string; size?: number }) {
|
|
return (
|
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
|
{/* Tower */}
|
|
<line x1="12" y1="10" x2="11" y2="23" stroke={color} strokeWidth="1.5" />
|
|
<line x1="12" y1="10" x2="13" y2="23" stroke={color} strokeWidth="1.5" />
|
|
{/* Hub */}
|
|
<circle cx="12" cy="9" r="1.5" fill={color} />
|
|
{/* Blade 1 - top */}
|
|
<path d="M12 9 L11.5 1 Q12 0 12.5 1 Z" fill={color} opacity="0.9" />
|
|
{/* Blade 2 - bottom-right */}
|
|
<path d="M12 9 L18 14 Q18.5 13 17.5 12.5 Z" fill={color} opacity="0.9" />
|
|
{/* Blade 3 - bottom-left */}
|
|
<path d="M12 9 L6 14 Q5.5 13 6.5 12.5 Z" fill={color} opacity="0.9" />
|
|
{/* Base */}
|
|
<line x1="8" y1="23" x2="16" y2="23" stroke={color} strokeWidth="1.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
interface Props {
|
|
facilities: PowerFacility[];
|
|
}
|
|
|
|
// Source → icon & color
|
|
const SOURCE_STYLE: Record<string, { icon: string; color: string; label: string }> = {
|
|
nuclear: { icon: '☢️', color: '#e040fb', label: '원자력' },
|
|
coal: { icon: '🏭', color: '#795548', label: '석탄' },
|
|
gas: { icon: '🔥', color: '#ff9800', label: 'LNG' },
|
|
oil: { icon: '🛢️', color: '#5d4037', label: '석유' },
|
|
hydro: { icon: '💧', color: '#2196f3', label: '수력' },
|
|
solar: { icon: '☀️', color: '#ffc107', label: '태양광' },
|
|
wind: { icon: '🌀', color: '#00bcd4', label: '풍력' },
|
|
biomass: { icon: '🌿', color: '#4caf50', label: '바이오' },
|
|
};
|
|
|
|
const SUBSTATION_STYLE = { icon: '⚡', color: '#ffeb3b', label: '변전소' };
|
|
|
|
function getStyle(f: PowerFacility) {
|
|
if (f.type === 'substation') return SUBSTATION_STYLE;
|
|
return SOURCE_STYLE[f.source || ''] || { icon: '⚡', color: '#9e9e9e', label: '발전소' };
|
|
}
|
|
|
|
function formatVoltage(v?: string): string {
|
|
if (!v) return '';
|
|
const kv = parseInt(v) / 1000;
|
|
if (isNaN(kv)) return v;
|
|
return `${kv}kV`;
|
|
}
|
|
|
|
export function InfraLayer({ facilities }: Props) {
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
|
|
const plants = useMemo(() => facilities.filter(f => f.type === 'plant'), [facilities]);
|
|
const substations = useMemo(() => facilities.filter(f => f.type === 'substation'), [facilities]);
|
|
|
|
const selected = selectedId ? facilities.find(f => f.id === selectedId) ?? null : null;
|
|
|
|
return (
|
|
<>
|
|
{/* Substations — smaller, show at higher zoom */}
|
|
{substations.map(f => {
|
|
const s = getStyle(f);
|
|
return (
|
|
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedId(f.id); }}>
|
|
<div style={{
|
|
width: 7, height: 7, borderRadius: 1,
|
|
background: '#1a1a2e', border: `1px solid ${s.color}`,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: 4, cursor: 'pointer',
|
|
}}>
|
|
<span>{s.icon}</span>
|
|
</div>
|
|
</Marker>
|
|
);
|
|
})}
|
|
|
|
{/* Power plants — larger, always visible */}
|
|
{plants.map(f => {
|
|
const s = getStyle(f);
|
|
const isWind = f.source === 'wind';
|
|
return (
|
|
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedId(f.id); }}>
|
|
<div style={{
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
|
cursor: 'pointer', pointerEvents: 'auto',
|
|
}}>
|
|
{isWind ? (
|
|
<WindTurbineIcon color={s.color} size={14} />
|
|
) : (
|
|
<div style={{
|
|
width: 12, height: 12, borderRadius: 2,
|
|
background: '#111', border: `1px solid ${s.color}`,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: 7, boxShadow: `0 0 3px ${s.color}33`,
|
|
}}>
|
|
<span>{s.icon}</span>
|
|
</div>
|
|
)}
|
|
<div style={{
|
|
fontSize: 6, color: s.color, marginTop: 1,
|
|
textShadow: '0 0 3px #000, 0 0 3px #000',
|
|
whiteSpace: 'nowrap', fontWeight: 600,
|
|
}}>
|
|
{f.name.length > 10 ? f.name.slice(0, 10) + '..' : f.name}
|
|
</div>
|
|
</div>
|
|
</Marker>
|
|
);
|
|
})}
|
|
|
|
{/* Popup */}
|
|
{selected && (
|
|
<Popup longitude={selected.lng} latitude={selected.lat}
|
|
onClose={() => setSelectedId(null)} closeOnClick={false}
|
|
anchor="bottom" maxWidth="280px" className="gl-popup">
|
|
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
|
|
<div style={{
|
|
background: getStyle(selected).color, color: '#000',
|
|
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
|
margin: '-10px -10px 8px -10px',
|
|
fontWeight: 700, fontSize: 13,
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
}}>
|
|
<span>{getStyle(selected).icon}</span>
|
|
{selected.name}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
|
<span style={{
|
|
background: getStyle(selected).color, color: '#000',
|
|
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
|
}}>
|
|
{getStyle(selected).label}
|
|
</span>
|
|
<span style={{
|
|
background: '#333', color: '#ccc',
|
|
padding: '1px 6px', borderRadius: 3, fontSize: 10,
|
|
}}>
|
|
{selected.type === 'plant' ? '발전소' : '변전소'}
|
|
</span>
|
|
</div>
|
|
<div style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
{selected.output && (
|
|
<div><span style={{ color: '#888' }}>출력: </span><strong>{selected.output}</strong></div>
|
|
)}
|
|
{selected.voltage && (
|
|
<div><span style={{ color: '#888' }}>전압: </span><strong>{formatVoltage(selected.voltage)}</strong></div>
|
|
)}
|
|
{selected.operator && (
|
|
<div><span style={{ color: '#888' }}>운영: </span>{selected.operator}</div>
|
|
)}
|
|
{selected.source && (
|
|
<div><span style={{ color: '#888' }}>연료: </span>{selected.source}</div>
|
|
)}
|
|
<div style={{ fontSize: 9, color: '#666', marginTop: 2 }}>
|
|
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
)}
|
|
</>
|
|
);
|
|
}
|