kcg-monitoring/src/components/InfraLayer.tsx
htlee ccdfb3517b feat: KCG 모니터링 대시보드 초기 프로젝트 구성
React 19 + TypeScript + Vite + MapLibre 기반 해양 모니터링 대시보드.
선박 AIS, 항공기, CCTV, 위성, 해양 인프라 등 다중 레이어 지원.
ESLint React Compiler 규칙 조정 및 lint 에러 수정 포함.
2026-03-17 09:01:18 +09:00

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>
)}
</>
);
}