kcg-monitoring/frontend/src/components/OilFacilityLayer.tsx
htlee 2534faa488 feat: 프론트엔드 모노레포 이관 + signal-batch 연동 + Tailwind/i18n/테마 전환
- frontend/ 폴더로 프론트엔드 전체 이관
- signal-batch API 연동 (한국 선박 위치 데이터)
- Tailwind CSS 4 + CSS 변수 테마 토큰 (dark/light)
- i18next 다국어 (ko/en) 인프라 + 28개 컴포넌트 적용
- 레이어 패널 트리 구조 재설계 (카테고리별 온/오프, 범례)
- Google OAuth 로그인 화면 + DEV LOGIN 우회
- 외부 API CORS 프록시 전환 (Airplanes.live, OpenSky, CelesTrak)
- ShipLayer 이미지 탭 전환 (signal-batch / MarineTraffic)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:54:41 +09:00

311 lines
16 KiB
TypeScript

import { memo, useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { OilFacility, OilFacilityType } from '../types';
interface Props {
facilities: OilFacility[];
currentTime: number;
}
const TYPE_COLORS: Record<OilFacilityType, string> = {
refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1',
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
};
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return String(n);
}
function getTooltipLabel(f: OilFacility): string {
if (f.capacityMgd) return `${formatNumber(f.capacityMgd)} MGD`;
if (f.capacityBpd) return `${formatNumber(f.capacityBpd)} bpd`;
if (f.reservesBbl) return `${f.reservesBbl}B bbl`;
if (f.reservesTcf) return `${f.reservesTcf} Tcf`;
if (f.capacityMcfd) return `${formatNumber(f.capacityMcfd)} Mcf/d`;
return '';
}
function getIconSize(f: OilFacility): number {
if (f.type === 'desalination') { const m = f.capacityMgd ?? 0; return m >= 200 ? 14 : m >= 80 ? 12 : 10; }
if (f.type === 'terminal') return (f.capacityBpd ?? 0) >= 1_000_000 ? 20 : 16;
if (f.type === 'oilfield') { const b = f.reservesBbl ?? 0; return b >= 20 ? 20 : b >= 10 ? 18 : 14; }
if (f.type === 'gasfield') { const t = f.reservesTcf ?? 0; return t >= 100 ? 20 : t >= 50 ? 18 : 14; }
if (f.type === 'refinery') { const b = f.capacityBpd ?? 0; return b >= 300_000 ? 20 : b >= 100_000 ? 18 : 14; }
return 16;
}
// Shared damage overlay (X mark + circle)
function DamageOverlay() {
return (
<>
<line x1={4} y1={4} x2={32} y2={32} stroke="#ff0000" strokeWidth={2.5} opacity={0.8} />
<line x1={32} y1={4} x2={4} y2={32} stroke="#ff0000" strokeWidth={2.5} opacity={0.8} />
<circle cx={18} cy={18} r={15} fill="none" stroke="#ff0000" strokeWidth={1.5} opacity={0.4} />
</>
);
}
// SVG icon renderers (JSX versions)
function RefineryIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
const sc = damaged ? '#ff0000' : color;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<defs>
<linearGradient id={`refGrad-${damaged ? 'd' : 'n'}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.5} />
<stop offset="100%" stopColor={color} stopOpacity={0.2} />
</linearGradient>
</defs>
<circle cx={18} cy={18} r={17} fill={`url(#refGrad-${damaged ? 'd' : 'n'})`}
stroke={sc} strokeWidth={damaged ? 2 : 1} opacity={0.9} />
<rect x={6} y={19} width={24} height={11} rx={1} fill={color} opacity={0.6} />
<rect x={16} y={7} width={4} height={13} fill={color} opacity={0.7} />
<rect x={9} y={12} width={4} height={8} fill={color} opacity={0.65} />
<rect x={23} y={10} width={4} height={10} fill={color} opacity={0.65} />
<circle cx={11} cy={10} r={1.5} fill={color} opacity={0.3} />
<circle cx={18} cy={5} r={2} fill={color} opacity={0.3} />
<circle cx={25} cy={8} r={1.5} fill={color} opacity={0.3} />
<rect x={10} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
<rect x={16} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
<rect x={23} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
<line x1={13} y1={15} x2={16} y2={15} stroke={color} strokeWidth={0.8} opacity={0.5} />
<line x1={20} y1={13} x2={23} y2={13} stroke={color} strokeWidth={0.8} opacity={0.5} />
{damaged && <DamageOverlay />}
</svg>
);
}
function OilFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
const sc = damaged ? '#ff0000' : color;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<rect x={4} y={31} width={28} height={2.5} rx={1} fill={color} opacity={0.7} />
<line x1={18} y1={14} x2={12} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
<line x1={18} y1={14} x2={24} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
<line x1={14} y1={25} x2={22} y2={25} stroke={color} strokeWidth={1} opacity={0.7} />
<line x1={4} y1={12} x2={28} y2={10} stroke={sc} strokeWidth={2} opacity={0.9} />
<circle cx={18} cy={13} r={2} fill={color} opacity={0.8} stroke={sc} strokeWidth={1} />
<path d="M4 12 L4 17 L7 17 L7 12" fill="none" stroke={sc} strokeWidth={1.5} opacity={0.85} />
<line x1={5.5} y1={17} x2={5.5} y2={31} stroke={color} strokeWidth={1.2} opacity={0.75} />
<rect x={25} y={10} width={5} height={6} rx={1} fill={color} opacity={0.6} stroke={sc} strokeWidth={1} />
<line x1={27.5} y1={16} x2={27.5} y2={24} stroke={color} strokeWidth={1.2} opacity={0.75} />
<rect x={24} y={24} width={7} height={5} rx={1} fill={color} opacity={0.55} stroke={sc} strokeWidth={1} />
<rect x={3} y={28} width={5} height={3} rx={0.5} fill={color} opacity={0.65} stroke={sc} strokeWidth={0.8} />
<path d="M5.5 29 C5.5 29 4.5 30 4.5 30.5 C4.5 31 5 31.2 5.5 31.2 C6 31.2 6.5 31 6.5 30.5 C6.5 30 5.5 29 5.5 29Z"
fill={color} opacity={0.85} />
{damaged && <DamageOverlay />}
</svg>
);
}
function GasFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<line x1={10} y1={24} x2={8} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={14} y1={25} x2={13} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={22} y1={25} x2={23} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={26} y1={24} x2={28} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={9} y1={29} x2={14} y2={27} stroke={color} strokeWidth={0.8} opacity={0.55} />
<line x1={22} y1={27} x2={27} y2={29} stroke={color} strokeWidth={0.8} opacity={0.55} />
<line x1={7} y1={33} x2={29} y2={33} stroke={color} strokeWidth={1.2} opacity={0.6} />
<ellipse cx={18} cy={16} rx={12} ry={10} fill={color} opacity={0.45}
stroke={damaged ? '#ff0000' : color} strokeWidth={1.5} />
<ellipse cx={16} cy={12} rx={7} ry={5} fill={color} opacity={0.3} />
<ellipse cx={18} cy={16} rx={12} ry={2.5} fill="none" stroke={color} strokeWidth={0.8} opacity={0.55} />
<rect x={16.5} y={4} width={3} height={3} rx={0.5} fill={color} opacity={0.7} />
<line x1={18} y1={4} x2={18} y2={6} stroke={color} strokeWidth={1.5} opacity={0.7} />
{damaged && <DamageOverlay />}
</svg>
);
}
function TerminalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<circle cx={18} cy={10} r={4} fill="none" stroke={damaged ? '#ff0000' : color} strokeWidth={2} />
<line x1={18} y1={14} x2={18} y2={28} stroke={color} strokeWidth={2} />
<path d="M10 24 C10 28 14 32 18 32 C22 32 26 28 26 24" fill="none" stroke={color} strokeWidth={2} />
<line x1={18} y1={8} x2={18} y2={6} stroke={color} strokeWidth={2} />
<line x1={16} y1={6} x2={20} y2={6} stroke={color} strokeWidth={2.5} />
<path d="M6 24 L10 24" stroke={color} strokeWidth={1.5} />
<path d="M26 24 L30 24" stroke={color} strokeWidth={1.5} />
<polygon points="5,24 8,22 8,26" fill={color} opacity={0.7} />
<polygon points="31,24 28,22 28,26" fill={color} opacity={0.7} />
{damaged && <DamageOverlay />}
</svg>
);
}
function PetrochemIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<path d="M14 6 L14 16 L8 30 L28 30 L22 16 L22 6Z" fill={color} opacity={0.7} stroke={damaged ? '#ff0000' : '#fff'} strokeWidth={1} />
<rect x={13} y={4} width={10} height={4} rx={1} fill={color} opacity={0.9} stroke={damaged ? '#ff0000' : '#fff'} strokeWidth={0.8} />
<path d="M11 22 L25 22 L28 30 L8 30Z" fill={color} opacity={0.5} />
<circle cx={16} cy={25} r={1.5} fill="#c4b5fd" opacity={0.7} />
<circle cx={20} cy={23} r={1} fill="#c4b5fd" opacity={0.6} />
<circle cx={18} cy={27} r={1.2} fill="#c4b5fd" opacity={0.5} />
{damaged && <DamageOverlay />}
</svg>
);
}
function DesalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
const sc = damaged ? '#ff0000' : color;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<path d="M14 3 C14 3 6 14 6 19 C6 23.5 9.5 27 14 27 C18.5 27 22 23.5 22 19 C22 14 14 3 14 3Z"
fill={color} opacity={0.4} stroke={sc} strokeWidth={1.2} />
<path d="M14 10 C14 10 10 16 10 19 C10 21.5 11.8 23.5 14 23.5 C16.2 23.5 18 21.5 18 19 C18 16 14 10 14 10Z"
fill={color} opacity={0.3} />
<rect x={24} y={5} width={6} height={3} rx={1} fill={color} opacity={0.5} stroke={sc} strokeWidth={0.8} />
<line x1={27} y1={8} x2={27} y2={12} stroke={sc} strokeWidth={1.5} opacity={0.7} />
<path d="M24 8 L24 10 Q24 12 26 12 L28 12 Q30 12 30 10 L30 8"
fill="none" stroke={sc} strokeWidth={0.8} opacity={0.6} />
<circle cx={27} cy={14.5} r={1} fill={color} opacity={0.55} />
<circle cx={27} cy={17} r={0.7} fill={color} opacity={0.45} />
<rect x={23} y={20} width={9} height={12} rx={1.5} fill={color} opacity={0.4}
stroke={sc} strokeWidth={1} />
<line x1={24} y1={24} x2={31} y2={24} stroke={color} strokeWidth={0.6} opacity={0.5} />
<line x1={24} y1={27} x2={31} y2={27} stroke={color} strokeWidth={0.6} opacity={0.5} />
<path d="M18 22 L23 22" stroke={sc} strokeWidth={1} opacity={0.6} />
<line x1={27.5} y1={32} x2={27.5} y2={34} stroke={color} strokeWidth={1} opacity={0.55} />
<line x1={4} y1={34} x2={33} y2={34} stroke={color} strokeWidth={1} opacity={0.25} />
{damaged && <DamageOverlay />}
</svg>
);
}
function FacilityIconSvg({ facility, damaged }: { facility: OilFacility; damaged: boolean }) {
const color = TYPE_COLORS[facility.type];
const size = getIconSize(facility);
switch (facility.type) {
case 'refinery': return <RefineryIcon size={size} color={color} damaged={damaged} />;
case 'oilfield': return <OilFieldIcon size={size} color={color} damaged={damaged} />;
case 'gasfield': return <GasFieldIcon size={size} color={color} damaged={damaged} />;
case 'terminal': return <TerminalIcon size={size} color={color} damaged={damaged} />;
case 'petrochemical': return <PetrochemIcon size={size} color={color} damaged={damaged} />;
case 'desalination': return <DesalIcon size={size} color={color} damaged={damaged} />;
}
}
export const OilFacilityLayer = memo(function OilFacilityLayer({ facilities, currentTime }: Props) {
return (
<>
{facilities.map(f => (
<FacilityMarker key={f.id} facility={f} currentTime={currentTime} />
))}
</>
);
});
function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) {
const { t } = useTranslation('ships');
const [showPopup, setShowPopup] = useState(false);
const color = TYPE_COLORS[facility.type];
const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt);
const isPlanned = !!facility.planned && !isDamaged;
const stat = getTooltipLabel(facility);
return (
<>
<Marker longitude={facility.lng} latitude={facility.lat} anchor="center">
<div className="relative">
{/* Planned strike targeting ring */}
{isPlanned && (
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-9 rounded-full pointer-events-none"
style={{
border: '2px dashed #ff6600',
animation: 'planned-pulse 2s ease-in-out infinite',
}}
>
{/* Crosshair lines */}
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
<div className="absolute -left-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
<div className="absolute -right-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
</div>
)}
<div className="cursor-pointer"
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
<FacilityIconSvg facility={facility} damaged={isDamaged} />
</div>
<div className="gl-marker-label text-[8px]" style={{
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color,
}}>
{isDamaged ? '\u{1F4A5} ' : isPlanned ? '\u{1F3AF} ' : ''}{facility.nameKo}
{stat && <span className="text-[#aaa] text-[7px] ml-0.5">{stat}</span>}
</div>
</div>
</Marker>
{showPopup && (
<Popup longitude={facility.lng} latitude={facility.lat}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div className="min-w-[220px] font-mono text-xs">
<div className="flex gap-1 items-center mb-1.5">
<span
className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white"
style={{ background: color }}
>{t(`facility.type.${facility.type}`)}</span>
{isDamaged && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff0000]">
{t('facility.damaged')}
</span>
)}
{isPlanned && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff6600]">
{t('facility.plannedStrike')}
</span>
)}
</div>
<div className="font-bold text-[13px] my-1">{facility.nameKo}</div>
<div className="text-[10px] text-kcg-muted mb-1.5">{facility.name}</div>
<div className="bg-black/30 rounded p-1.5 grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
{facility.capacityBpd != null && (
<><span className="text-kcg-muted">{t('facility.production')}</span>
<span className="text-white font-semibold">{formatNumber(facility.capacityBpd)} bpd</span></>
)}
{facility.capacityMgd != null && (
<><span className="text-kcg-muted">{t('facility.desalProduction')}</span>
<span className="text-white font-semibold">{formatNumber(facility.capacityMgd)} MGD</span></>
)}
{facility.capacityMcfd != null && (
<><span className="text-kcg-muted">{t('facility.gasProduction')}</span>
<span className="text-white font-semibold">{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
)}
{facility.reservesBbl != null && (
<><span className="text-kcg-muted">{t('facility.reserveOil')}</span>
<span className="text-white font-semibold">{facility.reservesBbl}B {t('facility.barrels')}</span></>
)}
{facility.reservesTcf != null && (
<><span className="text-kcg-muted">{t('facility.reserveGas')}</span>
<span className="text-white font-semibold">{facility.reservesTcf} Tcf</span></>
)}
{facility.operator && (
<><span className="text-kcg-muted">{t('facility.operator')}</span>
<span className="text-white">{facility.operator}</span></>
)}
</div>
{facility.description && (
<p className="mt-1.5 mb-0 text-[11px] text-kcg-text-secondary leading-snug">{facility.description}</p>
)}
{isPlanned && facility.plannedLabel && (
<div className="mt-1.5 px-2 py-1 text-[11px] rounded leading-snug bg-[rgba(255,102,0,0.15)] border border-[rgba(255,102,0,0.4)] text-[#ff9933]">
{facility.plannedLabel}
</div>
)}
<div className="text-[10px] text-kcg-dim mt-1.5">
{facility.lat.toFixed(4)}°N, {facility.lng.toFixed(4)}°E
</div>
</div>
</Popup>
)}
</>
);
}