import { IconLayer, TextLayer } from '@deck.gl/layers'; import type { PickingInfo, Layer } from '@deck.gl/core'; import { svgToDataUri } from '../../utils/svgToDataUri'; import { COAST_GUARD_FACILITIES } from '../../services/coastGuard'; import type { CoastGuardFacility, CoastGuardType } from '../../services/coastGuard'; import { KOREAN_AIRPORTS } from '../../services/airports'; import type { KoreanAirport } from '../../services/airports'; import { NAV_WARNINGS } from '../../services/navWarning'; import type { NavWarning, NavWarningLevel, TrainingOrg } from '../../services/navWarning'; import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../../services/piracy'; import type { PiracyZone } from '../../services/piracy'; import { hexToRgb } from './types'; import type { LayerFactoryConfig } from './types'; // ─── CoastGuard ─────────────────────────────────────────────────────────────── const CG_TYPE_COLOR: Record = { hq: '#ff6b6b', regional: '#ffa94d', station: '#4dabf7', substation: '#69db7c', vts: '#da77f2', navy: '#3b82f6', }; function coastGuardSvg(type: CoastGuardType, size: number): string { const color = CG_TYPE_COLOR[type]; if (type === 'navy') { return ` `; } if (type === 'vts') { return ` `; } return ` `; } const CG_TYPE_SIZE: Record = { hq: 24, regional: 20, station: 16, substation: 13, vts: 14, navy: 18, }; // ─── Airport ────────────────────────────────────────────────────────────────── const AP_COUNTRY_COLOR: Record = { KR: { intl: '#a78bfa', domestic: '#7c8aaa' }, CN: { intl: '#ef4444', domestic: '#b91c1c' }, JP: { intl: '#f472b6', domestic: '#9d174d' }, KP: { intl: '#f97316', domestic: '#c2410c' }, TW: { intl: '#10b981', domestic: '#059669' }, }; function apColor(ap: KoreanAirport): string { const cc = AP_COUNTRY_COLOR[ap.country ?? 'KR'] ?? AP_COUNTRY_COLOR.KR; return ap.intl ? cc.intl : cc.domestic; } function airportSvg(color: string, size: number): string { return ` `; } // ─── NavWarning ─────────────────────────────────────────────────────────────── const NW_ORG_COLOR: Record = { '해군': '#8b5cf6', '해병대': '#22c55e', '공군': '#f97316', '육군': '#ef4444', '해경': '#3b82f6', '국과연': '#eab308', }; function navWarningSvg(level: NavWarningLevel, org: TrainingOrg, size: number): string { const color = NW_ORG_COLOR[org]; if (level === 'danger') { return ` `; } return ` `; } // ─── Piracy ─────────────────────────────────────────────────────────────────── function piracySvg(color: string, size: number): string { return ` `; } // ─── Module-level icon caches ───────────────────────────────────────────────── const cgIconCache = new Map(); const apIconCache = new Map(); const nwIconCache = new Map(); const piracyIconCache = new Map(); export function createNavigationLayers( config: { coastGuard: boolean; airports: boolean; navWarning: boolean; piracy: boolean }, fc: LayerFactoryConfig, ): Layer[] { const layers: Layer[] = []; const sc = fc.sc; const onPick = fc.onPick; // ── Coast Guard ──────────────────────────────────────────────────────── if (config.coastGuard) { function getCgIconUrl(type: CoastGuardType): string { if (!cgIconCache.has(type)) { const size = CG_TYPE_SIZE[type]; cgIconCache.set(type, svgToDataUri(coastGuardSvg(type, size * 2))); } return cgIconCache.get(type)!; } layers.push( new IconLayer({ id: 'static-coastguard-icon', data: COAST_GUARD_FACILITIES, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => { const sz = CG_TYPE_SIZE[d.type] * 2; return { url: getCgIconUrl(d.type), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; }, getSize: (d) => CG_TYPE_SIZE[d.type] * sc, updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'coastGuard', object: info.object }); return true; }, }), new TextLayer({ id: 'static-coastguard-label', data: COAST_GUARD_FACILITIES.filter(f => f.type === 'hq' || f.type === 'regional' || f.type === 'navy' || f.type === 'vts'), getPosition: (d) => [d.lng, d.lat], getText: (d) => { if (d.type === 'vts') return 'VTS'; if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8); return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'; }, getSize: 12 * sc * fc.fs, updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 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', }), ); } // ── Airports ─────────────────────────────────────────────────────────── if (config.airports) { function getApIconUrl(ap: KoreanAirport): string { const color = apColor(ap); const size = ap.intl ? 40 : 32; const key = `${color}-${size}`; if (!apIconCache.has(key)) { apIconCache.set(key, svgToDataUri(airportSvg(color, size))); } return apIconCache.get(key)!; } layers.push( new IconLayer({ id: 'static-airports-icon', data: KOREAN_AIRPORTS, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => { const sz = d.intl ? 40 : 32; return { url: getApIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; }, getSize: (d) => (d.intl ? 20 : 16) * sc, updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'airport', object: info.object }); return true; }, }), new TextLayer({ id: 'static-airports-label', data: KOREAN_AIRPORTS, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''), getSize: 12 * sc * fc.fs, updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(apColor(d)), 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', }), ); } // ── NavWarning ───────────────────────────────────────────────────────── if (config.navWarning) { function getNwIconUrl(w: NavWarning): string { const key = `${w.level}-${w.org}`; if (!nwIconCache.has(key)) { const size = w.level === 'danger' ? 32 : 28; nwIconCache.set(key, svgToDataUri(navWarningSvg(w.level, w.org, size))); } return nwIconCache.get(key)!; } layers.push( new IconLayer({ id: 'static-navwarning-icon', data: NAV_WARNINGS, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => { const sz = d.level === 'danger' ? 32 : 28; return { url: getNwIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; }, getSize: (d) => (d.level === 'danger' ? 16 : 14) * sc, updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'navWarning', object: info.object }); return true; }, }), new TextLayer({ id: 'static-navwarning-label', data: NAV_WARNINGS, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.id, getSize: 12 * sc * fc.fs, updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 9], fontFamily: 'monospace', fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 8, outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), ); } // ── Piracy ───────────────────────────────────────────────────────────── if (config.piracy) { function getPiracyIconUrl(zone: PiracyZone): string { const key = zone.level; if (!piracyIconCache.has(key)) { const color = PIRACY_LEVEL_COLOR[zone.level]; const size = zone.level === 'critical' ? 56 : zone.level === 'high' ? 48 : 40; piracyIconCache.set(key, svgToDataUri(piracySvg(color, size))); } return piracyIconCache.get(key)!; } layers.push( new IconLayer({ id: 'static-piracy-icon', data: PIRACY_ZONES, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => { const sz = d.level === 'critical' ? 56 : d.level === 'high' ? 48 : 40; return { url: getPiracyIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 }; }, getSize: (d) => (d.level === 'critical' ? 28 : d.level === 'high' ? 24 : 20) * sc, updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'piracy', object: info.object }); return true; }, }), new TextLayer({ id: 'static-piracy-label', data: PIRACY_ZONES, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo, getSize: 12 * sc * fc.fs, updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 14], fontFamily: 'monospace', fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 8, outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), ); } return layers; }