이란 시설 deck.gl 전환:
- OilFacilityLayer/AirportLayer/MEFacilityLayer/MEEnergyHazardLayer → IconLayer(SVG) + TextLayer
- 26개 고유 SVG 아이콘 (배경 원형 + 색상 테두리 + 고유 실루엣)
- ReplayMap/SatelliteMap: DeckGLOverlay + 줌 스케일 + 통합 팝업
한국 군사/정부/NK 아이콘 SVG 업그레이드:
- createMilitaryLayers: 군사기지 5종 + 정부기관 7종 + NK 발사장 7종 → SVG IconLayer
라벨 가독성 개선 (이란/한국 공통):
- TextLayer fontSettings: { sdf: true } + outlineWidth: 8 → 사막/위성 배경 위 선명 테두리
- 라벨 폰트 크기 ~1.2배 상향
이란 데이터 이관:
- meEnergyHazardFacilities.ts: 중동 에너지/위험시설 84개
- sampleData: 나탄즈-디모나 핵시설 교차공격 이벤트 (D+20)
- fishing-zones 좌표 보정, types overseasUK→overseasIsrael, vite.config /ollama 프록시
기타:
- AiChatPanel: AI 해양분석 챗 UI (API URL placeholder)
- EventLog에 AiChatPanel 연동 (한국 탭)
- IranDashboard LayerPanel 카운트 전수 보정 + OverseasTreeNode 3단계 트리
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
155 lines
6.4 KiB
TypeScript
155 lines
6.4 KiB
TypeScript
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
|
import type { PickingInfo, Layer } from '@deck.gl/core';
|
|
import { svgToDataUri } from '../../utils/svgToDataUri';
|
|
import { EAST_ASIA_PORTS } from '../../data/ports';
|
|
import type { Port } from '../../data/ports';
|
|
import { KOREA_WIND_FARMS } from '../../data/windFarms';
|
|
import type { WindFarm } from '../../data/windFarms';
|
|
import { hexToRgb } from './types';
|
|
import type { LayerFactoryConfig } from './types';
|
|
|
|
// ─── Port colors ──────────────────────────────────────────────────────────────
|
|
|
|
const PORT_COUNTRY_COLOR: Record<string, string> = {
|
|
KR: '#3b82f6',
|
|
CN: '#ef4444',
|
|
JP: '#f472b6',
|
|
KP: '#f97316',
|
|
TW: '#10b981',
|
|
};
|
|
|
|
// ─── Port SVG ────────────────────────────────────────────────────────────────
|
|
|
|
function portSvg(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="5" r="2.5" stroke="${color}" stroke-width="1.5" fill="none"/>
|
|
<line x1="12" y1="7.5" x2="12" y2="21" stroke="${color}" stroke-width="1.5"/>
|
|
<line x1="7" y1="12" x2="17" y2="12" stroke="${color}" stroke-width="1.5"/>
|
|
<path d="M5 18 Q8 21 12 21 Q16 21 19 18" stroke="${color}" stroke-width="1.5" fill="none"/>
|
|
</svg>`;
|
|
}
|
|
|
|
// ─── Wind Turbine SVG ─────────────────────────────────────────────────────────
|
|
|
|
const WIND_COLOR = '#00bcd4';
|
|
|
|
function windTurbineSvg(size: number): string {
|
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<line x1="12" y1="10" x2="11" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
|
<line x1="12" y1="10" x2="13" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
|
<circle cx="12" cy="9" r="1.8" fill="${WIND_COLOR}"/>
|
|
<path d="M12 9 L11.5 1 Q12 0 12.5 1 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
|
<path d="M12 9 L18 14 Q18.5 13 17.5 12.5 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
|
<path d="M12 9 L6 14 Q5.5 13 6.5 12.5 Z" fill="${WIND_COLOR}" opacity="0.9"/>
|
|
<line x1="8" y1="23" x2="16" y2="23" stroke="${WIND_COLOR}" stroke-width="1.5"/>
|
|
</svg>`;
|
|
}
|
|
|
|
// ─── Module-level icon caches ─────────────────────────────────────────────────
|
|
|
|
const portIconCache = new Map<string, string>();
|
|
const WIND_ICON_URL = svgToDataUri(windTurbineSvg(36));
|
|
|
|
export function createPortLayers(
|
|
config: { ports: boolean; windFarm: boolean },
|
|
fc: LayerFactoryConfig,
|
|
): Layer[] {
|
|
const layers: Layer[] = [];
|
|
const sc = fc.sc;
|
|
const onPick = fc.onPick;
|
|
|
|
// ── Ports ───────────────────────────────────────────────────────────────
|
|
if (config.ports) {
|
|
function getPortIconUrl(p: Port): string {
|
|
const key = `${p.country}-${p.type}`;
|
|
if (!portIconCache.has(key)) {
|
|
const color = PORT_COUNTRY_COLOR[p.country] ?? PORT_COUNTRY_COLOR.KR;
|
|
const size = p.type === 'major' ? 32 : 24;
|
|
portIconCache.set(key, svgToDataUri(portSvg(color, size)));
|
|
}
|
|
return portIconCache.get(key)!;
|
|
}
|
|
|
|
layers.push(
|
|
new IconLayer<Port>({
|
|
id: 'static-ports-icon',
|
|
data: EAST_ASIA_PORTS,
|
|
getPosition: (d) => [d.lng, d.lat],
|
|
getIcon: (d) => ({
|
|
url: getPortIconUrl(d),
|
|
width: d.type === 'major' ? 32 : 24,
|
|
height: d.type === 'major' ? 32 : 24,
|
|
anchorX: d.type === 'major' ? 16 : 12,
|
|
anchorY: d.type === 'major' ? 16 : 12,
|
|
}),
|
|
getSize: (d) => (d.type === 'major' ? 16 : 12) * sc,
|
|
updateTriggers: { getSize: [sc] },
|
|
pickable: true,
|
|
onClick: (info: PickingInfo<Port>) => {
|
|
if (info.object) onPick({ kind: 'port', object: info.object });
|
|
return true;
|
|
},
|
|
}),
|
|
new TextLayer<Port>({
|
|
id: 'static-ports-label',
|
|
data: EAST_ASIA_PORTS,
|
|
getPosition: (d) => [d.lng, d.lat],
|
|
getText: (d) => d.nameKo.replace('항', ''),
|
|
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',
|
|
getAlignmentBaseline: 'top',
|
|
getPixelOffset: [0, 8],
|
|
fontFamily: 'monospace',
|
|
fontWeight: 700,
|
|
fontSettings: { sdf: true },
|
|
outlineWidth: 8,
|
|
outlineColor: [0, 0, 0, 255],
|
|
billboard: false,
|
|
characterSet: 'auto',
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ── Wind Farms ─────────────────────────────────────────────────────────
|
|
if (config.windFarm) {
|
|
layers.push(
|
|
new IconLayer<WindFarm>({
|
|
id: 'static-windfarm-icon',
|
|
data: KOREA_WIND_FARMS,
|
|
getPosition: (d) => [d.lng, d.lat],
|
|
getIcon: () => ({ url: WIND_ICON_URL, width: 36, height: 36, anchorX: 18, anchorY: 18 }),
|
|
getSize: 18 * sc,
|
|
updateTriggers: { getSize: [sc] },
|
|
pickable: true,
|
|
onClick: (info: PickingInfo<WindFarm>) => {
|
|
if (info.object) onPick({ kind: 'windFarm', object: info.object });
|
|
return true;
|
|
},
|
|
}),
|
|
new TextLayer<WindFarm>({
|
|
id: 'static-windfarm-label',
|
|
data: KOREA_WIND_FARMS,
|
|
getPosition: (d) => [d.lng, d.lat],
|
|
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
|
getSize: 12 * sc,
|
|
updateTriggers: { getSize: [sc] },
|
|
getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number],
|
|
getTextAnchor: 'middle',
|
|
getAlignmentBaseline: 'top',
|
|
getPixelOffset: [0, 10],
|
|
fontFamily: 'monospace',
|
|
fontWeight: 700,
|
|
fontSettings: { sdf: true },
|
|
outlineWidth: 8,
|
|
outlineColor: [0, 0, 0, 255],
|
|
billboard: false,
|
|
characterSet: 'auto',
|
|
}),
|
|
);
|
|
}
|
|
|
|
return layers;
|
|
}
|