kcg-monitoring/frontend/src/hooks/layers/createPortLayers.ts
htlee e196ee292c feat: korea-layers-enhancement 이관 — 이란 시설 deck.gl SVG 전환 + AI 챗 + 아이콘 품질 통합
이란 시설 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>
2026-03-23 14:48:58 +09:00

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