- 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>
311 lines
16 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|