fix: hotfix 동기화 — history/detail candidate_count 안전 처리 #225
@ -4,6 +4,27 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-23.5]
|
||||
|
||||
### 추가
|
||||
- 이란 시설 deck.gl SVG 전환: OilFacility/Airport/MEFacility/MEEnergyHazard → IconLayer(SVG) + TextLayer
|
||||
- 26개 고유 SVG 아이콘 (배경 원형 + 색상 테두리 + 고유 실루엣)
|
||||
- 중동 에너지/위험시설 데이터 84개 (meEnergyHazardFacilities)
|
||||
- 나탄즈-디모나 핵시설 교차공격 리플레이 이벤트 (D+20)
|
||||
- AI 해양분석 챗 UI (AiChatPanel, API placeholder)
|
||||
- LayerPanel 해외시설 3단계 트리 (국가→카테고리→하위시설)
|
||||
|
||||
### 변경
|
||||
- 한국 군사/정부/NK 발사장 아이콘: emoji → SVG IconLayer 업그레이드 (19종)
|
||||
- 시설 라벨 SDF 테두리 적용 (fontSettings.sdf + outlineWidth:8) — 사막/위성 배경 가독성
|
||||
- 라벨 폰트 크기 ~1.2배 상향 (이란/한국 공통)
|
||||
- ReplayMap/SatelliteMap: DeckGLOverlay + 줌 스케일 연동
|
||||
|
||||
### 수정
|
||||
- IranDashboard LayerPanel 카운트 전수 보정 (하드코딩→실제 데이터 기반)
|
||||
- fishing-zones GeoJSON 좌표 보정
|
||||
- overseas 국가 키: overseasUK → overseasIsrael
|
||||
|
||||
## [2026-03-23.4]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -6,6 +6,7 @@ import { getDisasterNews, getDisasterCatIcon, getDisasterCatColor } from '../../
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { aggregateFishingStats, GEAR_LABELS } from '../../utils/fishingAnalysis';
|
||||
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||
import { AiChatPanel } from '../korea/AiChatPanel';
|
||||
|
||||
type DashboardTab = 'iran' | 'korea';
|
||||
|
||||
@ -349,7 +350,7 @@ function useTimeAgo() {
|
||||
export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS, highlightKoreanShips = false, onToggleHighlightKorean, onShipHover, onShipClick }: Props) {
|
||||
const { t } = useTranslation(['common', 'events', 'ships']);
|
||||
const timeAgo = useTimeAgo();
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set(['kr-ships', 'cn-ships']));
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set(['kr-ships', 'cn-ships', 'cn-fishing']));
|
||||
const toggleCollapse = useCallback((key: string) => {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
@ -879,6 +880,16 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* AI 해양분석 챗 — 한국 탭 전용 */}
|
||||
{dashboardTab === 'korea' && (
|
||||
<AiChatPanel
|
||||
ships={ships}
|
||||
koreanShipCount={koreanShips.length}
|
||||
chineseShipCount={chineseShips.length}
|
||||
totalShipCount={ships.length}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -129,6 +129,18 @@ interface OverseasItem {
|
||||
children?: OverseasItem[];
|
||||
}
|
||||
|
||||
function countOverseasActiveLeaves(items: OverseasItem[], layers: Record<string, boolean>): number {
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
if (item.children?.length) {
|
||||
count += countOverseasActiveLeaves(item.children, layers);
|
||||
} else if (layers[item.key]) {
|
||||
count += (item.count ?? 1);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
interface LayerPanelProps {
|
||||
layers: Record<string, boolean>;
|
||||
onToggle: (key: string) => void;
|
||||
@ -545,11 +557,7 @@ export function LayerPanel({
|
||||
<LayerTreeItem
|
||||
layerKey="overseas-section"
|
||||
label="해외시설"
|
||||
count={overseasItems?.reduce((sum, item) => {
|
||||
const parentOn = layers[item.key] ? 1 : 0;
|
||||
const childrenOn = item.children?.filter(c => layers[c.key]).length ?? 0;
|
||||
return sum + parentOn + childrenOn;
|
||||
}, 0) ?? 0}
|
||||
count={overseasItems ? countOverseasActiveLeaves(overseasItems, layers) : 0}
|
||||
color="#f97316"
|
||||
active={expanded.has('overseas-section')}
|
||||
expandable
|
||||
@ -560,32 +568,16 @@ export function LayerPanel({
|
||||
{expanded.has('overseas-section') && overseasItems && overseasItems.length > 0 && (
|
||||
<div className="layer-tree-children">
|
||||
{overseasItems.map(item => (
|
||||
<div key={item.key}>
|
||||
<LayerTreeItem
|
||||
layerKey={item.key}
|
||||
label={item.label}
|
||||
color={item.color}
|
||||
active={layers[item.key] ?? false}
|
||||
expandable={!!item.children?.length}
|
||||
isExpanded={expanded.has(`overseas-${item.key}`)}
|
||||
onToggle={() => onToggle(item.key)}
|
||||
onExpand={() => toggleExpand(`overseas-${item.key}`)}
|
||||
/>
|
||||
{item.children?.length && expanded.has(`overseas-${item.key}`) && (
|
||||
<div className="layer-tree-children">
|
||||
{item.children.map(child => (
|
||||
<LayerTreeItem
|
||||
key={child.key}
|
||||
layerKey={child.key}
|
||||
label={child.label}
|
||||
color={child.color}
|
||||
active={layers[child.key] ?? false}
|
||||
onToggle={() => onToggle(child.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<OverseasTreeNode
|
||||
key={item.key}
|
||||
item={item}
|
||||
depth={0}
|
||||
layers={layers}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onToggleAll={onToggle}
|
||||
toggleExpand={toggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@ -594,6 +586,75 @@ export function LayerPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Overseas 3-level tree node ────────────────────── */
|
||||
|
||||
function OverseasTreeNode({ item, depth, layers, expanded, onToggle, onToggleAll, toggleExpand }: {
|
||||
item: OverseasItem;
|
||||
depth: number;
|
||||
layers: Record<string, boolean>;
|
||||
expanded: Set<string>;
|
||||
onToggle: (key: string) => void;
|
||||
onToggleAll: (key: string) => void;
|
||||
toggleExpand: (key: string) => void;
|
||||
}) {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isExpanded = expanded.has(item.key);
|
||||
const isActive = hasChildren
|
||||
? item.children!.some(c => c.children?.length ? c.children.some(gc => layers[gc.key]) : layers[c.key])
|
||||
: layers[item.key];
|
||||
const leafCount = hasChildren ? countOverseasActiveLeaves([item], layers) : (item.count ?? 0);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (hasChildren) {
|
||||
// 부모 토글 → 모든 하위 리프 on/off
|
||||
const allLeaves: string[] = [];
|
||||
const collectLeaves = (node: OverseasItem) => {
|
||||
if (node.children?.length) node.children.forEach(collectLeaves);
|
||||
else allLeaves.push(node.key);
|
||||
};
|
||||
collectLeaves(item);
|
||||
const allOn = allLeaves.every(k => layers[k]);
|
||||
for (const k of allLeaves) {
|
||||
if (allOn || !layers[k]) onToggleAll(k);
|
||||
}
|
||||
} else {
|
||||
onToggle(item.key);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginLeft: depth * 12 }}>
|
||||
<LayerTreeItem
|
||||
layerKey={item.key}
|
||||
label={item.label}
|
||||
color={item.color}
|
||||
active={!!isActive}
|
||||
expandable={!!hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
count={leafCount}
|
||||
onToggle={handleToggle}
|
||||
onExpand={() => toggleExpand(item.key)}
|
||||
/>
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="layer-tree-children">
|
||||
{item.children!.map(child => (
|
||||
<OverseasTreeNode
|
||||
key={child.key}
|
||||
item={child}
|
||||
depth={depth + 1}
|
||||
layers={layers}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onToggleAll={onToggleAll}
|
||||
toggleExpand={toggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Sub-components ─────────────────────────────────── */
|
||||
|
||||
function LayerTreeItem({
|
||||
|
||||
151
frontend/src/components/iran/AirportLayer.ts
Normal file
151
frontend/src/components/iran/AirportLayer.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo, Layer } from '@deck.gl/core';
|
||||
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||
import { middleEastAirports } from '../../data/airports';
|
||||
import type { Airport } from '../../data/airports';
|
||||
|
||||
// ─── US base detection ───────────────────────────────────────────────────────
|
||||
|
||||
const US_BASE_ICAOS = new Set([
|
||||
'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL',
|
||||
]);
|
||||
|
||||
function isUSBase(airport: Airport): boolean {
|
||||
return airport.type === 'military' && US_BASE_ICAOS.has(airport.icao);
|
||||
}
|
||||
|
||||
// ─── Deduplication ───────────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_PRIORITY: Record<Airport['type'], number> = {
|
||||
military: 3, large: 2, medium: 1, small: 0,
|
||||
};
|
||||
|
||||
function deduplicateByArea(airports: Airport[]): Airport[] {
|
||||
const sorted = [...airports].sort((a, b) => {
|
||||
const pa = TYPE_PRIORITY[a.type] + (isUSBase(a) ? 1 : 0);
|
||||
const pb = TYPE_PRIORITY[b.type] + (isUSBase(b) ? 1 : 0);
|
||||
return pb - pa;
|
||||
});
|
||||
const kept: Airport[] = [];
|
||||
for (const ap of sorted) {
|
||||
const tooClose = kept.some(
|
||||
k => Math.abs(k.lat - ap.lat) < 0.8 && Math.abs(k.lng - ap.lng) < 0.8,
|
||||
);
|
||||
if (!tooClose) kept.push(ap);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
const DEDUPLICATED_AIRPORTS = deduplicateByArea(middleEastAirports);
|
||||
export const IRAN_AIRPORT_COUNT = DEDUPLICATED_AIRPORTS.length;
|
||||
|
||||
// ─── Colors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getAirportColor(airport: Airport): string {
|
||||
if (isUSBase(airport)) return '#3b82f6';
|
||||
if (airport.type === 'military') return '#ef4444';
|
||||
return '#f59e0b';
|
||||
}
|
||||
|
||||
// ─── SVG generators ──────────────────────────────────────────────────────────
|
||||
|
||||
function militaryPlaneSvg(color: string): string {
|
||||
return `<path d="M12 8.5 L12.8 11 L16.5 12.5 L16.5 13.5 L12.8 12.8 L12.5 15 L13.5 15.8 L13.5 16.5 L12 16 L10.5 16.5 L10.5 15.8 L11.5 15 L11.2 12.8 L7.5 13.5 L7.5 12.5 L11.2 11 L12 8.5Z" fill="${color}"/>`;
|
||||
}
|
||||
|
||||
function civilPlaneSvg(color: string): string {
|
||||
return `<path d="M12 8c-.5 0-.8.3-.8.5v2.7l-4.2 2v1l4.2-1.1v2.5l-1.1.8v.8L12 16l1.9.5v-.8l-1.1-.8v-2.5l4.2 1.1v-1l-4.2-2V8.5c0-.2-.3-.5-.8-.5z" fill="${color}"/>`;
|
||||
}
|
||||
|
||||
function airportSvg(airport: Airport): string {
|
||||
const color = getAirportColor(airport);
|
||||
const isMil = airport.type === 'military';
|
||||
const size = airport.type === 'large' ? 32 : airport.type === 'small' ? 24 : 28;
|
||||
const plane = isMil ? militaryPlaneSvg(color) : civilPlaneSvg(color);
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="2"/>
|
||||
${plane}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Module-level icon cache ─────────────────────────────────────────────────
|
||||
|
||||
const airportIconCache = new Map<string, string>();
|
||||
|
||||
function getAirportIconUrl(airport: Airport): string {
|
||||
const isUS = isUSBase(airport);
|
||||
const key = `${airport.type}-${isUS ? 'us' : 'std'}`;
|
||||
if (!airportIconCache.has(key)) {
|
||||
airportIconCache.set(key, svgToDataUri(airportSvg(airport)));
|
||||
}
|
||||
return airportIconCache.get(key)!;
|
||||
}
|
||||
|
||||
function getIconDimension(airport: Airport): number {
|
||||
return airport.type === 'large' ? 32 : airport.type === 'small' ? 24 : 28;
|
||||
}
|
||||
|
||||
// ─── Label color ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getAirportLabelColor(airport: Airport): [number, number, number, number] {
|
||||
if (isUSBase(airport)) return [59, 130, 246, 255];
|
||||
if (airport.type === 'military') return [239, 68, 68, 255];
|
||||
return [245, 158, 11, 255];
|
||||
}
|
||||
|
||||
// ─── Public interface ────────────────────────────────────────────────────────
|
||||
|
||||
export interface AirportLayerConfig {
|
||||
visible: boolean;
|
||||
sc: number;
|
||||
onPick: (ap: Airport) => void;
|
||||
}
|
||||
|
||||
export function createIranAirportLayers(config: AirportLayerConfig): Layer[] {
|
||||
if (!config.visible) return [];
|
||||
|
||||
const { sc, onPick } = config;
|
||||
|
||||
return [
|
||||
new IconLayer<Airport>({
|
||||
id: 'iran-airport-icon',
|
||||
data: DEDUPLICATED_AIRPORTS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => {
|
||||
const dim = getIconDimension(d);
|
||||
return {
|
||||
url: getAirportIconUrl(d),
|
||||
width: dim,
|
||||
height: dim,
|
||||
anchorX: dim / 2,
|
||||
anchorY: dim / 2,
|
||||
};
|
||||
},
|
||||
getSize: (d) => (d.type === 'large' ? 16 : d.type === 'small' ? 12 : 14) * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<Airport>) => {
|
||||
if (info.object) onPick(info.object);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<Airport>({
|
||||
id: 'iran-airport-label',
|
||||
data: DEDUPLICATED_AIRPORTS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo ?? d.name,
|
||||
getSize: 9 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => getAirportLabelColor(d),
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
];
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import type { Airport } from '../../data/airports';
|
||||
|
||||
const US_BASE_ICAOS = new Set([
|
||||
'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL',
|
||||
]);
|
||||
|
||||
function isUSBase(airport: Airport): boolean {
|
||||
return airport.type === 'military' && US_BASE_ICAOS.has(airport.icao);
|
||||
}
|
||||
|
||||
const FLAG_EMOJI: Record<string, string> = {
|
||||
IR: '\u{1F1EE}\u{1F1F7}', IQ: '\u{1F1EE}\u{1F1F6}', IL: '\u{1F1EE}\u{1F1F1}',
|
||||
AE: '\u{1F1E6}\u{1F1EA}', SA: '\u{1F1F8}\u{1F1E6}', QA: '\u{1F1F6}\u{1F1E6}',
|
||||
BH: '\u{1F1E7}\u{1F1ED}', KW: '\u{1F1F0}\u{1F1FC}', OM: '\u{1F1F4}\u{1F1F2}',
|
||||
TR: '\u{1F1F9}\u{1F1F7}', JO: '\u{1F1EF}\u{1F1F4}', LB: '\u{1F1F1}\u{1F1E7}',
|
||||
SY: '\u{1F1F8}\u{1F1FE}', EG: '\u{1F1EA}\u{1F1EC}', PK: '\u{1F1F5}\u{1F1F0}',
|
||||
DJ: '\u{1F1E9}\u{1F1EF}', YE: '\u{1F1FE}\u{1F1EA}', SO: '\u{1F1F8}\u{1F1F4}',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<Airport['type'], string> = {
|
||||
large: 'International Airport', medium: 'Airport',
|
||||
small: 'Regional Airport', military: 'Military Airbase',
|
||||
};
|
||||
|
||||
interface Props { airports: Airport[]; }
|
||||
|
||||
const TYPE_PRIORITY: Record<Airport['type'], number> = {
|
||||
military: 3, large: 2, medium: 1, small: 0,
|
||||
};
|
||||
|
||||
// Keep one airport per area (~50km radius). Priority: military/US base > large > medium > small.
|
||||
function deduplicateByArea(airports: Airport[]): Airport[] {
|
||||
const sorted = [...airports].sort((a, b) => {
|
||||
const pa = TYPE_PRIORITY[a.type] + (isUSBase(a) ? 1 : 0);
|
||||
const pb = TYPE_PRIORITY[b.type] + (isUSBase(b) ? 1 : 0);
|
||||
return pb - pa;
|
||||
});
|
||||
const kept: Airport[] = [];
|
||||
for (const ap of sorted) {
|
||||
const tooClose = kept.some(
|
||||
k => Math.abs(k.lat - ap.lat) < 0.8 && Math.abs(k.lng - ap.lng) < 0.8,
|
||||
);
|
||||
if (!tooClose) kept.push(ap);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
export const AirportLayer = memo(function AirportLayer({ airports }: Props) {
|
||||
const filtered = useMemo(() => deduplicateByArea(airports), [airports]);
|
||||
return (
|
||||
<>
|
||||
{filtered.map(ap => (
|
||||
<AirportMarker key={ap.icao} airport={ap} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function AirportMarker({ airport }: { airport: Airport }) {
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const isMil = airport.type === 'military';
|
||||
const isUS = isUSBase(airport);
|
||||
const color = isUS ? '#3b82f6' : isMil ? '#ef4444' : '#f59e0b';
|
||||
const size = airport.type === 'large' ? 18 : airport.type === 'small' ? 12 : 16;
|
||||
const flag = FLAG_EMOJI[airport.country] || '';
|
||||
|
||||
// Single circle with airplane inside (plane shifted down to center in circle)
|
||||
const plane = isMil
|
||||
? <path d="M12 8.5 L12.8 11 L16.5 12.5 L16.5 13.5 L12.8 12.8 L12.5 15 L13.5 15.8 L13.5 16.5 L12 16 L10.5 16.5 L10.5 15.8 L11.5 15 L11.2 12.8 L7.5 13.5 L7.5 12.5 L11.2 11 L12 8.5Z"
|
||||
fill={color} />
|
||||
: <path d="M12 8c-.5 0-.8.3-.8.5v2.7l-4.2 2v1l4.2-1.1v2.5l-1.1.8v.8L12 16l1.9.5v-.8l-1.1-.8v-2.5l4.2 1.1v-1l-4.2-2V8.5c0-.2-.3-.5-.8-.5z"
|
||||
fill={color} />;
|
||||
const icon = (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size}>
|
||||
<circle cx={12} cy={12} r={10} fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth={2} />
|
||||
{plane}
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Marker longitude={airport.lng} latitude={airport.lat} anchor="center">
|
||||
<div style={{ width: size, height: size, cursor: 'pointer' }}
|
||||
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
|
||||
{icon}
|
||||
</div>
|
||||
</Marker>
|
||||
{showPopup && (
|
||||
<Popup longitude={airport.lng} latitude={airport.lat}
|
||||
onClose={() => setShowPopup(false)} closeOnClick={false}
|
||||
anchor="bottom" offset={[0, -size / 2]} maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body" style={{ minWidth: 220 }}>
|
||||
<div className="popup-header" style={{ background: isUS ? '#1e3a5f' : isMil ? '#991b1b' : '#92400e' }}>
|
||||
{isUS ? <span style={{ fontSize: 16 }}>{'\u{1F1FA}\u{1F1F8}'}</span>
|
||||
: flag ? <span style={{ fontSize: 16 }}>{flag}</span> : null}
|
||||
<strong style={{ flex: 1 }}>{airport.name}</strong>
|
||||
</div>
|
||||
{airport.nameKo && (
|
||||
<div style={{ fontSize: 12, color: '#ccc', marginBottom: 6 }}>{airport.nameKo}</div>
|
||||
)}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={{
|
||||
background: color, color: isUS || isMil ? '#fff' : '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{isUS ? 'US Military Base' : TYPE_LABELS[airport.type]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
{airport.iata && <div><span className="popup-label">IATA : </span><strong>{airport.iata}</strong></div>}
|
||||
<div><span className="popup-label">ICAO : </span><strong>{airport.icao}</strong></div>
|
||||
{airport.city && <div><span className="popup-label">City : </span>{airport.city}</div>}
|
||||
<div><span className="popup-label">Country : </span>{airport.country}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{airport.lat.toFixed(4)}°{airport.lat >= 0 ? 'N' : 'S'}, {airport.lng.toFixed(4)}°{airport.lng >= 0 ? 'E' : 'W'}
|
||||
</div>
|
||||
{airport.iata && (
|
||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
||||
<a href={`https://www.flightradar24.com/airport/${airport.iata.toLowerCase()}`}
|
||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
||||
Flightradar24 →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,9 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IRAN_OIL_COUNT } from './createIranOilLayers';
|
||||
import { IRAN_AIRPORT_COUNT } from './createIranAirportLayers';
|
||||
import { ME_FACILITY_COUNT } from './createMEFacilityLayers';
|
||||
import { ME_ENERGY_HAZARD_FACILITIES } from '../../data/meEnergyHazardFacilities';
|
||||
import { ReplayMap } from './ReplayMap';
|
||||
import type { FlyToTarget } from './ReplayMap';
|
||||
import { GlobeMap } from './GlobeMap';
|
||||
@ -56,7 +60,7 @@ const INITIAL_LAYERS: LayerVisibility = {
|
||||
meFacilities: true,
|
||||
militaryOnly: false,
|
||||
overseasUS: false,
|
||||
overseasUK: false,
|
||||
overseasIsrael: false,
|
||||
overseasIran: false,
|
||||
overseasUAE: false,
|
||||
overseasSaudi: false,
|
||||
@ -113,6 +117,8 @@ const IranDashboard = ({
|
||||
setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 });
|
||||
}, []);
|
||||
|
||||
const meCountByCountry = (ck: string) => ME_ENERGY_HAZARD_FACILITIES.filter(f => f.countryKey === ck).length;
|
||||
|
||||
// 헤더 슬롯 Portal — 이란 모드 토글 + 맵 모드 + 카운트
|
||||
const headerSlot = document.getElementById('dashboard-header-slot');
|
||||
const countsSlot = document.getElementById('dashboard-counts-slot');
|
||||
@ -216,22 +222,22 @@ const IranDashboard = ({
|
||||
extraLayers={[
|
||||
{ key: 'events', label: t('layers.events'), color: '#a855f7' },
|
||||
{ key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: iranData.koreanShips.length },
|
||||
{ key: 'airports', label: t('layers.airports'), color: '#f59e0b' },
|
||||
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706' },
|
||||
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 },
|
||||
{ key: 'airports', label: t('layers.airports'), color: '#f59e0b', count: IRAN_AIRPORT_COUNT },
|
||||
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706', count: IRAN_OIL_COUNT },
|
||||
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: ME_FACILITY_COUNT },
|
||||
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
|
||||
]}
|
||||
overseasItems={[
|
||||
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' },
|
||||
{ key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' },
|
||||
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' },
|
||||
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' },
|
||||
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' },
|
||||
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' },
|
||||
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' },
|
||||
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' },
|
||||
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' },
|
||||
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' },
|
||||
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6', count: meCountByCountry('us') },
|
||||
{ key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#dc2626', count: meCountByCountry('il') },
|
||||
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e', count: meCountByCountry('ir') },
|
||||
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b', count: meCountByCountry('ae') },
|
||||
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16', count: meCountByCountry('sa') },
|
||||
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48', count: meCountByCountry('om') },
|
||||
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6', count: meCountByCountry('qa') },
|
||||
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316', count: meCountByCountry('kw') },
|
||||
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d', count: meCountByCountry('iq') },
|
||||
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48', count: meCountByCountry('bh') },
|
||||
]}
|
||||
hiddenAcCategories={hiddenAcCategories}
|
||||
hiddenShipCategories={hiddenShipCategories}
|
||||
|
||||
219
frontend/src/components/iran/MEEnergyHazardLayer.tsx
Normal file
219
frontend/src/components/iran/MEEnergyHazardLayer.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo, Layer } from '@deck.gl/core';
|
||||
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||
import {
|
||||
ME_ENERGY_HAZARD_FACILITIES,
|
||||
SUB_TYPE_META,
|
||||
layerKeyToSubType,
|
||||
layerKeyToCountry,
|
||||
type EnergyHazardFacility,
|
||||
type FacilitySubType,
|
||||
} from '../../data/meEnergyHazardFacilities';
|
||||
|
||||
// LayerVisibility overseas key → countryKey mapping
|
||||
const COUNTRY_KEY_TO_LAYER_KEY: Record<string, string> = {
|
||||
us: 'overseasUS',
|
||||
ir: 'overseasIran',
|
||||
ae: 'overseasUAE',
|
||||
sa: 'overseasSaudi',
|
||||
om: 'overseasOman',
|
||||
qa: 'overseasQatar',
|
||||
kw: 'overseasKuwait',
|
||||
iq: 'overseasIraq',
|
||||
bh: 'overseasBahrain',
|
||||
// il (Israel) is shown when meFacilities is true (no dedicated overseas key)
|
||||
il: 'meFacilities',
|
||||
};
|
||||
|
||||
function isFacilityVisible(f: EnergyHazardFacility, layers: Record<string, boolean>): boolean {
|
||||
const countryLayerKey = COUNTRY_KEY_TO_LAYER_KEY[f.countryKey];
|
||||
if (!countryLayerKey || !layers[countryLayerKey]) return false;
|
||||
|
||||
// Check sub-type toggle if present, otherwise fall through to country-level toggle
|
||||
// Sub-type keys: e.g. "irPower", "ilNuclear", "usOilTank"
|
||||
const subTypeKey = f.countryKey + capitalizeFirst(f.subType.replace('_', ''));
|
||||
if (subTypeKey in layers) return !!layers[subTypeKey];
|
||||
|
||||
// Check category-level parent key: e.g. "irEnergy", "usHazard"
|
||||
const categoryKey = f.countryKey + capitalizeFirst(f.category);
|
||||
if (categoryKey in layers) return !!layers[categoryKey];
|
||||
|
||||
// Fall back to country-level toggle
|
||||
return true;
|
||||
}
|
||||
|
||||
function capitalizeFirst(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
// Pre-build layer-key → subType entries from layerKeyToSubType/layerKeyToCountry
|
||||
// for reference — the actual filter uses the above isFacilityVisible logic.
|
||||
// Exported for re-use elsewhere if needed.
|
||||
export { layerKeyToSubType, layerKeyToCountry };
|
||||
|
||||
export interface MELayerConfig {
|
||||
layers: Record<string, boolean>;
|
||||
sc: number;
|
||||
onPick: (facility: EnergyHazardFacility) => void;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return [r, g, b, alpha];
|
||||
}
|
||||
|
||||
// ─── SVG icon functions ────────────────────────────────────────────────────────
|
||||
|
||||
function powerSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M13 5 L7 13 L12 13 L11 19 L17 11 L12 11 Z" fill="${color}" opacity="0.9"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function windSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<circle cx="12" cy="12" r="1.5" fill="${color}"/>
|
||||
<line x1="12" y1="10.5" x2="12" y2="5" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 Q15 5 15 7 Q15 9 12 10.5" fill="${color}" opacity="0.7"/>
|
||||
<line x1="10.7" y1="11.25" x2="6" y2="8.5" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M6 8.5 Q4 11 5.5 13 Q7 14.5 10.7 13" fill="${color}" opacity="0.7"/>
|
||||
<line x1="13.3" y1="12.75" x2="18" y2="15.5" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M18 15.5 Q20 13 18.5 11 Q17 9.5 13.3 11" fill="${color}" opacity="0.7"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function nuclearSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<circle cx="12" cy="12" r="2" fill="${color}"/>
|
||||
<path d="M12 10 Q10 7 7 7 Q6 9 7 11 Q9 12 12 12" fill="${color}" opacity="0.7"/>
|
||||
<path d="M13.7 11 Q16 9 17 7 Q15 5 13 6 Q11 8 12 10" fill="${color}" opacity="0.7"/>
|
||||
<path d="M10.3 13 Q7 13 6 16 Q8 18 11 17 Q13 15 13.7 13" fill="${color}" opacity="0.7"/>
|
||||
<path d="M13.7 13 Q15 16 17 17 Q19 15 18 12 Q16 11 13.7 12" fill="${color}" opacity="0.7"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function thermalSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<rect x="5" y="11" width="14" height="7" rx="1" fill="${color}" opacity="0.6"/>
|
||||
<rect x="7" y="7" width="2" height="5" fill="${color}" opacity="0.6"/>
|
||||
<rect x="11" y="5" width="2" height="7" fill="${color}" opacity="0.6"/>
|
||||
<rect x="15" y="8" width="2" height="4" fill="${color}" opacity="0.6"/>
|
||||
<path d="M8 5 Q8.5 3.5 9 5 Q9.5 3 10 5" fill="none" stroke="${color}" stroke-width="0.8" opacity="0.8"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function petrochemSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<rect x="10" y="5" width="4" height="8" rx="1" fill="${color}" opacity="0.65"/>
|
||||
<ellipse cx="12" cy="14.5" rx="4.5" ry="2.5" fill="${color}" opacity="0.75"/>
|
||||
<line x1="7" y1="10" x2="10" y2="10" stroke="${color}" stroke-width="1"/>
|
||||
<line x1="14" y1="10" x2="17" y2="10" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M7 10 Q5.5 13 8 15" fill="none" stroke="${color}" stroke-width="0.8"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function lngSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="9" y1="7" x2="9" y2="10" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="12" y1="5" x2="12" y2="10" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="15" y1="7" x2="15" y2="10" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="7" y1="9" x2="17" y2="9" stroke="${color}" stroke-width="1"/>
|
||||
<ellipse cx="12" cy="15" rx="5" ry="3.5" fill="${color}" opacity="0.65"/>
|
||||
<line x1="12" y1="10" x2="12" y2="11.5" stroke="${color}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function oilTankSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<ellipse cx="12" cy="8" rx="5" ry="2" fill="${color}" opacity="0.5"/>
|
||||
<rect x="7" y="8" width="10" height="8" fill="${color}" opacity="0.6"/>
|
||||
<ellipse cx="12" cy="16" rx="5" ry="2" fill="${color}" opacity="0.8"/>
|
||||
<line x1="9" y1="8" x2="9" y2="16" stroke="rgba(0,0,0,0.2)" stroke-width="0.5"/>
|
||||
<line x1="15" y1="8" x2="15" y2="16" stroke="rgba(0,0,0,0.2)" stroke-width="0.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function hazPortSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 L20 18 L4 18 Z" fill="${color}" opacity="0.7"/>
|
||||
<line x1="12" y1="10" x2="12" y2="14" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="16" r="1" fill="#fff"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const SUB_TYPE_SVG_FN: Record<FacilitySubType, (color: string, size: number) => string> = {
|
||||
power: powerSvg,
|
||||
wind: windSvg,
|
||||
nuclear: nuclearSvg,
|
||||
thermal: thermalSvg,
|
||||
petrochem: petrochemSvg,
|
||||
lng: lngSvg,
|
||||
oil_tank: oilTankSvg,
|
||||
haz_port: hazPortSvg,
|
||||
};
|
||||
|
||||
const iconCache = new Map<string, string>();
|
||||
|
||||
function getIconUrl(subType: FacilitySubType): string {
|
||||
if (!iconCache.has(subType)) {
|
||||
const color = SUB_TYPE_META[subType].color;
|
||||
iconCache.set(subType, svgToDataUri(SUB_TYPE_SVG_FN[subType](color, 64)));
|
||||
}
|
||||
return iconCache.get(subType)!;
|
||||
}
|
||||
|
||||
export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] {
|
||||
const { layers, sc, onPick } = config;
|
||||
|
||||
const visibleFacilities = ME_ENERGY_HAZARD_FACILITIES.filter(f =>
|
||||
isFacilityVisible(f, layers),
|
||||
);
|
||||
|
||||
if (visibleFacilities.length === 0) return [];
|
||||
|
||||
const iconLayer = new IconLayer<EnergyHazardFacility>({
|
||||
id: 'me-energy-hazard-icon',
|
||||
data: visibleFacilities,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({ url: getIconUrl(d.subType), width: 64, height: 64, anchorX: 32, anchorY: 32 }),
|
||||
getSize: (d) => (d.category === 'hazard' ? 20 : 18) * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<EnergyHazardFacility>) => {
|
||||
if (info.object) onPick(info.object);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const labelLayer = new TextLayer<EnergyHazardFacility>({
|
||||
id: 'me-energy-hazard-label',
|
||||
data: visibleFacilities,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color, 200),
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 12],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
});
|
||||
|
||||
return [iconLayer, labelLayer];
|
||||
}
|
||||
179
frontend/src/components/iran/MEFacilityLayer.ts
Normal file
179
frontend/src/components/iran/MEFacilityLayer.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo, Layer } from '@deck.gl/core';
|
||||
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||
import { ME_FACILITIES } from '../../data/middleEastFacilities';
|
||||
import type { MEFacility } from '../../data/middleEastFacilities';
|
||||
|
||||
export const ME_FACILITY_COUNT = ME_FACILITIES.length;
|
||||
|
||||
// ─── Type colors ──────────────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_COLORS: Record<MEFacility['type'], string> = {
|
||||
naval: '#3b82f6',
|
||||
military_hq: '#ef4444',
|
||||
missile: '#dc2626',
|
||||
intelligence: '#8b5cf6',
|
||||
government: '#f59e0b',
|
||||
radar: '#06b6d4',
|
||||
};
|
||||
|
||||
// ─── SVG generators ──────────────────────────────────────────────────────────
|
||||
|
||||
// naval: anchor symbol
|
||||
function navalSvg(color: string): string {
|
||||
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="34" height="34" rx="4" fill="rgba(0,0,0,0.65)" stroke="${color}" stroke-width="1.5"/>
|
||||
<circle cx="18" cy="10" r="3" fill="none" stroke="${color}" stroke-width="1.8"/>
|
||||
<line x1="18" y1="13" x2="18" y2="28" stroke="${color}" stroke-width="1.8"/>
|
||||
<line x1="10" y1="17" x2="26" y2="17" stroke="${color}" stroke-width="1.8"/>
|
||||
<path d="M10 26 Q12 30 18 30 Q24 30 26 26" fill="none" stroke="${color}" stroke-width="1.8"/>
|
||||
<line x1="10" y1="26" x2="8" y2="28" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="26" y1="26" x2="28" y2="28" stroke="${color}" stroke-width="1.2"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// military_hq: star symbol
|
||||
function militaryHqSvg(color: string): string {
|
||||
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="34" height="34" rx="4" fill="rgba(0,0,0,0.65)" stroke="${color}" stroke-width="1.5"/>
|
||||
<polygon points="18,6 21,15 30,15 23,20 26,29 18,24 10,29 13,20 6,15 15,15" fill="${color}" opacity="0.9"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// missile: upward arrow / rocket shape
|
||||
function missileSvg(color: string): string {
|
||||
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="34" height="34" rx="4" fill="rgba(0,0,0,0.65)" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M18 5 L21 14 L21 24 L18 27 L15 24 L15 14 Z" fill="${color}" opacity="0.85"/>
|
||||
<path d="M15 14 L10 18 L15 18 Z" fill="${color}" opacity="0.7"/>
|
||||
<path d="M21 14 L26 18 L21 18 Z" fill="${color}" opacity="0.7"/>
|
||||
<line x1="16" y1="27" x2="14" y2="32" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
|
||||
<line x1="20" y1="27" x2="22" y2="32" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
|
||||
<circle cx="18" cy="10" r="2" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// intelligence: magnifying glass
|
||||
function intelligenceSvg(color: string): string {
|
||||
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="34" height="34" rx="4" fill="rgba(0,0,0,0.65)" stroke="${color}" stroke-width="1.5"/>
|
||||
<circle cx="16" cy="16" r="8" fill="none" stroke="${color}" stroke-width="2.2"/>
|
||||
<line x1="22" y1="22" x2="30" y2="30" stroke="${color}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="14" cy="14" r="3" fill="${color}" opacity="0.2"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// government: pillars / building
|
||||
function governmentSvg(color: string): string {
|
||||
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="34" height="34" rx="4" fill="rgba(0,0,0,0.65)" stroke="${color}" stroke-width="1.5"/>
|
||||
<rect x="6" y="30" width="24" height="2.5" rx="0.5" fill="${color}" opacity="0.8"/>
|
||||
<rect x="8" y="27" width="20" height="3" rx="0.5" fill="${color}" opacity="0.6"/>
|
||||
<rect x="9" y="14" width="2.5" height="13" fill="${color}" opacity="0.75"/>
|
||||
<rect x="14" y="14" width="2.5" height="13" fill="${color}" opacity="0.75"/>
|
||||
<rect x="19" y="14" width="2.5" height="13" fill="${color}" opacity="0.75"/>
|
||||
<rect x="24" y="14" width="2.5" height="13" fill="${color}" opacity="0.75"/>
|
||||
<path d="M6 14 L18 6 L30 14 Z" fill="${color}" opacity="0.8"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// radar: radio waves / dish
|
||||
function radarSvg(color: string): string {
|
||||
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="34" height="34" rx="4" fill="rgba(0,0,0,0.65)" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M9 28 Q9 14 18 10 Q27 14 27 28" fill="${color}" opacity="0.15" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M12 28 Q12 17 18 13 Q24 17 24 28" fill="${color}" opacity="0.2" stroke="${color}" stroke-width="1"/>
|
||||
<line x1="18" y1="10" x2="18" y2="28" stroke="${color}" stroke-width="1.5" opacity="0.6"/>
|
||||
<circle cx="18" cy="10" r="2" fill="${color}" opacity="0.9"/>
|
||||
<path d="M7 22 Q10 18 14 18" fill="none" stroke="${color}" stroke-width="1.2" opacity="0.55"/>
|
||||
<path d="M29 22 Q26 18 22 18" fill="none" stroke="${color}" stroke-width="1.2" opacity="0.55"/>
|
||||
<line x1="14" y1="28" x2="22" y2="28" stroke="${color}" stroke-width="2" opacity="0.6"/>
|
||||
<line x1="18" y1="28" x2="18" y2="32" stroke="${color}" stroke-width="2" opacity="0.6"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function buildMESvg(type: MEFacility['type'], color: string): string {
|
||||
switch (type) {
|
||||
case 'naval': return navalSvg(color);
|
||||
case 'military_hq': return militaryHqSvg(color);
|
||||
case 'missile': return missileSvg(color);
|
||||
case 'intelligence': return intelligenceSvg(color);
|
||||
case 'government': return governmentSvg(color);
|
||||
case 'radar': return radarSvg(color);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Module-level icon cache ─────────────────────────────────────────────────
|
||||
|
||||
const meIconCache = new Map<string, string>();
|
||||
|
||||
function getMEIconUrl(type: MEFacility['type']): string {
|
||||
if (!meIconCache.has(type)) {
|
||||
meIconCache.set(type, svgToDataUri(buildMESvg(type, TYPE_COLORS[type])));
|
||||
}
|
||||
return meIconCache.get(type)!;
|
||||
}
|
||||
|
||||
// ─── Label color ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getMELabelColor(type: MEFacility['type']): [number, number, number, number] {
|
||||
const hex = TYPE_COLORS[type];
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return [r, g, b, 255];
|
||||
}
|
||||
|
||||
// ─── Public interface ────────────────────────────────────────────────────────
|
||||
|
||||
export interface MEFacilityLayerConfig {
|
||||
visible: boolean;
|
||||
sc: number;
|
||||
onPick: (f: MEFacility) => void;
|
||||
}
|
||||
|
||||
export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] {
|
||||
if (!config.visible) return [];
|
||||
|
||||
const { sc, onPick } = config;
|
||||
|
||||
return [
|
||||
new IconLayer<MEFacility>({
|
||||
id: 'me-facility-icon',
|
||||
data: ME_FACILITIES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({
|
||||
url: getMEIconUrl(d.type),
|
||||
width: 36,
|
||||
height: 36,
|
||||
anchorX: 18,
|
||||
anchorY: 18,
|
||||
}),
|
||||
getSize: 16 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<MEFacility>) => {
|
||||
if (info.object) onPick(info.object);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<MEFacility>({
|
||||
id: 'me-facility-label',
|
||||
data: ME_FACILITIES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo),
|
||||
getSize: 9 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => getMELabelColor(d.type),
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
];
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { ME_FACILITIES, ME_FACILITY_TYPE_META } from '../../data/middleEastFacilities';
|
||||
import type { MEFacility } from '../../data/middleEastFacilities';
|
||||
|
||||
export const MEFacilityLayer = memo(function MEFacilityLayer() {
|
||||
const [selected, setSelected] = useState<MEFacility | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ME_FACILITIES.map(f => {
|
||||
const meta = ME_FACILITY_TYPE_META[f.type];
|
||||
return (
|
||||
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 3px ${meta.color}88)` }}
|
||||
>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 3,
|
||||
background: 'rgba(0,0,0,0.7)', border: `1.5px solid ${meta.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 9,
|
||||
}}>
|
||||
{meta.icon}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: meta.color, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{f.nameKo.length > 12 ? f.nameKo.slice(0, 12) + '..' : f.nameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const meta = ME_FACILITY_TYPE_META[selected.type];
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{selected.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{meta.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
background: meta.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{selected.country}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°{selected.lat >= 0 ? 'N' : 'S'}, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
});
|
||||
252
frontend/src/components/iran/OilFacilityLayer.ts
Normal file
252
frontend/src/components/iran/OilFacilityLayer.ts
Normal file
@ -0,0 +1,252 @@
|
||||
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo, Layer } from '@deck.gl/core';
|
||||
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||
import { iranOilFacilities } from '../../data/oilFacilities';
|
||||
import type { OilFacility, OilFacilityType } from '../../types';
|
||||
|
||||
export const IRAN_OIL_COUNT = iranOilFacilities.length;
|
||||
|
||||
// ─── Type colors ──────────────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_COLORS: Record<OilFacilityType, string> = {
|
||||
refinery: '#f59e0b',
|
||||
oilfield: '#10b981',
|
||||
gasfield: '#6366f1',
|
||||
terminal: '#ec4899',
|
||||
petrochemical: '#8b5cf6',
|
||||
desalination: '#06b6d4',
|
||||
};
|
||||
|
||||
// ─── SVG generators ──────────────────────────────────────────────────────────
|
||||
|
||||
function damageOverlaySvg(): string {
|
||||
return `<line x1="4" y1="4" x2="32" y2="32" stroke="#ff0000" stroke-width="2.5" opacity="0.8"/>
|
||||
<line x1="32" y1="4" x2="4" y2="32" stroke="#ff0000" stroke-width="2.5" opacity="0.8"/>
|
||||
<circle cx="18" cy="18" r="15" fill="none" stroke="#ff0000" stroke-width="1.5" opacity="0.4"/>`;
|
||||
}
|
||||
|
||||
function plannedOverlaySvg(): string {
|
||||
return `<circle cx="18" cy="18" r="15" fill="none" stroke="#ff6600" stroke-width="2" stroke-dasharray="4 3" opacity="0.9"/>
|
||||
<line x1="18" y1="0" x2="18" y2="4" stroke="#ff6600" stroke-width="1.5" opacity="0.7"/>
|
||||
<line x1="18" y1="32" x2="18" y2="36" stroke="#ff6600" stroke-width="1.5" opacity="0.7"/>
|
||||
<line x1="0" y1="18" x2="4" y2="18" stroke="#ff6600" stroke-width="1.5" opacity="0.7"/>
|
||||
<line x1="32" y1="18" x2="36" y2="18" stroke="#ff6600" stroke-width="1.5" opacity="0.7"/>`;
|
||||
}
|
||||
|
||||
function refinerySvg(color: string, damaged: boolean, planned: boolean): string {
|
||||
const sc = damaged ? '#ff0000' : color;
|
||||
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="refGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="${color}" stop-opacity="0.5"/>
|
||||
<stop offset="100%" stop-color="${color}" stop-opacity="0.2"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="18" cy="18" r="17" fill="url(#refGrad)" stroke="${sc}" stroke-width="${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}" stroke-width="0.8" opacity="0.5"/>
|
||||
<line x1="20" y1="13" x2="23" y2="13" stroke="${color}" stroke-width="0.8" opacity="0.5"/>
|
||||
${damaged ? damageOverlaySvg() : ''}
|
||||
${planned ? plannedOverlaySvg() : ''}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function oilfieldSvg(color: string, damaged: boolean, planned: boolean): string {
|
||||
const sc = damaged ? '#ff0000' : color;
|
||||
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<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}" stroke-width="1.5" opacity="0.85"/>
|
||||
<line x1="18" y1="14" x2="24" y2="31" stroke="${sc}" stroke-width="1.5" opacity="0.85"/>
|
||||
<line x1="14" y1="25" x2="22" y2="25" stroke="${color}" stroke-width="1" opacity="0.7"/>
|
||||
<line x1="4" y1="12" x2="28" y2="10" stroke="${sc}" stroke-width="2" opacity="0.9"/>
|
||||
<circle cx="18" cy="13" r="2" fill="${color}" opacity="0.8" stroke="${sc}" stroke-width="1"/>
|
||||
<path d="M4 12 L4 17 L7 17 L7 12" fill="none" stroke="${sc}" stroke-width="1.5" opacity="0.85"/>
|
||||
<line x1="5.5" y1="17" x2="5.5" y2="31" stroke="${color}" stroke-width="1.2" opacity="0.75"/>
|
||||
<rect x="25" y="10" width="5" height="6" rx="1" fill="${color}" opacity="0.6" stroke="${sc}" stroke-width="1"/>
|
||||
<line x1="27.5" y1="16" x2="27.5" y2="24" stroke="${color}" stroke-width="1.2" opacity="0.75"/>
|
||||
<rect x="24" y="24" width="7" height="5" rx="1" fill="${color}" opacity="0.55" stroke="${sc}" stroke-width="1"/>
|
||||
<rect x="3" y="28" width="5" height="3" rx="0.5" fill="${color}" opacity="0.65" stroke="${sc}" stroke-width="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 ? damageOverlaySvg() : ''}
|
||||
${planned ? plannedOverlaySvg() : ''}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function gasfieldSvg(color: string, damaged: boolean, planned: boolean): string {
|
||||
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="10" y1="24" x2="8" y2="33" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
|
||||
<line x1="14" y1="25" x2="13" y2="33" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
|
||||
<line x1="22" y1="25" x2="23" y2="33" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
|
||||
<line x1="26" y1="24" x2="28" y2="33" stroke="${color}" stroke-width="1.5" opacity="0.7"/>
|
||||
<line x1="9" y1="29" x2="14" y2="27" stroke="${color}" stroke-width="0.8" opacity="0.55"/>
|
||||
<line x1="22" y1="27" x2="27" y2="29" stroke="${color}" stroke-width="0.8" opacity="0.55"/>
|
||||
<line x1="7" y1="33" x2="29" y2="33" stroke="${color}" stroke-width="1.2" opacity="0.6"/>
|
||||
<ellipse cx="18" cy="16" rx="12" ry="10" fill="${color}" opacity="0.45" stroke="${damaged ? '#ff0000' : color}" stroke-width="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}" stroke-width="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}" stroke-width="1.5" opacity="0.7"/>
|
||||
${damaged ? damageOverlaySvg() : ''}
|
||||
${planned ? plannedOverlaySvg() : ''}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function terminalSvg(color: string, damaged: boolean, planned: boolean): string {
|
||||
const sc = damaged ? '#ff0000' : color;
|
||||
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="18" cy="10" r="4" fill="none" stroke="${sc}" stroke-width="2"/>
|
||||
<line x1="18" y1="14" x2="18" y2="28" stroke="${color}" stroke-width="2"/>
|
||||
<path d="M10 24 C10 28 14 32 18 32 C22 32 26 28 26 24" fill="none" stroke="${color}" stroke-width="2"/>
|
||||
<line x1="18" y1="8" x2="18" y2="6" stroke="${color}" stroke-width="2"/>
|
||||
<line x1="16" y1="6" x2="20" y2="6" stroke="${color}" stroke-width="2.5"/>
|
||||
<path d="M6 24 L10 24" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M26 24 L30 24" stroke="${color}" stroke-width="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 ? damageOverlaySvg() : ''}
|
||||
${planned ? plannedOverlaySvg() : ''}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function petrochemSvg(color: string, damaged: boolean, planned: boolean): string {
|
||||
const sc = damaged ? '#ff0000' : '#fff';
|
||||
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 6 L14 16 L8 30 L28 30 L22 16 L22 6Z" fill="${color}" opacity="0.7" stroke="${sc}" stroke-width="1"/>
|
||||
<rect x="13" y="4" width="10" height="4" rx="1" fill="${color}" opacity="0.9" stroke="${sc}" stroke-width="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 ? damageOverlaySvg() : ''}
|
||||
${planned ? plannedOverlaySvg() : ''}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function desalSvg(color: string, damaged: boolean, planned: boolean): string {
|
||||
const sc = damaged ? '#ff0000' : color;
|
||||
return `<svg width="36" height="36" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<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}" stroke-width="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}" stroke-width="0.8"/>
|
||||
<line x1="27" y1="8" x2="27" y2="12" stroke="${sc}" stroke-width="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}" stroke-width="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}" stroke-width="1"/>
|
||||
<line x1="24" y1="24" x2="31" y2="24" stroke="${color}" stroke-width="0.6" opacity="0.5"/>
|
||||
<line x1="24" y1="27" x2="31" y2="27" stroke="${color}" stroke-width="0.6" opacity="0.5"/>
|
||||
<path d="M18 22 L23 22" stroke="${sc}" stroke-width="1" opacity="0.6"/>
|
||||
<line x1="27.5" y1="32" x2="27.5" y2="34" stroke="${color}" stroke-width="1" opacity="0.55"/>
|
||||
<line x1="4" y1="34" x2="33" y2="34" stroke="${color}" stroke-width="1" opacity="0.25"/>
|
||||
${damaged ? damageOverlaySvg() : ''}
|
||||
${planned ? plannedOverlaySvg() : ''}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function buildSvg(type: OilFacilityType, color: string, damaged: boolean, planned: boolean): string {
|
||||
switch (type) {
|
||||
case 'refinery': return refinerySvg(color, damaged, planned);
|
||||
case 'oilfield': return oilfieldSvg(color, damaged, planned);
|
||||
case 'gasfield': return gasfieldSvg(color, damaged, planned);
|
||||
case 'terminal': return terminalSvg(color, damaged, planned);
|
||||
case 'petrochemical': return petrochemSvg(color, damaged, planned);
|
||||
case 'desalination': return desalSvg(color, damaged, planned);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Module-level icon cache ─────────────────────────────────────────────────
|
||||
|
||||
const oilIconCache = new Map<string, string>();
|
||||
|
||||
function getOilIconUrl(type: OilFacilityType, damaged: boolean, planned: boolean): string {
|
||||
const key = `${type}-${damaged ? 'd' : 'n'}-${planned ? 'p' : 'n'}`;
|
||||
if (!oilIconCache.has(key)) {
|
||||
const color = TYPE_COLORS[type];
|
||||
oilIconCache.set(key, svgToDataUri(buildSvg(type, color, damaged, planned)));
|
||||
}
|
||||
return oilIconCache.get(key)!;
|
||||
}
|
||||
|
||||
// ─── Label color ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getLabelColor(type: OilFacilityType, damaged: boolean, planned: boolean): [number, number, number, number] {
|
||||
if (damaged) return [255, 0, 0, 255];
|
||||
if (planned) return [255, 102, 0, 255];
|
||||
const hex = TYPE_COLORS[type];
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return [r, g, b, 255];
|
||||
}
|
||||
|
||||
// ─── Public interface ────────────────────────────────────────────────────────
|
||||
|
||||
export interface OilLayerConfig {
|
||||
visible: boolean;
|
||||
sc: number;
|
||||
currentTime: number;
|
||||
onPick: (f: OilFacility) => void;
|
||||
}
|
||||
|
||||
export function createIranOilLayers(config: OilLayerConfig): Layer[] {
|
||||
if (!config.visible) return [];
|
||||
|
||||
const { sc, currentTime, onPick } = config;
|
||||
|
||||
return [
|
||||
new IconLayer<OilFacility>({
|
||||
id: 'iran-oil-icon',
|
||||
data: iranOilFacilities,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => {
|
||||
const isDamaged = !!(d.damaged && d.damagedAt && currentTime >= d.damagedAt);
|
||||
const isPlanned = !!d.planned && !isDamaged;
|
||||
return {
|
||||
url: getOilIconUrl(d.type, isDamaged, isPlanned),
|
||||
width: 36,
|
||||
height: 36,
|
||||
anchorX: 18,
|
||||
anchorY: 18,
|
||||
};
|
||||
},
|
||||
getSize: 18 * sc,
|
||||
updateTriggers: { getSize: [sc], getIcon: [currentTime] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<OilFacility>) => {
|
||||
if (info.object) onPick(info.object);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
new TextLayer<OilFacility>({
|
||||
id: 'iran-oil-label',
|
||||
data: iranOilFacilities,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo,
|
||||
getSize: 9 * sc,
|
||||
updateTriggers: { getSize: [sc], getColor: [currentTime] },
|
||||
getColor: (d) => {
|
||||
const isDamaged = !!(d.damaged && d.damagedAt && currentTime >= d.damagedAt);
|
||||
const isPlanned = !!d.planned && !isDamaged;
|
||||
return getLabelColor(d.type, isDamaged, isPlanned);
|
||||
},
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
];
|
||||
}
|
||||
@ -1,320 +0,0 @@
|
||||
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 '';
|
||||
}
|
||||
|
||||
|
||||
// Planned strike targeting ring (SVG 내부 — 위치 정확도)
|
||||
function PlannedOverlay() {
|
||||
return (
|
||||
<>
|
||||
<circle cx={18} cy={18} r={16} fill="none" stroke="#ff6600" strokeWidth={2}
|
||||
strokeDasharray="4 3" opacity={0.9}>
|
||||
<animate attributeName="r" values="14;17;14" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.9;0.5;0.9" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
{/* Crosshair lines */}
|
||||
<line x1={18} y1={0} x2={18} y2={4} stroke="#ff6600" strokeWidth={1.5} opacity={0.7} />
|
||||
<line x1={18} y1={32} x2={18} y2={36} stroke="#ff6600" strokeWidth={1.5} opacity={0.7} />
|
||||
<line x1={0} y1={18} x2={4} y2={18} stroke="#ff6600" strokeWidth={1.5} opacity={0.7} />
|
||||
<line x1={32} y1={18} x2={36} y2={18} stroke="#ff6600" strokeWidth={1.5} opacity={0.7} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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, planned }: { size: number; color: string; damaged: boolean; planned: 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 />}
|
||||
{planned && <PlannedOverlay />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function OilFieldIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: 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 />}
|
||||
{planned && <PlannedOverlay />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GasFieldIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: 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 />}
|
||||
{planned && <PlannedOverlay />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: 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 />}
|
||||
{planned && <PlannedOverlay />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PetrochemIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: 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 />}
|
||||
{planned && <PlannedOverlay />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DesalIcon({ size, color, damaged, planned }: { size: number; color: string; damaged: boolean; planned: 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 />}
|
||||
{planned && <PlannedOverlay />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 모든 아이콘을 36x36 고정 크기로 렌더링 (anchor="center" 정렬용)
|
||||
const ICON_RENDER_SIZE = 36;
|
||||
|
||||
function FacilityIconSvg({ facility, damaged, planned }: { facility: OilFacility; damaged: boolean; planned: boolean }) {
|
||||
const color = TYPE_COLORS[facility.type];
|
||||
const props = { size: ICON_RENDER_SIZE, color, damaged, planned };
|
||||
switch (facility.type) {
|
||||
case 'refinery': return <RefineryIcon {...props} />;
|
||||
case 'oilfield': return <OilFieldIcon {...props} />;
|
||||
case 'gasfield': return <GasFieldIcon {...props} />;
|
||||
case 'terminal': return <TerminalIcon {...props} />;
|
||||
case 'petrochemical': return <PetrochemIcon {...props} />;
|
||||
case 'desalination': return <DesalIcon {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
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="top-left" style={{ overflow: 'visible' }}>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
style={{
|
||||
width: ICON_RENDER_SIZE,
|
||||
height: ICON_RENDER_SIZE,
|
||||
position: 'relative',
|
||||
transform: `translate(-${ICON_RENDER_SIZE / 2}px, -${ICON_RENDER_SIZE / 2}px)`,
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
|
||||
>
|
||||
<FacilityIconSvg facility={facility} damaged={isDamaged} planned={isPlanned} />
|
||||
{/* Label */}
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
@ -7,11 +7,16 @@ import { SatelliteLayer } from '../layers/SatelliteLayer';
|
||||
import { ShipLayer } from '../layers/ShipLayer';
|
||||
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
|
||||
import { SeismicMarker } from '../layers/SeismicMarker';
|
||||
import { OilFacilityLayer } from './OilFacilityLayer';
|
||||
import { AirportLayer } from './AirportLayer';
|
||||
import { MEFacilityLayer } from './MEFacilityLayer';
|
||||
import { iranOilFacilities } from '../../data/oilFacilities';
|
||||
import { middleEastAirports } from '../../data/airports';
|
||||
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
|
||||
import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer';
|
||||
import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities';
|
||||
import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities';
|
||||
import { createIranOilLayers } from './createIranOilLayers';
|
||||
import type { OilFacility } from './createIranOilLayers';
|
||||
import { createIranAirportLayers } from './createIranAirportLayers';
|
||||
import type { Airport } from './createIranAirportLayers';
|
||||
import { createMEFacilityLayers } from './createMEFacilityLayers';
|
||||
import type { MEFacility } from './createMEFacilityLayers';
|
||||
import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types';
|
||||
import { countryLabelsGeoJSON } from '../../data/countryLabels';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
@ -111,10 +116,48 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
|
||||
osint: 8,
|
||||
};
|
||||
|
||||
type IranPickedFacility =
|
||||
| { kind: 'oil'; data: OilFacility }
|
||||
| { kind: 'airport'; data: Airport }
|
||||
| { kind: 'meFacility'; data: MEFacility };
|
||||
|
||||
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, seismicMarker }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||
const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null);
|
||||
const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(null);
|
||||
const [zoomLevel, setZoomLevel] = useState(5);
|
||||
const zoomRef = useRef(5);
|
||||
|
||||
const handleZoom = useCallback((e: { viewState: { zoom: number } }) => {
|
||||
const z = Math.floor(e.viewState.zoom);
|
||||
if (z !== zoomRef.current) {
|
||||
zoomRef.current = z;
|
||||
setZoomLevel(z);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const zoomScale = useMemo(() => {
|
||||
if (zoomLevel <= 4) return 0.8;
|
||||
if (zoomLevel <= 5) return 0.9;
|
||||
if (zoomLevel <= 6) return 1.0;
|
||||
if (zoomLevel <= 7) return 1.2;
|
||||
if (zoomLevel <= 8) return 1.5;
|
||||
if (zoomLevel <= 9) return 1.8;
|
||||
if (zoomLevel <= 10) return 2.2;
|
||||
if (zoomLevel <= 11) return 2.5;
|
||||
if (zoomLevel <= 12) return 2.8;
|
||||
if (zoomLevel <= 13) return 3.5;
|
||||
return 4.2;
|
||||
}, [zoomLevel]);
|
||||
|
||||
const iranDeckLayers = useMemo(() => [
|
||||
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
|
||||
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
|
||||
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
|
||||
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, onPick: setMePickedFacility }),
|
||||
], [layers, zoomScale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (flyToTarget && mapRef.current) {
|
||||
@ -187,6 +230,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
||||
initialViewState={{ longitude: initialCenter?.lng ?? 44, latitude: initialCenter?.lat ?? 31.5, zoom: initialZoom ?? 5 }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={MAP_STYLE}
|
||||
onZoom={handleZoom}
|
||||
>
|
||||
<NavigationControl position="top-right" />
|
||||
|
||||
@ -432,9 +476,158 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} hoveredMmsi={hoveredShipMmsi} focusMmsi={focusShipMmsi} onFocusClear={onFocusShipClear} />}
|
||||
{layers.ships && <DamagedShipLayer currentTime={currentTime} />}
|
||||
{seismicMarker && <SeismicMarker {...seismicMarker} />}
|
||||
{layers.airports && <AirportLayer airports={middleEastAirports} />}
|
||||
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
|
||||
{layers.meFacilities && <MEFacilityLayer />}
|
||||
|
||||
<DeckGLOverlay layers={iranDeckLayers} />
|
||||
|
||||
{mePickedFacility && (() => {
|
||||
const meta = SUB_TYPE_META[mePickedFacility.subType];
|
||||
return (
|
||||
<Popup
|
||||
longitude={mePickedFacility.lng}
|
||||
latitude={mePickedFacility.lat}
|
||||
onClose={() => setMePickedFacility(null)}
|
||||
closeOnClick={false}
|
||||
anchor="bottom"
|
||||
maxWidth="320px"
|
||||
className="gl-popup"
|
||||
>
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<strong style={{ fontSize: 13 }}>{meta.icon} {mePickedFacility.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ background: meta.color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
||||
{mePickedFacility.country}
|
||||
</span>
|
||||
{mePickedFacility.capacityMW !== undefined && (
|
||||
<span style={{ background: '#222', color: '#94a3b8', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
||||
{mePickedFacility.capacityMW.toLocaleString()} MW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{mePickedFacility.description}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{mePickedFacility.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{mePickedFacility.lat.toFixed(4)}°{mePickedFacility.lat >= 0 ? 'N' : 'S'}, {mePickedFacility.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
|
||||
{iranPickedFacility && (() => {
|
||||
const { kind, data } = iranPickedFacility;
|
||||
if (kind === 'oil') {
|
||||
const OIL_TYPE_COLORS: Record<string, string> = {
|
||||
refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1',
|
||||
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
|
||||
};
|
||||
const color = OIL_TYPE_COLORS[data.type] ?? '#888';
|
||||
return (
|
||||
<Popup longitude={data.lng} latitude={data.lat}
|
||||
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<strong style={{ fontSize: 13 }}>{data.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ background: color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
||||
{data.type}
|
||||
</span>
|
||||
{data.operator && (
|
||||
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
||||
{data.operator}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{data.description && (
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{data.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
if (kind === 'airport') {
|
||||
const isMil = data.type === 'military';
|
||||
const color = isMil ? '#ef4444' : '#f59e0b';
|
||||
return (
|
||||
<Popup longitude={data.lng} latitude={data.lat}
|
||||
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: isMil ? '#991b1b' : '#92400e', color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<strong style={{ fontSize: 13 }}>{data.nameKo ?? data.name}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ background: color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
||||
{data.type === 'military' ? 'Military Airbase' : data.type === 'large' ? 'International Airport' : 'Airport'}
|
||||
</span>
|
||||
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
||||
{data.country}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px', fontSize: 11, marginBottom: 6 }}>
|
||||
{data.iata && <><span style={{ color: '#888' }}>IATA</span><strong>{data.iata}</strong></>}
|
||||
<span style={{ color: '#888' }}>ICAO</span><strong>{data.icao}</strong>
|
||||
{data.city && <><span style={{ color: '#888' }}>City</span><span>{data.city}</span></>}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°{data.lng >= 0 ? 'E' : 'W'}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
if (kind === 'meFacility') {
|
||||
const meta = { naval: { label: 'Naval Base', color: '#3b82f6', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#ef4444', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#dc2626', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#8b5cf6', icon: '🔍' }, government: { label: 'Government', color: '#f59e0b', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#06b6d4', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' };
|
||||
return (
|
||||
<Popup longitude={data.lng} latitude={data.lat}
|
||||
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{data.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{meta.icon} {data.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ background: meta.color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
||||
{data.country}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{data.description}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{data.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
@ -7,11 +7,16 @@ import { SatelliteLayer } from '../layers/SatelliteLayer';
|
||||
import { ShipLayer } from '../layers/ShipLayer';
|
||||
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
|
||||
import { SeismicMarker } from '../layers/SeismicMarker';
|
||||
import { OilFacilityLayer } from './OilFacilityLayer';
|
||||
import { AirportLayer } from './AirportLayer';
|
||||
import { MEFacilityLayer } from './MEFacilityLayer';
|
||||
import { iranOilFacilities } from '../../data/oilFacilities';
|
||||
import { middleEastAirports } from '../../data/airports';
|
||||
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
|
||||
import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer';
|
||||
import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities';
|
||||
import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities';
|
||||
import { createIranOilLayers } from './createIranOilLayers';
|
||||
import type { OilFacility } from './createIranOilLayers';
|
||||
import { createIranAirportLayers } from './createIranAirportLayers';
|
||||
import type { Airport } from './createIranAirportLayers';
|
||||
import { createMEFacilityLayers } from './createMEFacilityLayers';
|
||||
import type { MEFacility } from './createMEFacilityLayers';
|
||||
import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types';
|
||||
import { countryLabelsGeoJSON } from '../../data/countryLabels';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
@ -94,10 +99,48 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
|
||||
osint: 8,
|
||||
};
|
||||
|
||||
type IranPickedFacility =
|
||||
| { kind: 'oil'; data: OilFacility }
|
||||
| { kind: 'airport'; data: Airport }
|
||||
| { kind: 'meFacility'; data: MEFacility };
|
||||
|
||||
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers, hoveredShipMmsi, focusShipMmsi, onFocusShipClear, flyToTarget, onFlyToDone, seismicMarker }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||
const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null);
|
||||
const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(null);
|
||||
const [zoomLevel, setZoomLevel] = useState(5);
|
||||
const zoomRef = useRef(5);
|
||||
|
||||
const handleZoom = useCallback((e: { viewState: { zoom: number } }) => {
|
||||
const z = Math.floor(e.viewState.zoom);
|
||||
if (z !== zoomRef.current) {
|
||||
zoomRef.current = z;
|
||||
setZoomLevel(z);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const zoomScale = useMemo(() => {
|
||||
if (zoomLevel <= 4) return 0.8;
|
||||
if (zoomLevel <= 5) return 0.9;
|
||||
if (zoomLevel <= 6) return 1.0;
|
||||
if (zoomLevel <= 7) return 1.2;
|
||||
if (zoomLevel <= 8) return 1.5;
|
||||
if (zoomLevel <= 9) return 1.8;
|
||||
if (zoomLevel <= 10) return 2.2;
|
||||
if (zoomLevel <= 11) return 2.5;
|
||||
if (zoomLevel <= 12) return 2.8;
|
||||
if (zoomLevel <= 13) return 3.5;
|
||||
return 4.2;
|
||||
}, [zoomLevel]);
|
||||
|
||||
const iranDeckLayers = useMemo(() => [
|
||||
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
|
||||
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
|
||||
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
|
||||
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, onPick: setMePickedFacility }),
|
||||
], [layers, zoomScale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (flyToTarget && mapRef.current) {
|
||||
@ -133,8 +176,53 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={SATELLITE_STYLE as maplibregl.StyleSpecification}
|
||||
attributionControl={false}
|
||||
onZoom={handleZoom}
|
||||
>
|
||||
<NavigationControl position="top-right" />
|
||||
<DeckGLOverlay layers={iranDeckLayers} />
|
||||
|
||||
{mePickedFacility && (() => {
|
||||
const meta = SUB_TYPE_META[mePickedFacility.subType];
|
||||
return (
|
||||
<Popup
|
||||
longitude={mePickedFacility.lng}
|
||||
latitude={mePickedFacility.lat}
|
||||
onClose={() => setMePickedFacility(null)}
|
||||
closeOnClick={false}
|
||||
anchor="bottom"
|
||||
maxWidth="320px"
|
||||
className="gl-popup"
|
||||
>
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<strong style={{ fontSize: 13 }}>{meta.icon} {mePickedFacility.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ background: meta.color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
||||
{mePickedFacility.country}
|
||||
</span>
|
||||
{mePickedFacility.capacityMW !== undefined && (
|
||||
<span style={{ background: '#222', color: '#94a3b8', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
||||
{mePickedFacility.capacityMW.toLocaleString()} MW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{mePickedFacility.description}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{mePickedFacility.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{mePickedFacility.lat.toFixed(4)}°{mePickedFacility.lat >= 0 ? 'N' : 'S'}, {mePickedFacility.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Korean country labels */}
|
||||
<Source id="country-labels" type="geojson" data={countryLabels}>
|
||||
@ -264,15 +352,119 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
||||
</Popup>
|
||||
)}
|
||||
|
||||
{iranPickedFacility && (() => {
|
||||
const { kind, data } = iranPickedFacility;
|
||||
if (kind === 'oil') {
|
||||
const OIL_TYPE_COLORS: Record<string, string> = {
|
||||
refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1',
|
||||
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
|
||||
};
|
||||
const color = OIL_TYPE_COLORS[data.type] ?? '#888';
|
||||
return (
|
||||
<Popup longitude={data.lng} latitude={data.lat}
|
||||
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<strong style={{ fontSize: 13 }}>{data.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ background: color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
||||
{data.type}
|
||||
</span>
|
||||
{data.operator && (
|
||||
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
||||
{data.operator}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{data.description && (
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{data.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
if (kind === 'airport') {
|
||||
const isMil = data.type === 'military';
|
||||
const color = isMil ? '#ef4444' : '#f59e0b';
|
||||
return (
|
||||
<Popup longitude={data.lng} latitude={data.lat}
|
||||
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: isMil ? '#991b1b' : '#92400e', color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<strong style={{ fontSize: 13 }}>{data.nameKo ?? data.name}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ background: color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
||||
{data.type === 'military' ? 'Military Airbase' : data.type === 'large' ? 'International Airport' : 'Airport'}
|
||||
</span>
|
||||
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
||||
{data.country}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px', fontSize: 11, marginBottom: 6 }}>
|
||||
{data.iata && <><span style={{ color: '#888' }}>IATA</span><strong>{data.iata}</strong></>}
|
||||
<span style={{ color: '#888' }}>ICAO</span><strong>{data.icao}</strong>
|
||||
{data.city && <><span style={{ color: '#888' }}>City</span><span>{data.city}</span></>}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°{data.lng >= 0 ? 'E' : 'W'}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
if (kind === 'meFacility') {
|
||||
const meta = { naval: { label: 'Naval Base', color: '#3b82f6', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#ef4444', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#dc2626', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#8b5cf6', icon: '🔍' }, government: { label: 'Government', color: '#f59e0b', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#06b6d4', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' };
|
||||
return (
|
||||
<Popup longitude={data.lng} latitude={data.lat}
|
||||
onClose={() => setIranPickedFacility(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{data.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{meta.icon} {data.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ background: meta.color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
||||
{data.country}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{data.description}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{data.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{data.lat.toFixed(4)}°{data.lat >= 0 ? 'N' : 'S'}, {data.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Overlay layers */}
|
||||
{layers.aircraft && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
|
||||
{layers.satellites && <SatelliteLayer satellites={satellites} />}
|
||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} hoveredMmsi={hoveredShipMmsi} focusMmsi={focusShipMmsi} onFocusClear={onFocusShipClear} />}
|
||||
<DamagedShipLayer currentTime={currentTime} />
|
||||
{seismicMarker && <SeismicMarker {...seismicMarker} />}
|
||||
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
|
||||
{layers.airports && <AirportLayer airports={middleEastAirports} />}
|
||||
{layers.meFacilities && <MEFacilityLayer />}
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
|
||||
103
frontend/src/components/iran/createIranAirportLayers.ts
Normal file
103
frontend/src/components/iran/createIranAirportLayers.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { Layer, PickingInfo } from '@deck.gl/core';
|
||||
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||
import { middleEastAirports } from '../../data/airports';
|
||||
import type { Airport } from '../../data/airports';
|
||||
|
||||
export { type Airport };
|
||||
|
||||
export const IRAN_AIRPORT_COUNT = middleEastAirports.length;
|
||||
|
||||
const US_BASE_ICAOS = new Set([
|
||||
'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL',
|
||||
]);
|
||||
|
||||
function getAirportColor(airport: Airport): string {
|
||||
const isMil = airport.type === 'military';
|
||||
const isUS = isMil && US_BASE_ICAOS.has(airport.icao);
|
||||
if (isUS) return '#3b82f6';
|
||||
if (isMil) return '#ef4444';
|
||||
if (airport.type === 'international') return '#f59e0b';
|
||||
return '#7c8aaa';
|
||||
}
|
||||
|
||||
function airportSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
|
||||
fill="${color}" stroke="#fff" stroke-width="0.3"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const iconCache = new Map<string, string>();
|
||||
|
||||
function getIconUrl(airport: Airport): string {
|
||||
const color = getAirportColor(airport);
|
||||
const size = airport.type === 'military' && US_BASE_ICAOS.has(airport.icao) ? 48 : 40;
|
||||
const key = `${color}-${size}`;
|
||||
if (!iconCache.has(key)) {
|
||||
iconCache.set(key, svgToDataUri(airportSvg(color, size)));
|
||||
}
|
||||
return iconCache.get(key)!;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return [r, g, b, alpha];
|
||||
}
|
||||
|
||||
export interface IranAirportLayerConfig {
|
||||
visible: boolean;
|
||||
sc: number;
|
||||
onPick: (airport: Airport) => void;
|
||||
}
|
||||
|
||||
export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[] {
|
||||
const { visible, sc, onPick } = config;
|
||||
if (!visible) return [];
|
||||
|
||||
const iconLayer = new IconLayer<Airport>({
|
||||
id: 'iran-airport-icon',
|
||||
data: middleEastAirports,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => {
|
||||
const isMilUS = d.type === 'military' && US_BASE_ICAOS.has(d.icao);
|
||||
const sz = isMilUS ? 48 : 40;
|
||||
return { url: getIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||
},
|
||||
getSize: (d) => (d.type === 'military' && US_BASE_ICAOS.has(d.icao) ? 20 : 16) * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<Airport>) => {
|
||||
if (info.object) onPick(info.object);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const labelLayer = new TextLayer<Airport>({
|
||||
id: 'iran-airport-label',
|
||||
data: middleEastAirports,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => {
|
||||
const nameKo = d.nameKo ?? d.name;
|
||||
return nameKo.length > 10 ? nameKo.slice(0, 10) + '..' : nameKo;
|
||||
},
|
||||
getSize: 11 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => hexToRgba(getAirportColor(d)),
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
});
|
||||
|
||||
return [iconLayer, labelLayer];
|
||||
}
|
||||
153
frontend/src/components/iran/createIranOilLayers.ts
Normal file
153
frontend/src/components/iran/createIranOilLayers.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { Layer, PickingInfo } from '@deck.gl/core';
|
||||
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||
import { iranOilFacilities } from '../../data/oilFacilities';
|
||||
import type { OilFacility, OilFacilityType } from '../../types';
|
||||
|
||||
export { type OilFacility };
|
||||
|
||||
export const IRAN_OIL_COUNT = iranOilFacilities.length;
|
||||
|
||||
const TYPE_COLORS: Record<OilFacilityType, string> = {
|
||||
refinery: '#f59e0b',
|
||||
oilfield: '#10b981',
|
||||
gasfield: '#6366f1',
|
||||
terminal: '#ec4899',
|
||||
petrochemical: '#8b5cf6',
|
||||
desalination: '#06b6d4',
|
||||
};
|
||||
|
||||
function refinerySvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<rect x="5" y="10" width="14" height="8" rx="1" fill="${color}" opacity="0.7"/>
|
||||
<rect x="7" y="6" width="2" height="5" fill="${color}" opacity="0.6"/>
|
||||
<rect x="11" y="4" width="2" height="7" fill="${color}" opacity="0.6"/>
|
||||
<rect x="15" y="7" width="2" height="4" fill="${color}" opacity="0.6"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function oilfieldSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="6" y1="18" x2="18" y2="18" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="12" y1="8" x2="8" y2="18" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="12" y1="8" x2="16" y2="18" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="5" y1="10" x2="17" y2="9" stroke="${color}" stroke-width="2"/>
|
||||
<circle cx="12" cy="8" r="1.5" fill="${color}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function gasfieldSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<ellipse cx="12" cy="14" rx="5" ry="4" fill="${color}" opacity="0.6"/>
|
||||
<line x1="12" y1="10" x2="12" y2="5" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M9 7 Q12 4 15 7" fill="none" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M10 5.5 Q12 3 14 5.5" fill="none" stroke="${color}" stroke-width="0.8" opacity="0.7"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function terminalSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M8 9 Q12 6 16 9 Q16 14 12 16 Q8 14 8 9Z" fill="${color}" opacity="0.5"/>
|
||||
<path d="M6 16 Q12 20 18 16" fill="none" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke="${color}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function petrochemSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<rect x="10" y="5" width="4" height="9" rx="1" fill="${color}" opacity="0.6"/>
|
||||
<ellipse cx="12" cy="15" rx="4" ry="2.5" fill="${color}" opacity="0.7"/>
|
||||
<line x1="7" y1="10" x2="10" y2="10" stroke="${color}" stroke-width="1"/>
|
||||
<line x1="14" y1="10" x2="17" y2="10" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M7 10 Q6 13 8 15" fill="none" stroke="${color}" stroke-width="0.8"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function desalinationSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 Q16 10 16 14 Q16 18 12 18 Q8 18 8 14 Q8 10 12 5Z" fill="${color}" opacity="0.7"/>
|
||||
<path d="M10 14 Q12 16 14 14" fill="none" stroke="#fff" stroke-width="0.8" opacity="0.6"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
type SvgFn = (color: string, size: number) => string;
|
||||
|
||||
const TYPE_SVG_FN: Record<OilFacilityType, SvgFn> = {
|
||||
refinery: refinerySvg,
|
||||
oilfield: oilfieldSvg,
|
||||
gasfield: gasfieldSvg,
|
||||
terminal: terminalSvg,
|
||||
petrochemical: petrochemSvg,
|
||||
desalination: desalinationSvg,
|
||||
};
|
||||
|
||||
const iconCache = new Map<string, string>();
|
||||
|
||||
function getIconUrl(type: OilFacilityType): string {
|
||||
if (!iconCache.has(type)) {
|
||||
const color = TYPE_COLORS[type];
|
||||
iconCache.set(type, svgToDataUri(TYPE_SVG_FN[type](color, 64)));
|
||||
}
|
||||
return iconCache.get(type)!;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return [r, g, b, alpha];
|
||||
}
|
||||
|
||||
export interface IranOilLayerConfig {
|
||||
visible: boolean;
|
||||
sc: number;
|
||||
onPick: (facility: OilFacility) => void;
|
||||
}
|
||||
|
||||
export function createIranOilLayers(config: IranOilLayerConfig): Layer[] {
|
||||
const { visible, sc, onPick } = config;
|
||||
if (!visible) return [];
|
||||
|
||||
const iconLayer = new IconLayer<OilFacility>({
|
||||
id: 'iran-oil-icon',
|
||||
data: iranOilFacilities,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({ url: getIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }),
|
||||
getSize: 18 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<OilFacility>) => {
|
||||
if (info.object) onPick(info.object);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const labelLayer = new TextLayer<OilFacility>({
|
||||
id: 'iran-oil-label',
|
||||
data: iranOilFacilities,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => hexToRgba(TYPE_COLORS[d.type]),
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
});
|
||||
|
||||
return [iconLayer, labelLayer];
|
||||
}
|
||||
148
frontend/src/components/iran/createMEFacilityLayers.ts
Normal file
148
frontend/src/components/iran/createMEFacilityLayers.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { Layer, PickingInfo } from '@deck.gl/core';
|
||||
import { svgToDataUri } from '../../utils/svgToDataUri';
|
||||
import { ME_FACILITIES, ME_FACILITY_TYPE_META } from '../../data/middleEastFacilities';
|
||||
import type { MEFacility } from '../../data/middleEastFacilities';
|
||||
|
||||
export { type MEFacility };
|
||||
|
||||
export const ME_FACILITY_COUNT = ME_FACILITIES.length;
|
||||
|
||||
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return [r, g, b, alpha];
|
||||
}
|
||||
|
||||
function navalSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="7" y1="9" x2="17" y2="9" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M7 9 Q6 13 8 15" fill="none" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M17 9 Q18 13 16 15" fill="none" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M8 15 Q12 18 16 15" fill="none" stroke="${color}" stroke-width="1.2"/>
|
||||
<circle cx="12" cy="5" r="1.5" fill="${color}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function militaryHqSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<polygon points="12,5 13.8,10.1 19.2,10.1 14.7,13.2 16.5,18.3 12,15.2 7.5,18.3 9.3,13.2 4.8,10.1 10.2,10.1" fill="${color}" opacity="0.85"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function missileSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 L14 10 L14 17 L12 19 L10 17 L10 10 Z" fill="${color}" opacity="0.85"/>
|
||||
<path d="M10 13 L7 16 L10 15 Z" fill="${color}" opacity="0.7"/>
|
||||
<path d="M14 13 L17 16 L14 15 Z" fill="${color}" opacity="0.7"/>
|
||||
<path d="M11 10 L13 10 L13 8 Q12 6 11 8 Z" fill="#fff" opacity="0.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function intelligenceSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<ellipse cx="11" cy="11" rx="5" ry="5" fill="none" stroke="${color}" stroke-width="1.5"/>
|
||||
<circle cx="11" cy="11" r="2" fill="${color}" opacity="0.7"/>
|
||||
<line x1="15" y1="15" x2="18" y2="18" stroke="${color}" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function governmentSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="5" y1="18" x2="19" y2="18" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="5" x2="5" y2="9" stroke="${color}" stroke-width="1"/>
|
||||
<line x1="12" y1="5" x2="19" y2="9" stroke="${color}" stroke-width="1"/>
|
||||
<line x1="5" y1="9" x2="19" y2="9" stroke="${color}" stroke-width="1"/>
|
||||
<rect x="7" y="10" width="2" height="8" fill="${color}" opacity="0.7"/>
|
||||
<rect x="11" y="10" width="2" height="8" fill="${color}" opacity="0.7"/>
|
||||
<rect x="15" y="10" width="2" height="8" fill="${color}" opacity="0.7"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function radarSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<ellipse cx="12" cy="9" rx="5" ry="3" fill="none" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="12" x2="12" y2="19" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M7 7 Q9 4 12 4 Q15 4 17 7" fill="none" stroke="${color}" stroke-width="0.8" opacity="0.6"/>
|
||||
<path d="M5 9 Q7 5 12 5 Q17 5 19 9" fill="none" stroke="${color}" stroke-width="0.6" opacity="0.4"/>
|
||||
<circle cx="12" cy="9" r="1.2" fill="${color}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
type MEFacilityType = MEFacility['type'];
|
||||
|
||||
type SvgFn = (color: string, size: number) => string;
|
||||
|
||||
const TYPE_SVG_FN: Record<MEFacilityType, SvgFn> = {
|
||||
naval: navalSvg,
|
||||
military_hq: militaryHqSvg,
|
||||
missile: missileSvg,
|
||||
intelligence: intelligenceSvg,
|
||||
government: governmentSvg,
|
||||
radar: radarSvg,
|
||||
};
|
||||
|
||||
const iconCache = new Map<string, string>();
|
||||
|
||||
function getIconUrl(type: MEFacilityType): string {
|
||||
if (!iconCache.has(type)) {
|
||||
const color = ME_FACILITY_TYPE_META[type].color;
|
||||
iconCache.set(type, svgToDataUri(TYPE_SVG_FN[type](color, 64)));
|
||||
}
|
||||
return iconCache.get(type)!;
|
||||
}
|
||||
|
||||
export interface MEFacilityLayerConfig {
|
||||
visible: boolean;
|
||||
sc: number;
|
||||
onPick: (facility: MEFacility) => void;
|
||||
}
|
||||
|
||||
export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] {
|
||||
const { visible, sc, onPick } = config;
|
||||
if (!visible) return [];
|
||||
|
||||
const iconLayer = new IconLayer<MEFacility>({
|
||||
id: 'me-facility-icon',
|
||||
data: ME_FACILITIES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({ url: getIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }),
|
||||
getSize: 18 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<MEFacility>) => {
|
||||
if (info.object) onPick(info.object);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const labelLayer = new TextLayer<MEFacility>({
|
||||
id: 'me-facility-label',
|
||||
data: ME_FACILITIES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color, 200),
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 12],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
});
|
||||
|
||||
return [iconLayer, labelLayer];
|
||||
}
|
||||
265
frontend/src/components/korea/AiChatPanel.tsx
Normal file
265
frontend/src/components/korea/AiChatPanel.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import type { Ship } from '../../types';
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
koreanShipCount: number;
|
||||
chineseShipCount: number;
|
||||
totalShipCount: number;
|
||||
}
|
||||
|
||||
// TODO: Python FastAPI 기반 해양분석 AI API로 전환 예정
|
||||
const AI_CHAT_URL = '/api/kcg/ai/chat';
|
||||
|
||||
function buildSystemPrompt(props: Props): string {
|
||||
const { ships, koreanShipCount, chineseShipCount, totalShipCount } = props;
|
||||
|
||||
// 선박 유형별 통계
|
||||
const byType: Record<string, number> = {};
|
||||
const byFlag: Record<string, number> = {};
|
||||
ships.forEach(s => {
|
||||
byType[s.category || 'unknown'] = (byType[s.category || 'unknown'] || 0) + 1;
|
||||
byFlag[s.flag || 'unknown'] = (byFlag[s.flag || 'unknown'] || 0) + 1;
|
||||
});
|
||||
|
||||
// 중국 어선 통계
|
||||
const cnFishing = ships.filter(s => s.flag === 'CN' && (s.category === 'fishing' || s.typecode === '30'));
|
||||
const cnFishingOperating = cnFishing.filter(s => s.speed >= 2 && s.speed <= 5);
|
||||
|
||||
return `당신은 대한민국 해양경찰청의 해양상황 분석 AI 어시스턴트입니다.
|
||||
현재 실시간 해양 모니터링 데이터를 기반으로 분석을 제공합니다.
|
||||
|
||||
## 현재 해양 상황 요약
|
||||
- 전체 선박: ${totalShipCount}척
|
||||
- 한국 선박: ${koreanShipCount}척
|
||||
- 중국 선박: ${chineseShipCount}척
|
||||
- 중국 어선: ${cnFishing.length}척 (조업 추정: ${cnFishingOperating.length}척)
|
||||
|
||||
## 선박 유형별 현황
|
||||
${Object.entries(byType).map(([k, v]) => `- ${k}: ${v}척`).join('\n')}
|
||||
|
||||
## 국적별 현황 (상위)
|
||||
${Object.entries(byFlag).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([k, v]) => `- ${k}: ${v}척`).join('\n')}
|
||||
|
||||
## 한중어업협정 핵심
|
||||
- 중국 허가어선 906척 (PT 저인망 323쌍, GN 유자망 200척, PS 위망 16척, OT 1척식 13척, FC 운반선 31척)
|
||||
- 특정어업수역 I~IV에서만 조업 허가
|
||||
- 휴어기: PT/OT 4/16~10/15, GN 6/2~8/31
|
||||
- 다크베셀(AIS 차단) 감시 필수
|
||||
|
||||
## 응답 규칙
|
||||
- 한국어로 답변
|
||||
- 간결하고 분석적으로
|
||||
- 데이터 기반 답변 우선
|
||||
- 불법조업 의심 시 근거 제시
|
||||
- 필요시 조치 권고 포함`;
|
||||
}
|
||||
|
||||
export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShipCount }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) inputRef.current?.focus();
|
||||
}, [isOpen]);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', content: input.trim(), timestamp: Date.now() };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const systemPrompt = buildSystemPrompt({ ships, koreanShipCount, chineseShipCount, totalShipCount });
|
||||
const apiMessages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.filter(m => m.role !== 'system').map(m => ({ role: m.role, content: m.content })),
|
||||
{ role: 'user', content: userMsg.content },
|
||||
];
|
||||
|
||||
const res = await fetch(AI_CHAT_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'qwen2.5:7b',
|
||||
messages: apiMessages,
|
||||
stream: false,
|
||||
options: { temperature: 0.3, num_predict: 1024 },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Ollama error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: data.message?.content || '응답을 생성할 수 없습니다.',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages(prev => [...prev, assistantMsg]);
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: `오류: ${err instanceof Error ? err.message : 'AI 서버 연결 실패'}. Ollama가 실행 중인지 확인하세요.`,
|
||||
timestamp: Date.now(),
|
||||
}]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [input, isLoading, messages, ships, koreanShipCount, chineseShipCount, totalShipCount]);
|
||||
|
||||
const quickQuestions = [
|
||||
'현재 해양 상황을 요약해줘',
|
||||
'중국어선 불법조업 의심 분석해줘',
|
||||
'서해 위험도를 평가해줘',
|
||||
'다크베셀 현황 분석해줘',
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
borderTop: '1px solid rgba(168,85,247,0.2)',
|
||||
marginTop: 8,
|
||||
}}>
|
||||
{/* Toggle header */}
|
||||
<div
|
||||
onClick={() => setIsOpen(p => !p)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 8px', cursor: 'pointer',
|
||||
background: isOpen ? 'rgba(168,85,247,0.12)' : 'rgba(168,85,247,0.04)',
|
||||
borderRadius: 4,
|
||||
borderLeft: '2px solid rgba(168,85,247,0.5)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>🤖</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#c4b5fd' }}>AI 해양분석</span>
|
||||
<span style={{ fontSize: 8, color: '#8b5cf6', marginLeft: 2, background: 'rgba(139,92,246,0.15)', padding: '1px 5px', borderRadius: 3 }}>Qwen 2.5</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 8, color: '#8b5cf6' }}>
|
||||
{isOpen ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chat body */}
|
||||
{isOpen && (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: 360, background: 'rgba(88,28,135,0.08)',
|
||||
borderRadius: '0 0 6px 6px', overflow: 'hidden',
|
||||
borderLeft: '2px solid rgba(168,85,247,0.3)',
|
||||
borderBottom: '1px solid rgba(168,85,247,0.15)',
|
||||
}}>
|
||||
{/* Messages */}
|
||||
<div style={{
|
||||
flex: 1, overflowY: 'auto', padding: '6px 8px',
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
}}>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ padding: '12px 0', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 10, color: '#a78bfa', marginBottom: 8 }}>
|
||||
해양 상황에 대해 질문하세요
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{quickQuestions.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => { setInput(q); }}
|
||||
style={{
|
||||
background: 'rgba(139,92,246,0.08)',
|
||||
border: '1px solid rgba(139,92,246,0.25)',
|
||||
borderRadius: 4, padding: '4px 8px',
|
||||
fontSize: 9, color: '#a78bfa',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '85%',
|
||||
background: msg.role === 'user'
|
||||
? 'rgba(139,92,246,0.25)'
|
||||
: 'rgba(168,85,247,0.08)',
|
||||
borderRadius: msg.role === 'user' ? '8px 8px 2px 8px' : '8px 8px 8px 2px',
|
||||
padding: '6px 8px',
|
||||
fontSize: 10,
|
||||
color: '#e2e8f0',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div style={{
|
||||
alignSelf: 'flex-start', padding: '6px 8px',
|
||||
background: 'rgba(168,85,247,0.08)', borderRadius: 8,
|
||||
fontSize: 10, color: '#a78bfa',
|
||||
}}>
|
||||
분석 중...
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 4, padding: '6px 8px',
|
||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||||
background: 'rgba(0,0,0,0.15)',
|
||||
}}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
|
||||
placeholder="해양 상황 질문..."
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1, background: 'rgba(139,92,246,0.06)',
|
||||
border: '1px solid rgba(139,92,246,0.2)',
|
||||
borderRadius: 4, padding: '5px 8px',
|
||||
fontSize: 10, color: '#e2e8f0', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={isLoading || !input.trim()}
|
||||
style={{
|
||||
background: isLoading || !input.trim() ? '#334155' : '#7c3aed',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 10px', fontSize: 10, fontWeight: 700,
|
||||
color: '#fff', cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
전송
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -242,14 +242,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
data: illegalFishingData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name || d.mmsi,
|
||||
getSize: 10 * zoomScale,
|
||||
getSize: 14 * zoomScale,
|
||||
getColor: [239, 68, 68, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 14],
|
||||
fontFamily: 'monospace',
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
@ -279,14 +280,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
data,
|
||||
getPosition: (d: { lng: number; lat: number }) => [d.lng, d.lat],
|
||||
getText: (d: { name: string }) => d.name,
|
||||
getSize: 12 * zoomScale,
|
||||
getSize: 14 * zoomScale,
|
||||
getColor: [255, 255, 255, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 3,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
@ -354,13 +356,14 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
data: gears,
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getText: (d: Ship) => d.name || d.mmsi,
|
||||
getSize: 9 * zoomScale,
|
||||
getSize: 13 * zoomScale,
|
||||
getColor: [249, 115, 22, 255],
|
||||
getTextAnchor: 'middle' as const,
|
||||
getAlignmentBaseline: 'top' as const,
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
outlineWidth: 2,
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 220],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
@ -388,14 +391,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
data: [parent],
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getText: (d: Ship) => `▼ ${d.name || groupName} (모선)`,
|
||||
getSize: 11 * zoomScale,
|
||||
getSize: 14 * zoomScale,
|
||||
getColor: [249, 115, 22, 255],
|
||||
getTextAnchor: 'middle' as const,
|
||||
getAlignmentBaseline: 'top' as const,
|
||||
getPixelOffset: [0, 18],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 3,
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 220],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
@ -452,14 +456,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
const prefix = role === 'LEADER' ? '★ ' : '';
|
||||
return `${prefix}${d.name || d.mmsi}`;
|
||||
},
|
||||
getSize: 9 * zoomScale,
|
||||
getSize: 13 * zoomScale,
|
||||
getColor: color,
|
||||
getTextAnchor: 'middle' as const,
|
||||
getAlignmentBaseline: 'top' as const,
|
||||
getPixelOffset: [0, 12],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 220],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
|
||||
194
frontend/src/data/meEnergyHazardFacilities.ts
Normal file
194
frontend/src/data/meEnergyHazardFacilities.ts
Normal file
@ -0,0 +1,194 @@
|
||||
// Middle East Energy & Hazard Facilities (OSINT + OpenStreetMap)
|
||||
|
||||
export type FacilitySubType =
|
||||
| 'power' | 'wind' | 'nuclear' | 'thermal' // energy
|
||||
| 'petrochem' | 'lng' | 'oil_tank' | 'haz_port'; // hazard
|
||||
|
||||
export interface EnergyHazardFacility {
|
||||
id: string;
|
||||
name: string;
|
||||
nameKo: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
country: string; // ISO-2
|
||||
countryKey: string; // overseas layer key prefix (us, il, ir, ae, sa, om, qa, kw, iq, bh)
|
||||
category: 'energy' | 'hazard';
|
||||
subType: FacilitySubType;
|
||||
capacityMW?: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const SUB_TYPE_META: Record<FacilitySubType, { label: string; color: string; icon: string }> = {
|
||||
power: { label: '발전소', color: '#a855f7', icon: '⚡' },
|
||||
wind: { label: '풍력단지', color: '#22d3ee', icon: '🌬' },
|
||||
nuclear: { label: '원자력발전소', color: '#f59e0b', icon: '☢' },
|
||||
thermal: { label: '화력발전소', color: '#64748b', icon: '🏭' },
|
||||
petrochem: { label: '석유화학단지', color: '#f97316', icon: '🛢' },
|
||||
lng: { label: 'LNG저장기지', color: '#0ea5e9', icon: '❄' },
|
||||
oil_tank: { label: '유류저장탱크', color: '#eab308', icon: '🛢' },
|
||||
haz_port: { label: '위험물항만하역시설', color: '#dc2626', icon: '⚠' },
|
||||
};
|
||||
|
||||
// layer key -> subType mapping
|
||||
export function layerKeyToSubType(key: string): FacilitySubType | null {
|
||||
if (key.endsWith('Power')) return 'power';
|
||||
if (key.endsWith('Wind')) return 'wind';
|
||||
if (key.endsWith('Nuclear')) return 'nuclear';
|
||||
if (key.endsWith('Thermal')) return 'thermal';
|
||||
if (key.endsWith('Petrochem')) return 'petrochem';
|
||||
if (key.endsWith('Lng')) return 'lng';
|
||||
if (key.endsWith('OilTank')) return 'oil_tank';
|
||||
if (key.endsWith('HazPort')) return 'haz_port';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function layerKeyToCountry(key: string): string | null {
|
||||
const m = key.match(/^(us|il|ir|ae|sa|om|qa|kw|iq|bh)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
export const ME_ENERGY_HAZARD_FACILITIES: EnergyHazardFacility[] = [
|
||||
// ════════════════════════════════════════════
|
||||
// 🇺🇸 미국 (중동 주둔 시설 + 에너지 인프라)
|
||||
// ════════════════════════════════════════════
|
||||
{ id: 'US-E01', name: 'Al Udeid Power Plant', nameKo: '알우데이드 발전소', lat: 25.1175, lng: 51.3150, country: 'US', countryKey: 'us', category: 'energy', subType: 'power', capacityMW: 200, description: '미군 알우데이드 기지 전용 발전시설' },
|
||||
{ id: 'US-H01', name: 'Bahrain NAVSUP Fuel Depot', nameKo: '바레인 미해군 유류저장소', lat: 26.2361, lng: 50.6036, country: 'US', countryKey: 'us', category: 'hazard', subType: 'oil_tank', description: 'NSA Bahrain 유류 보급 시설' },
|
||||
{ id: 'US-H02', name: 'Jebel Ali US Navy Fuel Terminal', nameKo: '제벨알리 미해군 연료터미널', lat: 25.0100, lng: 55.0600, country: 'US', countryKey: 'us', category: 'hazard', subType: 'haz_port', description: '미 제5함대 연료 보급 항만' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇮🇱 이스라엘
|
||||
// ════════════════════════════════════════════
|
||||
// Energy
|
||||
{ id: 'IL-E01', name: 'Orot Rabin Power Station', nameKo: '오롯 라빈 화력발전소', lat: 32.3915, lng: 34.8610, country: 'IL', countryKey: 'il', category: 'energy', subType: 'thermal', capacityMW: 2590, description: '이스라엘 최대 석탄/가스 복합 발전소 (하데라)' },
|
||||
{ id: 'IL-E02', name: 'Rutenberg Power Station', nameKo: '루텐베르그 화력발전소', lat: 31.6200, lng: 34.5300, country: 'IL', countryKey: 'il', category: 'energy', subType: 'thermal', capacityMW: 2250, description: '아슈켈론 석탄 화력발전소' },
|
||||
{ id: 'IL-E03', name: 'Eshkol Power Station', nameKo: '에쉬콜 발전소', lat: 31.7940, lng: 34.6350, country: 'IL', countryKey: 'il', category: 'energy', subType: 'thermal', capacityMW: 1096, description: '아슈도드 해안 천연가스 복합화력 (IEC 운영)' },
|
||||
{ id: 'IL-E04', name: 'Hagit Power Station', nameKo: '하깃 발전소', lat: 32.5600, lng: 35.0800, country: 'IL', countryKey: 'il', category: 'energy', subType: 'power', capacityMW: 600, description: '북부 가스터빈 발전소' },
|
||||
{ id: 'IL-E05', name: 'Dimona Nuclear Research Center', nameKo: '디모나 원자력연구센터', lat: 31.0014, lng: 35.1467, country: 'IL', countryKey: 'il', category: 'energy', subType: 'nuclear', description: '네게브 원자력연구시설 (IRR-2)' },
|
||||
{ id: 'IL-E06', name: 'Ashalim Solar Power Station', nameKo: '아샬림 태양광발전소', lat: 31.1300, lng: 34.6600, country: 'IL', countryKey: 'il', category: 'energy', subType: 'power', capacityMW: 310, description: '네게브 사막 CSP+PV 복합 발전' },
|
||||
// Hazard
|
||||
{ id: 'IL-H01', name: 'Haifa Bay Petrochemical Complex', nameKo: '하이파만 석유화학단지', lat: 32.8100, lng: 35.0500, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'petrochem', description: 'Oil Refineries Ltd. + Bazan Group 정유/석유화학 단지' },
|
||||
{ id: 'IL-H02', name: 'Ashdod Oil Terminal', nameKo: '아시도드 유류터미널', lat: 31.8200, lng: 34.6350, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'oil_tank', description: 'EAPC 원유 수입 터미널 + 저장탱크' },
|
||||
{ id: 'IL-H03', name: 'Ashkelon Desalination & Energy Hub', nameKo: '아슈켈론 에너지허브', lat: 31.6100, lng: 34.5400, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'haz_port', description: '해수담수화 + LNG 수입 터미널' },
|
||||
{ id: 'IL-H04', name: 'Eilat-Ashkelon Pipeline Terminal', nameKo: 'EAPC 에일라트 터미널', lat: 29.5500, lng: 34.9500, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'oil_tank', description: '홍해 원유 수입 파이프라인 터미널' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇮🇷 이란 (Wikipedia + OSINT 기반)
|
||||
// 총 설치용량 ~85,000MW, 화력 95%+, 수력 ~12,000MW
|
||||
// ════════════════════════════════════════════
|
||||
// ── 화력발전소 (Thermal) ──
|
||||
{ id: 'IR-E01', name: 'Damavand Power Plant', nameKo: '다마반드 발전소', lat: 35.5200, lng: 51.9800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 2900, description: '이란 최대 화력발전소, 테헤란 남동 50km (가스복합)' },
|
||||
{ id: 'IR-E02', name: 'Shahid Salimi (Neka) Power Plant', nameKo: '샤히드 살리미(네카) 발전소', lat: 36.6500, lng: 53.3300, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 2214, description: '마잔다란주, 이란 2위 화력 (카스피해 연안)' },
|
||||
{ id: 'IR-E03', name: 'Shahid Rajaee Combined Cycle', nameKo: '샤히드 라자이 복합화력', lat: 36.3700, lng: 49.9900, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 2042, description: '가즈빈주, 이란 3위 복합화력' },
|
||||
{ id: 'IR-E04', name: 'Ramin Steam Power Plant', nameKo: '라민 증기화력발전소', lat: 31.3100, lng: 48.7400, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1890, description: '후제스탄주 아바즈 인근 증기터빈' },
|
||||
{ id: 'IR-E05', name: 'Shahid Montazeri Power Plant', nameKo: '샤히드 몬타제리 발전소', lat: 32.6500, lng: 51.6800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1600, description: '이스파한, 1984년 가동 개시' },
|
||||
{ id: 'IR-E06', name: 'Parand Combined Cycle', nameKo: '파란드 복합화력', lat: 35.4700, lng: 51.0100, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1536, description: '테헤란 서남부 복합화력' },
|
||||
{ id: 'IR-E07', name: 'Tabriz Thermal Power Plant', nameKo: '타브리즈 화력발전소', lat: 38.0600, lng: 46.3200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1386, description: '동아제르바이잔주' },
|
||||
{ id: 'IR-E08', name: 'Bandar Abbas Power Plant', nameKo: '반다르아바스 발전소', lat: 27.2000, lng: 56.2500, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1057, description: '호르무즈해협 연안' },
|
||||
{ id: 'IR-E09', name: 'Besat Power Plant', nameKo: '베사트 발전소', lat: 35.8300, lng: 50.9800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1000, description: '테헤란 남부 가스터빈' },
|
||||
{ id: 'IR-E10', name: 'Tous Power Plant', nameKo: '투스 발전소', lat: 36.3100, lng: 59.5800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1470, description: '마슈하드 인근 복합화력' },
|
||||
{ id: 'IR-E11', name: 'Fars (Shahid Dastjerdi) Power Plant', nameKo: '파르스 발전소', lat: 29.6000, lng: 52.5000, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1028, description: '시라즈 인근 가스복합' },
|
||||
{ id: 'IR-E12', name: 'Hormozgan Power Plant', nameKo: '호르모즈간 발전소', lat: 27.1800, lng: 56.3000, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 906, description: '호르모즈간주 가스복합' },
|
||||
{ id: 'IR-E13', name: 'Shahid Mofateh Power Plant', nameKo: '샤히드 모파테 발전소', lat: 34.7700, lng: 48.5200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1000, description: '하메단주 복합화력' },
|
||||
{ id: 'IR-E14', name: 'Kerman Combined Cycle', nameKo: '케르만 복합화력', lat: 30.2600, lng: 57.0700, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 928, description: '케르만주 복합화력' },
|
||||
{ id: 'IR-E15', name: 'Yazd Combined Cycle', nameKo: '야즈드 복합화력', lat: 31.9000, lng: 54.3700, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 948, description: '야즈드주 가스복합' },
|
||||
// ── 수력발전소 (Hydro) ──
|
||||
{ id: 'IR-E16', name: 'Karun-3 (Shahid Rajaee) Dam', nameKo: '카룬-3 수력발전소', lat: 31.8055, lng: 50.0893, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 2280, description: '이란 최대 수력, 후제스탄주 이제 SE 28km, 8기' },
|
||||
{ id: 'IR-E17', name: 'Shahid Abbaspour (Karun-1) Dam', nameKo: '카룬-1 (샤히드 아바스푸르) 수력', lat: 32.0519, lng: 49.6069, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 2000, description: '후제스탄주 마스제드솔레이만 NE 50km' },
|
||||
{ id: 'IR-E18', name: 'Karun-4 Dam', nameKo: '카룬-4 수력발전소', lat: 31.5969, lng: 50.4712, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 1000, description: '카룬강 상류, 2011년 가동' },
|
||||
{ id: 'IR-E19', name: 'Dez Dam Hydropower', nameKo: '데즈댐 수력발전소', lat: 32.6053, lng: 48.4640, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 520, description: '후제스탄주 안디메쉬크 NE 20km, 8기' },
|
||||
{ id: 'IR-E20', name: 'Masjed Soleiman Dam', nameKo: '마스제드솔레이만 수력', lat: 32.0300, lng: 49.2800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 2000, description: '카룬강 하류, 대형 아치댐' },
|
||||
// ── 원자력/핵시설 (Nuclear) ──
|
||||
{ id: 'IR-E21', name: 'Bushehr Nuclear Power Plant', nameKo: '부셰르 원자력발전소', lat: 28.8267, lng: 50.8867, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', capacityMW: 915, description: '이란 유일 상업 원전 (VVER-1000), 1995 러시아 계약' },
|
||||
{ id: 'IR-E22', name: 'Natanz Enrichment Facility', nameKo: '나탄즈 우라늄농축시설', lat: 33.7250, lng: 51.7267, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: '주요 원심분리기 농축시설 (지하)' },
|
||||
{ id: 'IR-E23', name: 'Fordow Enrichment Facility', nameKo: '포르도 우라늄농축시설', lat: 34.8800, lng: 51.5800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: '지하 농축시설 (FFEP, 쿰 인근 산속)' },
|
||||
{ id: 'IR-E24', name: 'Isfahan Nuclear Technology Center', nameKo: '이스파한 핵기술센터 (UCF)', lat: 32.7200, lng: 51.7200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: '우라늄전환시설 + 연구용 원자로' },
|
||||
{ id: 'IR-E25', name: 'Arak Heavy Water Reactor (IR-40)', nameKo: '아라크 중수로', lat: 34.0400, lng: 49.2400, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: 'IR-40 중수 연구용 원자로 (마르카지주)' },
|
||||
{ id: 'IR-E26', name: 'Darkhovin Nuclear Power Plant', nameKo: '다르코빈 원자력발전소', lat: 31.3700, lng: 48.3200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', capacityMW: 360, description: '이란 자체 건설 원전 (2007 착공, 후제스탄주)' },
|
||||
// ── 풍력 (Wind) ──
|
||||
{ id: 'IR-E27', name: 'Manjil-Rudbar Wind Farm', nameKo: '만질-루드바르 풍력단지', lat: 36.7400, lng: 49.4200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'wind', capacityMW: 101, description: '길란주, 이란 최대 풍력 (2003 가동)' },
|
||||
{ id: 'IR-E28', name: 'Binaloud Wind Farm', nameKo: '비날루드 풍력단지', lat: 36.2200, lng: 58.7500, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'wind', capacityMW: 28, description: '라자비호라산주 니샤푸르 인근, 43기 x 660kW' },
|
||||
// ── 태양광 (Solar) ──
|
||||
{ id: 'IR-E29', name: 'Zarand Solar Power Plant', nameKo: '자란드 태양광발전소', lat: 30.8100, lng: 56.5600, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 10, description: '케르만주 태양광 시범단지' },
|
||||
// Hazard
|
||||
{ id: 'IR-H01', name: 'South Pars Gas Complex (Assaluyeh)', nameKo: '사우스파르스 가스단지 (아살루예)', lat: 27.4800, lng: 52.6100, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'petrochem', description: '세계 최대 가스전 육상 처리시설 (20+ 페이즈)' },
|
||||
{ id: 'IR-H02', name: 'Kharg Island Oil Terminal', nameKo: '하르그섬 원유터미널', lat: 29.2300, lng: 50.3100, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'oil_tank', description: '이란 원유 수출의 90% 처리 (저장 2,800만 배럴)' },
|
||||
{ id: 'IR-H03', name: 'Bandar Imam Khomeini Petrochemical', nameKo: '반다르 이맘호메이니 석유화학', lat: 30.4300, lng: 49.0800, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'petrochem', description: 'Mahshahr 특별경제구역 석유화학단지' },
|
||||
{ id: 'IR-H04', name: 'Tombak LNG Terminal', nameKo: '톰박 LNG터미널', lat: 27.5200, lng: 52.5500, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'lng', description: 'Iran LNG 수출 터미널 (건설중)' },
|
||||
{ id: 'IR-H05', name: 'Bandar Abbas Oil Refinery', nameKo: '반다르아바스 정유소', lat: 27.2100, lng: 56.2800, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'petrochem', description: '일 320,000배럴 정유시설' },
|
||||
{ id: 'IR-H06', name: 'Lavan Island Oil Terminal', nameKo: '라반섬 원유터미널', lat: 26.8100, lng: 53.3600, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'oil_tank', description: '페르시아만 원유 저장/선적 시설' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇦🇪 UAE
|
||||
// ════════════════════════════════════════════
|
||||
// Energy
|
||||
{ id: 'AE-E01', name: 'Barakah Nuclear Power Plant', nameKo: '바라카 원자력발전소', lat: 23.9592, lng: 52.2567, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'nuclear', capacityMW: 5600, description: '아랍 최초 상업 원전 (APR-1400 x4)' },
|
||||
{ id: 'AE-E02', name: 'Jebel Ali Power & Desalination', nameKo: '제벨알리 발전/담수', lat: 25.0200, lng: 55.1100, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'thermal', capacityMW: 8695, description: '세계 최대 복합 발전/담수 단지' },
|
||||
{ id: 'AE-E03', name: 'Shams Solar Power Station', nameKo: '샴스 태양광발전소', lat: 23.5800, lng: 53.7100, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'power', capacityMW: 100, description: '아부다비 CSP 태양열 발전' },
|
||||
{ id: 'AE-E04', name: 'Hassyan Clean Coal Power Plant', nameKo: '하시안 청정석탄발전소', lat: 24.9600, lng: 55.0300, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'thermal', capacityMW: 2400, description: '두바이 석탄→가스 전환 중' },
|
||||
// Hazard
|
||||
{ id: 'AE-H01', name: 'Ruwais Industrial Complex (ADNOC)', nameKo: '루와이스 산업단지 (ADNOC)', lat: 24.1100, lng: 52.7300, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'petrochem', description: 'ADNOC 정유/석유화학 통합단지 (세계 최대급)' },
|
||||
{ id: 'AE-H02', name: 'Das Island LNG Terminal', nameKo: '다스섬 LNG터미널', lat: 25.1600, lng: 52.8700, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'lng', description: 'ADGAS LNG 수출 터미널 (연 570만톤)' },
|
||||
{ id: 'AE-H03', name: 'Fujairah Oil Terminal (FOSC)', nameKo: '푸자이라 유류터미널', lat: 25.1200, lng: 56.3400, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'oil_tank', description: '세계 3대 벙커링 허브 (저장 1,400만m3)' },
|
||||
{ id: 'AE-H04', name: 'Jebel Ali Free Zone Port', nameKo: '제벨알리 자유무역항', lat: 25.0000, lng: 55.0700, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'haz_port', description: '중동 최대 항만 (위험물 취급)' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇸🇦 사우디아라비아
|
||||
// ════════════════════════════════════════════
|
||||
// Energy
|
||||
{ id: 'SA-E01', name: 'Shoaiba Power & Desalination', nameKo: '쇼아이바 발전/담수', lat: 20.7000, lng: 39.5100, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'thermal', capacityMW: 5600, description: '홍해 연안 세계 최대급 복합 발전/담수' },
|
||||
{ id: 'SA-E02', name: 'Rabigh Power Plant', nameKo: '라비그 발전소', lat: 22.8000, lng: 39.0200, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'thermal', capacityMW: 2100, description: '홍해 연안 가스복합 발전소' },
|
||||
{ id: 'SA-E03', name: 'Dumat Al Jandal Wind Farm', nameKo: '두마트알잔달 풍력단지', lat: 29.8100, lng: 39.8700, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'wind', capacityMW: 400, description: '중동 최대 풍력단지' },
|
||||
{ id: 'SA-E04', name: 'Jubail IWPP', nameKo: '주바일 발전소', lat: 27.0200, lng: 49.6200, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'thermal', capacityMW: 2745, description: '동부 산업도시 복합 발전' },
|
||||
// Hazard
|
||||
{ id: 'SA-H01', name: 'Ras Tanura Oil Terminal', nameKo: '라스타누라 원유터미널', lat: 26.6400, lng: 50.1600, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'oil_tank', description: '세계 최대 해상 원유 선적 시설 (일 600만 배럴)' },
|
||||
{ id: 'SA-H02', name: 'Jubail Industrial City (SABIC)', nameKo: '주바일 산업단지 (SABIC)', lat: 27.0000, lng: 49.6500, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'petrochem', description: '세계 최대 석유화학 산업단지' },
|
||||
{ id: 'SA-H03', name: 'Yanbu Industrial City', nameKo: '얀부 산업단지', lat: 23.9600, lng: 38.2400, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'petrochem', description: '홍해 연안 정유/석유화학 단지' },
|
||||
{ id: 'SA-H04', name: 'Ras Al-Khair LNG Import', nameKo: '라스알카이르 LNG', lat: 27.4800, lng: 49.2600, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'lng', description: 'LNG 수입/가스화 터미널' },
|
||||
{ id: 'SA-H05', name: 'Abqaiq Oil Processing', nameKo: '아브카이크 원유처리시설', lat: 25.9400, lng: 49.6800, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'oil_tank', description: '세계 최대 원유 안정화 시설 (2019 공격 대상)' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇴🇲 오만
|
||||
// ════════════════════════════════════════════
|
||||
{ id: 'OM-E01', name: 'Barka Power & Desalination', nameKo: '바르카 발전/담수', lat: 23.6800, lng: 57.8700, country: 'OM', countryKey: 'om', category: 'energy', subType: 'thermal', capacityMW: 2007, description: 'GDF Suez 운영 복합발전' },
|
||||
{ id: 'OM-E02', name: 'Dhofar Wind Farm', nameKo: '도파르 풍력단지', lat: 17.0200, lng: 54.1000, country: 'OM', countryKey: 'om', category: 'energy', subType: 'wind', capacityMW: 50, description: 'GCC 최초 대형 풍력단지' },
|
||||
{ id: 'OM-H01', name: 'Sohar Industrial Port', nameKo: '소하르 산업항', lat: 24.3600, lng: 56.7400, country: 'OM', countryKey: 'om', category: 'hazard', subType: 'petrochem', description: '정유소+석유화학+알루미늄 제련단지' },
|
||||
{ id: 'OM-H02', name: 'Qalhat LNG Terminal', nameKo: '칼하트 LNG터미널', lat: 22.9200, lng: 59.3700, country: 'OM', countryKey: 'om', category: 'hazard', subType: 'lng', description: 'Oman LNG 수출 (연 1,060만톤)' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇶🇦 카타르
|
||||
// ════════════════════════════════════════════
|
||||
{ id: 'QA-E01', name: 'Ras Laffan Power Plant', nameKo: '라스라판 발전소', lat: 25.9100, lng: 51.5500, country: 'QA', countryKey: 'qa', category: 'energy', subType: 'thermal', capacityMW: 2730, description: '카타르 최대 발전소' },
|
||||
{ id: 'QA-H01', name: 'Ras Laffan Industrial City', nameKo: '라스라판 산업단지', lat: 25.9200, lng: 51.5300, country: 'QA', countryKey: 'qa', category: 'hazard', subType: 'lng', description: '세계 최대 LNG 수출기지 (QatarEnergy, 연 7,700만톤)' },
|
||||
{ id: 'QA-H02', name: 'Mesaieed Industrial City', nameKo: '메사이드 산업단지', lat: 24.9900, lng: 51.5600, country: 'QA', countryKey: 'qa', category: 'hazard', subType: 'petrochem', description: 'QatarEnergy 정유/석유화학/비료 단지' },
|
||||
{ id: 'QA-H03', name: 'Dukhan Oil Field Terminal', nameKo: '두칸 유전터미널', lat: 25.4300, lng: 50.7700, country: 'QA', countryKey: 'qa', category: 'hazard', subType: 'oil_tank', description: '서부 해안 육상 유전 터미널' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇰🇼 쿠웨이트
|
||||
// ════════════════════════════════════════════
|
||||
{ id: 'KW-E01', name: 'Az-Zour Power Plant', nameKo: '아즈주르 발전소', lat: 28.7200, lng: 48.3800, country: 'KW', countryKey: 'kw', category: 'energy', subType: 'thermal', capacityMW: 4800, description: '쿠웨이트 최대 발전/담수' },
|
||||
{ id: 'KW-H01', name: 'Mina Al Ahmadi Refinery', nameKo: '미나알아흐마디 정유소', lat: 29.0600, lng: 48.1500, country: 'KW', countryKey: 'kw', category: 'hazard', subType: 'petrochem', description: 'KNPC 운영 (일 466,000배럴)' },
|
||||
{ id: 'KW-H02', name: 'Az-Zour LNG Import Terminal', nameKo: '아즈주르 LNG터미널', lat: 28.7100, lng: 48.3500, country: 'KW', countryKey: 'kw', category: 'hazard', subType: 'lng', description: '쿠웨이트 LNG 수입 터미널' },
|
||||
{ id: 'KW-H03', name: 'Mina Abdullah Oil Tank Farm', nameKo: '미나압둘라 유류저장기지', lat: 29.0000, lng: 48.1700, country: 'KW', countryKey: 'kw', category: 'hazard', subType: 'oil_tank', description: '남부 원유 저장/선적' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇮🇶 이라크
|
||||
// ════════════════════════════════════════════
|
||||
{ id: 'IQ-E01', name: 'Basra Gas Power Plant', nameKo: '바스라 가스발전소', lat: 30.5100, lng: 47.7800, country: 'IQ', countryKey: 'iq', category: 'energy', subType: 'thermal', capacityMW: 1500, description: '남부 이라크 최대 발전소' },
|
||||
{ id: 'IQ-H01', name: 'Basra Oil Terminal (ABOT)', nameKo: '알바스라 원유터미널', lat: 29.6800, lng: 48.8000, country: 'IQ', countryKey: 'iq', category: 'hazard', subType: 'oil_tank', description: '이라크 원유 수출의 85% (페르시아만)' },
|
||||
{ id: 'IQ-H02', name: 'Khor Al-Zubair Port', nameKo: '코르알주바이르 항', lat: 30.1700, lng: 47.8700, country: 'IQ', countryKey: 'iq', category: 'hazard', subType: 'haz_port', description: '이라크 주요 위험물 하역항' },
|
||||
{ id: 'IQ-H03', name: 'Rumaila Oil Field', nameKo: '루마일라 유전', lat: 30.6300, lng: 47.4300, country: 'IQ', countryKey: 'iq', category: 'hazard', subType: 'oil_tank', description: '이라크 최대 유전 (일 150만 배럴)' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇧🇭 바레인
|
||||
// ════════════════════════════════════════════
|
||||
{ id: 'BH-E01', name: 'Al Dur Power & Water Plant', nameKo: '알두르 발전/담수', lat: 25.9400, lng: 50.6200, country: 'BH', countryKey: 'bh', category: 'energy', subType: 'thermal', capacityMW: 1234, description: '바레인 최대 발전소' },
|
||||
{ id: 'BH-H01', name: 'Sitra Oil Refinery (BAPCO)', nameKo: '시트라 정유소 (BAPCO)', lat: 26.1500, lng: 50.6100, country: 'BH', countryKey: 'bh', category: 'hazard', subType: 'petrochem', description: '바레인 유일 정유시설 (일 267,000배럴)' },
|
||||
{ id: 'BH-H02', name: 'Khalifa Bin Salman Port', nameKo: '칼리파빈살만항', lat: 26.0200, lng: 50.5500, country: 'BH', countryKey: 'bh', category: 'hazard', subType: 'haz_port', description: '바레인 주요 무역항 (위험물 하역)' },
|
||||
];
|
||||
|
||||
// Helper: filter by country key and subType
|
||||
export function filterFacilities(countryKey: string, subType?: FacilitySubType): EnergyHazardFacility[] {
|
||||
return ME_ENERGY_HAZARD_FACILITIES.filter(f =>
|
||||
f.countryKey === countryKey && (subType ? f.subType === subType : true)
|
||||
);
|
||||
}
|
||||
@ -1429,6 +1429,48 @@ export const sampleEvents: GeoEvent[] = [
|
||||
label: 'UN 안보리 — 호르무즈 해협 긴급회의 소집',
|
||||
description: 'UN 안보리, 호르무즈 해협 상선 피격 관련 긴급회의 소집. 중국·러시아 즉각 휴전 촉구, 미국 항행자유 강조.',
|
||||
},
|
||||
|
||||
// ═══ D+20 (2026-03-21) 나탄즈-디모나 핵시설 교차공격 ═══
|
||||
{
|
||||
id: 'd20-il1', timestamp: T0 + 20 * DAY + 4 * HOUR,
|
||||
lat: 33.7250, lng: 51.7267, type: 'strike',
|
||||
label: '나탄즈 — 이스라엘 핵시설 공습',
|
||||
description: 'IAF, 이란 나탄즈 우라늄 농축시설 정밀 타격. 이란 원자력청 "나탄즈 농축시설이 공격 표적이 됐다" 확인. IAEA 방사능 유출 미확인.',
|
||||
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Natanz_nuclear.jpg/320px-Natanz_nuclear.jpg',
|
||||
imageCaption: '나탄즈 핵시설 위성사진 (Wikimedia Commons)',
|
||||
},
|
||||
{
|
||||
id: 'd20-ir-assess', timestamp: T0 + 20 * DAY + 6 * HOUR,
|
||||
lat: 33.7250, lng: 51.7267, type: 'alert',
|
||||
label: '나탄즈 — 이란 방사능 조사 착수',
|
||||
description: '이란 원자력안전센터, 나탄즈 시설 인근 방사성 오염물질 배출 가능성 정밀 기술 조사. "현재까지 방사성 물질 누출 보고 없음, 인근 주민 위협 없음" 발표.',
|
||||
},
|
||||
{
|
||||
id: 'd20-ir1', timestamp: T0 + 20 * DAY + 10 * HOUR,
|
||||
lat: 31.0014, lng: 35.1467, type: 'strike',
|
||||
label: '디모나 — 이란 보복 미사일 공격',
|
||||
description: 'IRGC, 나탄즈 피격 보복으로 이스라엘 디모나 핵연구센터 겨냥 탄도미사일 발사. 이스라엘 방공 요격 실패, 최소 30명 이상 사상자 발생.',
|
||||
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Negev_Nuclear_Research_Center.jpg/320px-Negev_Nuclear_Research_Center.jpg',
|
||||
imageCaption: '디모나 네게브 핵연구센터 (Wikimedia Commons)',
|
||||
},
|
||||
{
|
||||
id: 'd20-il-def', timestamp: T0 + 20 * DAY + 10.5 * HOUR,
|
||||
lat: 31.0014, lng: 35.1467, type: 'alert',
|
||||
label: '디모나 — 요격 실패 조사 착수',
|
||||
description: '이스라엘군, 이란발 탄도미사일 요격 실패 경위 조사 착수. 미사일이 마을에 충돌, 막대한 재산 피해.',
|
||||
},
|
||||
{
|
||||
id: 'd20-iaea', timestamp: T0 + 20 * DAY + 12 * HOUR,
|
||||
lat: 48.2082, lng: 16.3738, type: 'alert',
|
||||
label: 'IAEA — 양측 핵시설 상황 파악 중',
|
||||
description: 'IAEA, 나탄즈 및 디모나 핵시설 상황 파악 중. 그로시 사무총장 "핵사고 위험 회피 위해 군사행동 자제 거듭 촉구".',
|
||||
},
|
||||
{
|
||||
id: 'd20-p1', timestamp: T0 + 20 * DAY + 14 * HOUR,
|
||||
lat: 38.8977, lng: -77.0365, type: 'alert',
|
||||
label: '워싱턴 — 미국 핵시설 공격 우려 성명',
|
||||
description: '미 국무부, 이란의 디모나 공격에 강력 규탄. "핵시설 겨냥 군사행동은 국제법 중대 위반" 경고.',
|
||||
},
|
||||
];
|
||||
|
||||
// 24시간 동안 10분 간격 센서 데이터 생성
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -126,7 +126,7 @@ export function createFacilityLayers(
|
||||
data: plants,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
||||
getSize: 8 * sc,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
@ -134,8 +134,9 @@ export function createFacilityLayers(
|
||||
getPixelOffset: [0, 8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
@ -186,7 +187,7 @@ export function createFacilityLayers(
|
||||
data: hazardData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
|
||||
getSize: 9 * sc,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200],
|
||||
getTextAnchor: 'middle',
|
||||
@ -194,8 +195,9 @@ export function createFacilityLayers(
|
||||
getPixelOffset: [0, 12],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
@ -244,7 +246,7 @@ export function createFacilityLayers(
|
||||
data: cnData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name,
|
||||
getSize: 9 * sc,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200],
|
||||
getTextAnchor: 'middle',
|
||||
@ -252,8 +254,9 @@ export function createFacilityLayers(
|
||||
getPixelOffset: [0, 12],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
@ -301,7 +304,7 @@ export function createFacilityLayers(
|
||||
data: jpData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name,
|
||||
getSize: 9 * sc,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200],
|
||||
getTextAnchor: 'middle',
|
||||
@ -309,8 +312,9 @@ export function createFacilityLayers(
|
||||
getPixelOffset: [0, 12],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
|
||||
@ -35,10 +35,253 @@ function missileImpactSvg(color: string): string {
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── MilitaryBase SVG ─────────────────────────────────────────────────────────
|
||||
|
||||
function navalBaseSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="7" y1="9" x2="17" y2="9" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M7 9 Q6 13 8 15" fill="none" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M17 9 Q18 13 16 15" fill="none" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M8 15 Q12 18 16 15" fill="none" stroke="${color}" stroke-width="1.2"/>
|
||||
<circle cx="12" cy="5" r="1.5" fill="${color}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function airforceBaseSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
|
||||
fill="${color}" stroke="#fff" stroke-width="0.3"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function armyBaseSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M6 10 Q6 8 8 8 L12 7 L16 8 Q18 8 18 10 L18 12 Q18 15 12 17 Q6 15 6 12 Z" fill="${color}" opacity="0.75"/>
|
||||
<line x1="9" y1="10" x2="15" y2="10" stroke="#fff" stroke-width="0.8" opacity="0.5"/>
|
||||
<line x1="8.5" y1="12" x2="15.5" y2="12" stroke="#fff" stroke-width="0.8" opacity="0.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function missileBaseSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 L14 10 L14 17 L12 19 L10 17 L10 10 Z" fill="${color}" opacity="0.85"/>
|
||||
<path d="M10 13 L7 16 L10 15 Z" fill="${color}" opacity="0.7"/>
|
||||
<path d="M14 13 L17 16 L14 15 Z" fill="${color}" opacity="0.7"/>
|
||||
<path d="M11 10 L13 10 L13 8 Q12 6 11 8 Z" fill="#fff" opacity="0.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function jointBaseSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<polygon points="12,5 13.8,10.1 19.2,10.1 14.7,13.2 16.5,18.3 12,15.2 7.5,18.3 9.3,13.2 4.8,10.1 10.2,10.1" fill="${color}" opacity="0.85"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── GovBuilding SVG ──────────────────────────────────────────────────────────
|
||||
|
||||
function governmentBuildingSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="5" y1="18" x2="19" y2="18" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="5" x2="5" y2="9" stroke="${color}" stroke-width="1"/>
|
||||
<line x1="12" y1="5" x2="19" y2="9" stroke="${color}" stroke-width="1"/>
|
||||
<line x1="5" y1="9" x2="19" y2="9" stroke="${color}" stroke-width="1"/>
|
||||
<rect x="7" y="10" width="2" height="8" fill="${color}" opacity="0.7"/>
|
||||
<rect x="11" y="10" width="2" height="8" fill="${color}" opacity="0.7"/>
|
||||
<rect x="15" y="10" width="2" height="8" fill="${color}" opacity="0.7"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function militaryHqSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<polygon points="12,5 13.8,10.1 19.2,10.1 14.7,13.2 16.5,18.3 12,15.2 7.5,18.3 9.3,13.2 4.8,10.1 10.2,10.1" fill="${color}" opacity="0.85"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function intelligenceSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<ellipse cx="11" cy="11" rx="5" ry="5" fill="none" stroke="${color}" stroke-width="1.5"/>
|
||||
<circle cx="11" cy="11" r="2" fill="${color}" opacity="0.7"/>
|
||||
<line x1="15" y1="15" x2="18" y2="18" stroke="${color}" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function foreignSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<circle cx="12" cy="12" r="6" fill="none" stroke="${color}" stroke-width="1.2"/>
|
||||
<ellipse cx="12" cy="12" rx="3" ry="6" fill="none" stroke="${color}" stroke-width="0.8"/>
|
||||
<line x1="6" y1="12" x2="18" y2="12" stroke="${color}" stroke-width="0.8"/>
|
||||
<line x1="7" y1="8.5" x2="17" y2="8.5" stroke="${color}" stroke-width="0.6" opacity="0.6"/>
|
||||
<line x1="7" y1="15.5" x2="17" y2="15.5" stroke="${color}" stroke-width="0.6" opacity="0.6"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function maritimeSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="7" y1="9" x2="17" y2="9" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M7 9 Q6 13 8 15" fill="none" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M17 9 Q18 13 16 15" fill="none" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M8 15 Q12 18 16 15" fill="none" stroke="${color}" stroke-width="1.2"/>
|
||||
<circle cx="12" cy="5" r="1.5" fill="${color}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function defenseSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M6 10 Q6 8 8 8 L12 7 L16 8 Q18 8 18 10 L18 12 Q18 15 12 17 Q6 15 6 12 Z" fill="${color}" opacity="0.75"/>
|
||||
<line x1="9" y1="10" x2="15" y2="10" stroke="#fff" stroke-width="0.8" opacity="0.5"/>
|
||||
<line x1="8.5" y1="12" x2="15.5" y2="12" stroke="#fff" stroke-width="0.8" opacity="0.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── NK Launch Site SVG ───────────────────────────────────────────────────────
|
||||
|
||||
function icbmSiteSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 4 L14.5 9 L14.5 18 L12 20 L9.5 18 L9.5 9 Z" fill="${color}" opacity="0.9"/>
|
||||
<path d="M9.5 12 L6 15 L9.5 14 Z" fill="${color}" opacity="0.7"/>
|
||||
<path d="M14.5 12 L18 15 L14.5 14 Z" fill="${color}" opacity="0.7"/>
|
||||
<path d="M10.5 9 L13.5 9 L13.5 7 Q12 5 10.5 7 Z" fill="#fff" opacity="0.4"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function srbmSiteSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 L14 10 L14 17 L12 19 L10 17 L10 10 Z" fill="${color}" opacity="0.85"/>
|
||||
<path d="M10 13 L7 16 L10 15 Z" fill="${color}" opacity="0.7"/>
|
||||
<path d="M14 13 L17 16 L14 15 Z" fill="${color}" opacity="0.7"/>
|
||||
<path d="M11 10 L13 10 L13 8 Q12 6 11 8 Z" fill="#fff" opacity="0.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function slbmSiteSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M2 16 Q12 20 22 16 L22 12 Q12 8 2 12 Z" fill="${color}" opacity="0.5"/>
|
||||
<path d="M12 5 L13.5 9 L13.5 15 L12 17 L10.5 15 L10.5 9 Z" fill="${color}" opacity="0.9"/>
|
||||
<path d="M10.5 12 L8 14 L10.5 13 Z" fill="${color}" opacity="0.7"/>
|
||||
<path d="M13.5 12 L16 14 L13.5 13 Z" fill="${color}" opacity="0.7"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function cruiseSiteSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M5 13 Q8 11 12 11 Q16 11 19 13 L18 15 Q15 16 12 16 Q9 16 6 15 Z" fill="${color}" opacity="0.8"/>
|
||||
<path d="M14 11 L14 8 L17 11" fill="${color}" opacity="0.6"/>
|
||||
<path d="M12 11 L12 9 L14 11" fill="${color}" opacity="0.5"/>
|
||||
<line x1="18" y1="13" x2="20" y2="12" stroke="${color}" stroke-width="1.2"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function artillerySiteSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="5" y1="16" x2="17" y2="12" stroke="${color}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="7" cy="17" r="2" fill="${color}" opacity="0.8"/>
|
||||
<circle cx="11" cy="17" r="2" fill="${color}" opacity="0.8"/>
|
||||
<line x1="17" y1="12" x2="20" y2="11" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Module-level icon caches ─────────────────────────────────────────────────
|
||||
|
||||
const launchIconCache = new Map<string, string>();
|
||||
const impactIconCache = new Map<string, string>();
|
||||
const milBaseIconCache = new Map<string, string>();
|
||||
const govBuildingIconCache = new Map<string, string>();
|
||||
const nkLaunchIconCache = new Map<string, string>();
|
||||
|
||||
// ─── MilitaryBase icon helpers ────────────────────────────────────────────────
|
||||
|
||||
const MIL_BASE_TYPE_COLOR: Record<string, string> = {
|
||||
naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e',
|
||||
missile: '#ef4444', joint: '#a78bfa',
|
||||
};
|
||||
|
||||
type MilBaseSvgFn = (color: string, size: number) => string;
|
||||
|
||||
const MIL_BASE_SVG_FN: Record<string, MilBaseSvgFn> = {
|
||||
naval: navalBaseSvg,
|
||||
airforce: airforceBaseSvg,
|
||||
army: armyBaseSvg,
|
||||
missile: missileBaseSvg,
|
||||
joint: jointBaseSvg,
|
||||
};
|
||||
|
||||
function getMilBaseIconUrl(type: string): string {
|
||||
if (!milBaseIconCache.has(type)) {
|
||||
const color = MIL_BASE_TYPE_COLOR[type] ?? '#a78bfa';
|
||||
const fn = MIL_BASE_SVG_FN[type] ?? jointBaseSvg;
|
||||
milBaseIconCache.set(type, svgToDataUri(fn(color, 64)));
|
||||
}
|
||||
return milBaseIconCache.get(type)!;
|
||||
}
|
||||
|
||||
// ─── GovBuilding icon helpers ─────────────────────────────────────────────────
|
||||
|
||||
const GOV_TYPE_COLOR: Record<string, string> = {
|
||||
executive: '#f59e0b', legislature: '#a78bfa', military_hq: '#ef4444',
|
||||
intelligence: '#6366f1', foreign: '#3b82f6', maritime: '#06b6d4', defense: '#dc2626',
|
||||
};
|
||||
|
||||
type GovSvgFn = (color: string, size: number) => string;
|
||||
|
||||
const GOV_SVG_FN: Record<string, GovSvgFn> = {
|
||||
executive: governmentBuildingSvg,
|
||||
legislature: governmentBuildingSvg,
|
||||
military_hq: militaryHqSvg,
|
||||
intelligence: intelligenceSvg,
|
||||
foreign: foreignSvg,
|
||||
maritime: maritimeSvg,
|
||||
defense: defenseSvg,
|
||||
};
|
||||
|
||||
function getGovBuildingIconUrl(type: string): string {
|
||||
if (!govBuildingIconCache.has(type)) {
|
||||
const color = GOV_TYPE_COLOR[type] ?? '#f59e0b';
|
||||
const fn = GOV_SVG_FN[type] ?? governmentBuildingSvg;
|
||||
govBuildingIconCache.set(type, svgToDataUri(fn(color, 64)));
|
||||
}
|
||||
return govBuildingIconCache.get(type)!;
|
||||
}
|
||||
|
||||
// ─── NKLaunchSite icon helpers ────────────────────────────────────────────────
|
||||
|
||||
type NkLaunchSvgFn = (color: string, size: number) => string;
|
||||
|
||||
const NK_LAUNCH_SVG_FN: Record<string, NkLaunchSvgFn> = {
|
||||
icbm: icbmSiteSvg,
|
||||
irbm: icbmSiteSvg,
|
||||
srbm: srbmSiteSvg,
|
||||
slbm: slbmSiteSvg,
|
||||
cruise: cruiseSiteSvg,
|
||||
artillery: artillerySiteSvg,
|
||||
mlrs: artillerySiteSvg,
|
||||
};
|
||||
|
||||
function getNkLaunchIconUrl(type: string): string {
|
||||
if (!nkLaunchIconCache.has(type)) {
|
||||
const color = NK_LAUNCH_TYPE_META[type]?.color ?? '#f97316';
|
||||
const fn = NK_LAUNCH_SVG_FN[type] ?? srbmSiteSvg;
|
||||
nkLaunchIconCache.set(type, svgToDataUri(fn(color, 64)));
|
||||
}
|
||||
return nkLaunchIconCache.get(type)!;
|
||||
}
|
||||
|
||||
export function createMilitaryLayers(
|
||||
config: { militaryBases: boolean; govBuildings: boolean; nkLaunch: boolean; nkMissile: boolean },
|
||||
@ -48,28 +291,16 @@ export function createMilitaryLayers(
|
||||
const sc = fc.sc;
|
||||
const onPick = fc.onPick;
|
||||
|
||||
// ── Military Bases — TextLayer (이모지) ───────────────────────────────
|
||||
// ── Military Bases — IconLayer ────────────────────────────────────────
|
||||
if (config.militaryBases) {
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e',
|
||||
missile: '#ef4444', joint: '#a78bfa',
|
||||
};
|
||||
const TYPE_ICON: Record<string, string> = {
|
||||
naval: '⚓', airforce: '✈', army: '🪖', missile: '🚀', joint: '⭐',
|
||||
};
|
||||
layers.push(
|
||||
new TextLayer<MilitaryBase>({
|
||||
id: 'static-militarybase-emoji',
|
||||
new IconLayer<MilitaryBase>({
|
||||
id: 'static-militarybase-icon',
|
||||
data: MILITARY_BASES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => TYPE_ICON[d.type] ?? '⭐',
|
||||
getSize: 14 * sc,
|
||||
getIcon: (d) => ({ url: getMilBaseIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }),
|
||||
getSize: 16 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: [255, 255, 255, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<MilitaryBase>) => {
|
||||
if (info.object) onPick({ kind: 'militaryBase', object: info.object });
|
||||
@ -81,45 +312,33 @@ export function createMilitaryLayers(
|
||||
data: MILITARY_BASES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo),
|
||||
getSize: 8 * sc,
|
||||
getSize: 11 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number],
|
||||
getColor: (d) => [...hexToRgb(MIL_BASE_TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 9],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Gov Buildings — TextLayer (이모지) ─────────────────────────────────
|
||||
// ── Gov Buildings — IconLayer ─────────────────────────────────────────
|
||||
if (config.govBuildings) {
|
||||
const GOV_TYPE_COLOR: Record<string, string> = {
|
||||
executive: '#f59e0b', legislature: '#a78bfa', military_hq: '#ef4444',
|
||||
intelligence: '#6366f1', foreign: '#3b82f6', maritime: '#06b6d4', defense: '#dc2626',
|
||||
};
|
||||
const GOV_TYPE_ICON: Record<string, string> = {
|
||||
executive: '🏛', legislature: '🏛', military_hq: '⭐',
|
||||
intelligence: '🔍', foreign: '🌐', maritime: '⚓', defense: '🛡',
|
||||
};
|
||||
layers.push(
|
||||
new TextLayer<GovBuilding>({
|
||||
id: 'static-govbuilding-emoji',
|
||||
new IconLayer<GovBuilding>({
|
||||
id: 'static-govbuilding-icon',
|
||||
data: GOV_BUILDINGS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛',
|
||||
getIcon: (d) => ({ url: getGovBuildingIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }),
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: [255, 255, 255, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<GovBuilding>) => {
|
||||
if (info.object) onPick({ kind: 'govBuilding', object: info.object });
|
||||
@ -131,7 +350,7 @@ export function createMilitaryLayers(
|
||||
data: GOV_BUILDINGS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
||||
getSize: 8 * sc,
|
||||
getSize: 11 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
@ -139,29 +358,25 @@ export function createMilitaryLayers(
|
||||
getPixelOffset: [0, 8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── NK Launch Sites — TextLayer (이모지) ──────────────────────────────
|
||||
// ── NK Launch Sites — IconLayer ───────────────────────────────────────
|
||||
if (config.nkLaunch) {
|
||||
layers.push(
|
||||
new TextLayer<NKLaunchSite>({
|
||||
id: 'static-nklaunch-emoji',
|
||||
new IconLayer<NKLaunchSite>({
|
||||
id: 'static-nklaunch-icon',
|
||||
data: NK_LAUNCH_SITES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => NK_LAUNCH_TYPE_META[d.type]?.icon ?? '🚀',
|
||||
getSize: (d) => (d.type === 'artillery' || d.type === 'mlrs' ? 10 : 13) * sc,
|
||||
getIcon: (d) => ({ url: getNkLaunchIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }),
|
||||
getSize: (d) => (d.type === 'artillery' || d.type === 'mlrs' ? 12 : 15) * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: [255, 255, 255, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<NKLaunchSite>) => {
|
||||
if (info.object) onPick({ kind: 'nkLaunch', object: info.object });
|
||||
@ -173,7 +388,7 @@ export function createMilitaryLayers(
|
||||
data: NK_LAUNCH_SITES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
||||
getSize: 8 * sc,
|
||||
getSize: 11 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
@ -181,8 +396,9 @@ export function createMilitaryLayers(
|
||||
getPixelOffset: [0, 8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
@ -264,7 +480,7 @@ export function createMilitaryLayers(
|
||||
data: impactData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`,
|
||||
getSize: 8 * sc,
|
||||
getSize: 11 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
@ -272,8 +488,9 @@ export function createMilitaryLayers(
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
|
||||
@ -174,7 +174,7 @@ export function createNavigationLayers(
|
||||
if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8);
|
||||
return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청';
|
||||
},
|
||||
getSize: 8 * sc,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
@ -182,8 +182,9 @@ export function createNavigationLayers(
|
||||
getPixelOffset: [0, 8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
@ -224,7 +225,7 @@ export function createNavigationLayers(
|
||||
data: KOREAN_AIRPORTS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''),
|
||||
getSize: 9 * sc,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
@ -232,8 +233,9 @@ export function createNavigationLayers(
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
@ -273,7 +275,7 @@ export function createNavigationLayers(
|
||||
data: NAV_WARNINGS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.id,
|
||||
getSize: 8 * sc,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
@ -281,8 +283,9 @@ export function createNavigationLayers(
|
||||
getPixelOffset: [0, 9],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
@ -323,7 +326,7 @@ export function createNavigationLayers(
|
||||
data: PIRACY_ZONES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo,
|
||||
getSize: 9 * sc,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
@ -331,8 +334,9 @@ export function createNavigationLayers(
|
||||
getPixelOffset: [0, 14],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
|
||||
@ -95,7 +95,7 @@ export function createPortLayers(
|
||||
data: EAST_ASIA_PORTS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.replace('항', ''),
|
||||
getSize: 9 * sc,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
@ -103,8 +103,9 @@ export function createPortLayers(
|
||||
getPixelOffset: [0, 8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
@ -132,7 +133,7 @@ export function createPortLayers(
|
||||
data: KOREA_WIND_FARMS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
||||
getSize: 9 * sc,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
@ -140,8 +141,9 @@ export function createPortLayers(
|
||||
getPixelOffset: [0, 10],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
|
||||
@ -123,15 +123,16 @@ export function useAnalysisDeckLayers(
|
||||
const name = d.ship.name || d.ship.mmsi;
|
||||
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
|
||||
},
|
||||
getSize: 10 * sizeScale,
|
||||
getSize: 13 * sizeScale,
|
||||
updateTriggers: { getSize: [sizeScale] },
|
||||
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 16],
|
||||
fontFamily: 'monospace',
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
@ -166,15 +167,16 @@ export function useAnalysisDeckLayers(
|
||||
const gap = d.dto.algorithms.darkVessel.gapDurationMin;
|
||||
return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK';
|
||||
},
|
||||
getSize: 10 * sizeScale,
|
||||
getSize: 13 * sizeScale,
|
||||
updateTriggers: { getSize: [sizeScale] },
|
||||
getColor: [168, 85, 247, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: [0, 14],
|
||||
fontFamily: 'monospace',
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
@ -189,14 +191,15 @@ export function useAnalysisDeckLayers(
|
||||
data: spoofData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`,
|
||||
getSize: 10 * sizeScale,
|
||||
getSize: 13 * sizeScale,
|
||||
getColor: [239, 68, 68, 255],
|
||||
getTextAnchor: 'start',
|
||||
getPixelOffset: [12, -8],
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
outlineWidth: 2,
|
||||
outlineColor: [0, 0, 0, 200],
|
||||
fontSettings: { sdf: true },
|
||||
outlineWidth: 8,
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
|
||||
@ -147,7 +147,7 @@ export interface LayerVisibility {
|
||||
meFacilities: boolean;
|
||||
militaryOnly: boolean;
|
||||
overseasUS: boolean;
|
||||
overseasUK: boolean;
|
||||
overseasIsrael: boolean;
|
||||
overseasIran: boolean;
|
||||
overseasUAE: boolean;
|
||||
overseasSaudi: boolean;
|
||||
|
||||
@ -110,6 +110,11 @@ export default defineConfig(({ mode }): UserConfig => ({
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/ollama': {
|
||||
target: 'http://localhost:11434',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/ollama/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user