import { IconLayer, TextLayer, PathLayer } from '@deck.gl/layers'; import type { PickingInfo, Layer } from '@deck.gl/core'; import { svgToDataUri } from '../../utils/svgToDataUri'; import { MILITARY_BASES } from '../../data/militaryBases'; import type { MilitaryBase } from '../../data/militaryBases'; import { GOV_BUILDINGS } from '../../data/govBuildings'; import type { GovBuilding } from '../../data/govBuildings'; import { NK_LAUNCH_SITES, NK_LAUNCH_TYPE_META } from '../../data/nkLaunchSites'; import type { NKLaunchSite } from '../../data/nkLaunchSites'; import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents'; import type { NKMissileEvent } from '../../data/nkMissileEvents'; import { hexToRgb } from './types'; import type { LayerFactoryConfig } from './types'; // ─── NKMissile SVG ──────────────────────────────────────────────────────────── function getMissileColor(type: string): string { if (type.includes('ICBM')) return '#dc2626'; if (type.includes('IRBM')) return '#ef4444'; if (type.includes('SLBM')) return '#3b82f6'; return '#f97316'; } function missileLaunchSvg(color: string): string { return ` `; } function missileImpactSvg(color: string): string { return ` `; } // ─── MilitaryBase SVG ───────────────────────────────────────────────────────── function navalBaseSvg(color: string, size: number): string { return ` `; } function airforceBaseSvg(color: string, size: number): string { return ` `; } function armyBaseSvg(color: string, size: number): string { return ` `; } function missileBaseSvg(color: string, size: number): string { return ` `; } function jointBaseSvg(color: string, size: number): string { return ` `; } // ─── GovBuilding SVG ────────────────────────────────────────────────────────── function governmentBuildingSvg(color: string, size: number): string { return ` `; } function militaryHqSvg(color: string, size: number): string { return ` `; } function intelligenceSvg(color: string, size: number): string { return ` `; } function foreignSvg(color: string, size: number): string { return ` `; } function maritimeSvg(color: string, size: number): string { return ` `; } function defenseSvg(color: string, size: number): string { return ` `; } // ─── NK Launch Site SVG ─────────────────────────────────────────────────────── function icbmSiteSvg(color: string, size: number): string { return ` `; } function srbmSiteSvg(color: string, size: number): string { return ` `; } function slbmSiteSvg(color: string, size: number): string { return ` `; } function cruiseSiteSvg(color: string, size: number): string { return ` `; } function artillerySiteSvg(color: string, size: number): string { return ` `; } // ─── Module-level icon caches ───────────────────────────────────────────────── const launchIconCache = new Map(); const impactIconCache = new Map(); const milBaseIconCache = new Map(); const govBuildingIconCache = new Map(); const nkLaunchIconCache = new Map(); // ─── MilitaryBase icon helpers ──────────────────────────────────────────────── const MIL_BASE_TYPE_COLOR: Record = { naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e', missile: '#ef4444', joint: '#a78bfa', }; type MilBaseSvgFn = (color: string, size: number) => string; const MIL_BASE_SVG_FN: Record = { 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 = { 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 = { 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 = { 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 }, fc: LayerFactoryConfig, ): Layer[] { const layers: Layer[] = []; const sc = fc.sc; const onPick = fc.onPick; // ── Military Bases — IconLayer ──────────────────────────────────────── if (config.militaryBases) { layers.push( new IconLayer({ id: 'static-militarybase-icon', data: MILITARY_BASES, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: getMilBaseIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }), getSize: 16 * sc, updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'militaryBase', object: info.object }); return true; }, }), new TextLayer({ id: 'static-militarybase-label', data: MILITARY_BASES, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo), getSize: 11 * sc * fc.fs, updateTriggers: { getSize: [sc, fc.fs] }, 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, fontSettings: { sdf: true }, outlineWidth: 8, outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), ); } // ── Gov Buildings — IconLayer ───────────────────────────────────────── if (config.govBuildings) { layers.push( new IconLayer({ id: 'static-govbuilding-icon', data: GOV_BUILDINGS, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: getGovBuildingIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }), getSize: 12 * sc, updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'govBuilding', object: info.object }); return true; }, }), new TextLayer({ id: 'static-govbuilding-label', data: GOV_BUILDINGS, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), getSize: 11 * sc * fc.fs, updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 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', }), ); } // ── NK Launch Sites — IconLayer ─────────────────────────────────────── if (config.nkLaunch) { layers.push( new IconLayer({ id: 'static-nklaunch-icon', data: NK_LAUNCH_SITES, getPosition: (d) => [d.lng, d.lat], 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] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'nkLaunch', object: info.object }); return true; }, }), new TextLayer({ id: 'static-nklaunch-label', data: NK_LAUNCH_SITES, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), getSize: 11 * sc * fc.fs, updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 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', }), ); } // ── NK Missile Events — IconLayer ───────────────────────────────────── if (config.nkMissile) { function getLaunchIconUrl(type: string): string { if (!launchIconCache.has(type)) { launchIconCache.set(type, svgToDataUri(missileLaunchSvg(getMissileColor(type)))); } return launchIconCache.get(type)!; } function getImpactIconUrl(type: string): string { if (!impactIconCache.has(type)) { impactIconCache.set(type, svgToDataUri(missileImpactSvg(getMissileColor(type)))); } return impactIconCache.get(type)!; } interface LaunchPoint { ev: NKMissileEvent; lat: number; lng: number } interface ImpactPoint { ev: NKMissileEvent; lat: number; lng: number } const launchData: LaunchPoint[] = NK_MISSILE_EVENTS.map(ev => ({ ev, lat: ev.launchLat, lng: ev.launchLng })); const impactData: ImpactPoint[] = NK_MISSILE_EVENTS.map(ev => ({ ev, lat: ev.impactLat, lng: ev.impactLng })); // 발사→착탄 궤적선 const trajectoryData = NK_MISSILE_EVENTS.map(ev => ({ path: [[ev.launchLng, ev.launchLat], [ev.impactLng, ev.impactLat]] as [number, number][], color: hexToRgb(getMissileColor(ev.type)), })); layers.push( new PathLayer<{ path: [number, number][]; color: [number, number, number] }>({ id: 'static-nkmissile-trajectory', data: trajectoryData, getPath: (d) => d.path, getColor: (d) => [...d.color, 150] as [number, number, number, number], getWidth: 2, widthUnits: 'pixels', getDashArray: [6, 3], dashJustified: true, extensions: [], }), ); layers.push( new IconLayer({ id: 'static-nkmissile-launch', data: launchData, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: getLaunchIconUrl(d.ev.type), width: 24, height: 24, anchorX: 12, anchorY: 12 }), getSize: 12 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => { const today = new Date().toISOString().slice(0, 10) === d.ev.date; return [255, 255, 255, today ? 255 : 90] as [number, number, number, number]; }, }), new IconLayer({ id: 'static-nkmissile-impact', data: impactData, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: getImpactIconUrl(d.ev.type), width: 32, height: 32, anchorX: 16, anchorY: 16 }), getSize: 16 * sc, updateTriggers: { getSize: [sc] }, getColor: (d) => { const today = new Date().toISOString().slice(0, 10) === d.ev.date; return [255, 255, 255, today ? 255 : 100] as [number, number, number, number]; }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'nkMissile', object: info.object.ev }); return true; }, }), new TextLayer({ id: 'static-nkmissile-label', data: impactData, getPosition: (d) => [d.lng, d.lat], getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`, getSize: 11 * sc * fc.fs, updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 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; }