diff --git a/frontend/src/hooks/layers/createFacilityLayers.ts b/frontend/src/hooks/layers/createFacilityLayers.ts new file mode 100644 index 0000000..556ec99 --- /dev/null +++ b/frontend/src/hooks/layers/createFacilityLayers.ts @@ -0,0 +1,310 @@ +import { IconLayer, TextLayer } from '@deck.gl/layers'; +import { svgToDataUri } from '../../utils/svgToDataUri'; +import { HAZARD_FACILITIES } from '../../data/hazardFacilities'; +import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from '../../data/cnFacilities'; +import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from '../../data/jpFacilities'; +import { + hexToRgb, + type LayerFactoryConfig, + type Layer, + type PickingInfo, + type PowerFacility, + type HazardFacility, + type HazardType, + type CnFacility, + type JpFacility, +} from './types'; + +// ─── Infra SVG ──────────────────────────────────────────────────────────────── + +const INFRA_SOURCE_COLOR: Record = { + nuclear: '#e040fb', + coal: '#795548', + gas: '#ff9800', + oil: '#5d4037', + hydro: '#2196f3', + solar: '#ffc107', + wind: '#00bcd4', + biomass: '#4caf50', +}; +const INFRA_SUBSTATION_COLOR = '#ffeb3b'; + +const WIND_COLOR = '#00bcd4'; + +function windTurbineSvg(size: number): string { + return ` + + + + + + + + `; +} + +function infraColor(f: PowerFacility): string { + if (f.type === 'substation') return INFRA_SUBSTATION_COLOR; + return INFRA_SOURCE_COLOR[f.source ?? ''] ?? '#9e9e9e'; +} + +function infraSvg(f: PowerFacility): string { + const color = infraColor(f); + if (f.source === 'wind') { + return windTurbineSvg(14).replace(`stroke="${WIND_COLOR}"`, `stroke="${color}"`).replace(new RegExp(`fill="${WIND_COLOR}"`, 'g'), `fill="${color}"`); + } + const size = f.type === 'substation' ? 7 : 12; + return ` + + `; +} + +// ─── createFacilityLayers ───────────────────────────────────────────────────── + +export function createFacilityLayers( + config: { + infra: boolean; + infraFacilities: PowerFacility[]; + hazardTypes: HazardType[]; + cnPower: boolean; + cnMilitary: boolean; + jpPower: boolean; + jpMilitary: boolean; + }, + fc: LayerFactoryConfig, +): Layer[] { + const layers: Layer[] = []; + const sc = fc.sc; + const onPick = fc.onPick; + + // ── Infra ────────────────────────────────────────────────────────────── + if (config.infra && config.infraFacilities.length > 0) { + const infraIconCache = new Map(); + function getInfraIconUrl(f: PowerFacility): string { + const key = `${f.type}-${f.source ?? ''}`; + if (!infraIconCache.has(key)) { + infraIconCache.set(key, svgToDataUri(infraSvg(f))); + } + return infraIconCache.get(key)!; + } + + const plants = config.infraFacilities.filter(f => f.type === 'plant'); + const substations = config.infraFacilities.filter(f => f.type === 'substation'); + + layers.push( + new IconLayer({ + id: 'static-infra-substation', + data: substations, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getInfraIconUrl(d), width: 14, height: 14, anchorX: 7, anchorY: 7 }), + getSize: 7 * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'infra', object: info.object }); + return true; + }, + }), + new IconLayer({ + id: 'static-infra-plant', + data: plants, + getPosition: (d) => [d.lng, d.lat], + getIcon: (d) => ({ url: getInfraIconUrl(d), width: 24, height: 24, anchorX: 12, anchorY: 12 }), + getSize: 12 * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'infra', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-infra-plant-label', + data: plants, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), + getSize: 8 * sc, + getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 8], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Hazard Facilities ────────────────────────────────────────────────── + if (config.hazardTypes.length > 0) { + const hazardTypeSet = new Set(config.hazardTypes); + const hazardData = HAZARD_FACILITIES.filter(f => hazardTypeSet.has(f.type)); + + const HAZARD_META: Record = { + petrochemical: { icon: '🏭', color: [249, 115, 22, 255] }, + lng: { icon: '🔵', color: [6, 182, 212, 255] }, + oilTank: { icon: '🛢️', color: [234, 179, 8, 255] }, + hazardPort: { icon: '⚠️', color: [239, 68, 68, 255] }, + nuclear: { icon: '☢️', color: [168, 85, 247, 255] }, + thermal: { icon: '🔥', color: [100, 116, 139, 255] }, + shipyard: { icon: '🚢', color: [14, 165, 233, 255] }, + wastewater: { icon: '💧', color: [16, 185, 129, 255] }, + heavyIndustry: { icon: '⚙️', color: [148, 163, 184, 255] }, + }; + + if (hazardData.length > 0) { + layers.push( + new TextLayer({ + id: 'static-hazard-emoji', + data: hazardData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️', + getSize: 16 * sc, + getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'hazard', object: info.object }); + return true; + }, + billboard: false, + characterSet: 'auto', + }), + ); + layers.push( + new TextLayer({ + id: 'static-hazard-label', + data: hazardData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, + getSize: 9 * sc, + getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + } + + // ── CN Facilities ────────────────────────────────────────────────────── + { + const CN_META: Record = { + nuclear: { icon: '☢️', color: [239, 68, 68, 255] }, + thermal: { icon: '🔥', color: [249, 115, 22, 255] }, + naval: { icon: '⚓', color: [59, 130, 246, 255] }, + airbase: { icon: '✈️', color: [34, 211, 238, 255] }, + army: { icon: '🪖', color: [34, 197, 94, 255] }, + shipyard: { icon: '🚢', color: [148, 163, 184, 255] }, + }; + const cnData: CnFacility[] = [ + ...(config.cnPower ? CN_POWER_PLANTS : []), + ...(config.cnMilitary ? CN_MILITARY_FACILITIES : []), + ]; + if (cnData.length > 0) { + layers.push( + new TextLayer({ + id: 'static-cn-emoji', + data: cnData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => CN_META[d.subType]?.icon ?? '📍', + getSize: 16 * sc, + getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'cnFacility', object: info.object }); + return true; + }, + billboard: false, + characterSet: 'auto', + }), + ); + layers.push( + new TextLayer({ + id: 'static-cn-label', + data: cnData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.name, + getSize: 9 * sc, + getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + } + + // ── JP Facilities ────────────────────────────────────────────────────── + { + const JP_META: Record = { + nuclear: { icon: '☢️', color: [239, 68, 68, 255] }, + thermal: { icon: '🔥', color: [249, 115, 22, 255] }, + naval: { icon: '⚓', color: [59, 130, 246, 255] }, + airbase: { icon: '✈️', color: [34, 211, 238, 255] }, + army: { icon: '🪖', color: [34, 197, 94, 255] }, + }; + const jpData: JpFacility[] = [ + ...(config.jpPower ? JP_POWER_PLANTS : []), + ...(config.jpMilitary ? JP_MILITARY_FACILITIES : []), + ]; + if (jpData.length > 0) { + layers.push( + new TextLayer({ + id: 'static-jp-emoji', + data: jpData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => JP_META[d.subType]?.icon ?? '📍', + getSize: 16 * sc, + getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'jpFacility', object: info.object }); + return true; + }, + billboard: false, + characterSet: 'auto', + }), + ); + layers.push( + new TextLayer({ + id: 'static-jp-label', + data: jpData, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.name, + getSize: 9 * sc, + getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 12], + fontFamily: 'monospace', + fontWeight: 600, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + } + + return layers; +} diff --git a/frontend/src/hooks/layers/createMilitaryLayers.ts b/frontend/src/hooks/layers/createMilitaryLayers.ts new file mode 100644 index 0000000..5ab8570 --- /dev/null +++ b/frontend/src/hooks/layers/createMilitaryLayers.ts @@ -0,0 +1,272 @@ +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 ` + + + + `; +} + +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 — TextLayer (이모지) ─────────────────────────────── + if (config.militaryBases) { + const TYPE_COLOR: Record = { + naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e', + missile: '#ef4444', joint: '#a78bfa', + }; + const TYPE_ICON: Record = { + naval: '⚓', airforce: '✈', army: '🪖', missile: '🚀', joint: '⭐', + }; + layers.push( + new TextLayer({ + id: 'static-militarybase-emoji', + data: MILITARY_BASES, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => TYPE_ICON[d.type] ?? '⭐', + getSize: 14 * sc, + getColor: [255, 255, 255, 220], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + billboard: false, + characterSet: 'auto', + 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: 8 * sc, + getColor: (d) => [...hexToRgb(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], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Gov Buildings — TextLayer (이모지) ───────────────────────────────── + if (config.govBuildings) { + const GOV_TYPE_COLOR: Record = { + executive: '#f59e0b', legislature: '#a78bfa', military_hq: '#ef4444', + intelligence: '#6366f1', foreign: '#3b82f6', maritime: '#06b6d4', defense: '#dc2626', + }; + const GOV_TYPE_ICON: Record = { + executive: '🏛', legislature: '🏛', military_hq: '⭐', + intelligence: '🔍', foreign: '🌐', maritime: '⚓', defense: '🛡', + }; + layers.push( + new TextLayer({ + id: 'static-govbuilding-emoji', + data: GOV_BUILDINGS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛', + getSize: 12 * sc, + getColor: [255, 255, 255, 220], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + billboard: false, + characterSet: 'auto', + 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: 8 * sc, + 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, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── NK Launch Sites — TextLayer (이모지) ────────────────────────────── + if (config.nkLaunch) { + layers.push( + new TextLayer({ + id: 'static-nklaunch-emoji', + 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, + getColor: [255, 255, 255, 220], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + billboard: false, + characterSet: 'auto', + 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: 8 * sc, + 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, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── NK Missile Events — IconLayer ───────────────────────────────────── + if (config.nkMissile) { + const launchIconCache = new Map(); + function getLaunchIconUrl(type: string): string { + if (!launchIconCache.has(type)) { + launchIconCache.set(type, svgToDataUri(missileLaunchSvg(getMissileColor(type)))); + } + return launchIconCache.get(type)!; + } + const impactIconCache = new Map(); + 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, + 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, + 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: 8 * sc, + getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + return layers; +} diff --git a/frontend/src/hooks/layers/createNavigationLayers.ts b/frontend/src/hooks/layers/createNavigationLayers.ts new file mode 100644 index 0000000..69a8df2 --- /dev/null +++ b/frontend/src/hooks/layers/createNavigationLayers.ts @@ -0,0 +1,332 @@ +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 ` + + + + + + + + `; +} + +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) { + const cgIconCache = new Map(); + 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, + 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: 8 * sc, + 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, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Airports ─────────────────────────────────────────────────────────── + if (config.airports) { + const apIconCache = new Map(); + 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, + 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: 9 * sc, + getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── NavWarning ───────────────────────────────────────────────────────── + if (config.navWarning) { + const nwIconCache = new Map(); + 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, + 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: 8 * sc, + 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, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Piracy ───────────────────────────────────────────────────────────── + if (config.piracy) { + const piracyIconCache = new Map(); + 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, + 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: 9 * sc, + 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, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + return layers; +} diff --git a/frontend/src/hooks/layers/createPortLayers.ts b/frontend/src/hooks/layers/createPortLayers.ts new file mode 100644 index 0000000..a336c1b --- /dev/null +++ b/frontend/src/hooks/layers/createPortLayers.ts @@ -0,0 +1,145 @@ +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 = { + KR: '#3b82f6', + CN: '#ef4444', + JP: '#f472b6', + KP: '#f97316', + TW: '#10b981', +}; + +// ─── Port SVG ──────────────────────────────────────────────────────────────── + +function portSvg(color: string, size: number): string { + return ` + + + + + `; +} + +// ─── Wind Turbine SVG ───────────────────────────────────────────────────────── + +const WIND_COLOR = '#00bcd4'; + +function windTurbineSvg(size: number): string { + return ` + + + + + + + + `; +} + +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) { + const portIconCache = new Map(); + 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({ + 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, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'port', object: info.object }); + return true; + }, + }), + new TextLayer({ + id: 'static-ports-label', + data: EAST_ASIA_PORTS, + getPosition: (d) => [d.lng, d.lat], + getText: (d) => d.nameKo.replace('항', ''), + getSize: 9 * 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, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + // ── Wind Farms ───────────────────────────────────────────────────────── + if (config.windFarm) { + const windUrl = svgToDataUri(windTurbineSvg(36)); + layers.push( + new IconLayer({ + id: 'static-windfarm-icon', + data: KOREA_WIND_FARMS, + getPosition: (d) => [d.lng, d.lat], + getIcon: () => ({ url: windUrl, width: 36, height: 36, anchorX: 18, anchorY: 18 }), + getSize: 18 * sc, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) onPick({ kind: 'windFarm', object: info.object }); + return true; + }, + }), + new TextLayer({ + 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: 9 * sc, + getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 10], + fontFamily: 'monospace', + fontWeight: 700, + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); + } + + return layers; +} diff --git a/frontend/src/hooks/layers/types.ts b/frontend/src/hooks/layers/types.ts new file mode 100644 index 0000000..3f179ec --- /dev/null +++ b/frontend/src/hooks/layers/types.ts @@ -0,0 +1,49 @@ +import type { PickingInfo, Layer } from '@deck.gl/core'; +import type { Port } from '../../data/ports'; +import type { WindFarm } from '../../data/windFarms'; +import type { MilitaryBase } from '../../data/militaryBases'; +import type { GovBuilding } from '../../data/govBuildings'; +import type { NKLaunchSite } from '../../data/nkLaunchSites'; +import type { NKMissileEvent } from '../../data/nkMissileEvents'; +import type { CoastGuardFacility } from '../../services/coastGuard'; +import type { KoreanAirport } from '../../services/airports'; +import type { NavWarning } from '../../services/navWarning'; +import type { PiracyZone } from '../../services/piracy'; +import type { PowerFacility } from '../../services/infra'; +import type { HazardFacility, HazardType } from '../../data/hazardFacilities'; +import type { CnFacility } from '../../data/cnFacilities'; +import type { JpFacility } from '../../data/jpFacilities'; + +export type StaticPickedObject = + | Port | WindFarm | MilitaryBase | GovBuilding + | NKLaunchSite | NKMissileEvent | CoastGuardFacility | KoreanAirport + | NavWarning | PiracyZone | PowerFacility | HazardFacility + | CnFacility | JpFacility; + +export type StaticLayerKind = + | 'port' | 'windFarm' | 'militaryBase' | 'govBuilding' + | 'nkLaunch' | 'nkMissile' | 'coastGuard' | 'airport' + | 'navWarning' | 'piracy' | 'infra' | 'hazard' + | 'cnFacility' | 'jpFacility'; + +export interface StaticPickInfo { + kind: StaticLayerKind; + object: StaticPickedObject; +} + +export interface LayerFactoryConfig { + sc: number; // sizeScale + onPick: (info: StaticPickInfo) => void; +} + +export type { PickingInfo, Layer }; +export type { Port, WindFarm, MilitaryBase, GovBuilding, NKLaunchSite, NKMissileEvent }; +export type { CoastGuardFacility, KoreanAirport, NavWarning, PiracyZone }; +export type { PowerFacility, HazardFacility, HazardType, CnFacility, JpFacility }; + +export function hexToRgb(hex: string): [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]; +} diff --git a/frontend/src/hooks/useStaticDeckLayers.ts b/frontend/src/hooks/useStaticDeckLayers.ts index 0d4c796..e18589c 100644 --- a/frontend/src/hooks/useStaticDeckLayers.ts +++ b/frontend/src/hooks/useStaticDeckLayers.ts @@ -1,75 +1,15 @@ import { useMemo } from 'react'; -import { IconLayer, TextLayer, PathLayer } from '@deck.gl/layers'; -import type { Layer, PickingInfo } from '@deck.gl/core'; -import { svgToDataUri } from '../utils/svgToDataUri'; - -// Data imports -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 { 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 { 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 type { Layer } from '@deck.gl/core'; import type { PowerFacility } from '../services/infra'; -import { HAZARD_FACILITIES } from '../data/hazardFacilities'; -import type { HazardFacility, HazardType } from '../data/hazardFacilities'; -import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from '../data/cnFacilities'; -import type { CnFacility } from '../data/cnFacilities'; -import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from '../data/jpFacilities'; -import type { JpFacility } from '../data/jpFacilities'; +import type { HazardType } from '../data/hazardFacilities'; -// ─── Type alias to avoid 'any' in PickingInfo ─────────────────────────────── +// Re-export types for consumers +export type { StaticPickedObject, StaticLayerKind, StaticPickInfo } from './layers/types'; -export type StaticPickedObject = - | Port - | WindFarm - | MilitaryBase - | GovBuilding - | NKLaunchSite - | NKMissileEvent - | CoastGuardFacility - | KoreanAirport - | NavWarning - | PiracyZone - | PowerFacility - | HazardFacility - | CnFacility - | JpFacility; - -export type StaticLayerKind = - | 'port' - | 'windFarm' - | 'militaryBase' - | 'govBuilding' - | 'nkLaunch' - | 'nkMissile' - | 'coastGuard' - | 'airport' - | 'navWarning' - | 'piracy' - | 'infra' - | 'hazard' - | 'cnFacility' - | 'jpFacility'; - -export interface StaticPickInfo { - kind: StaticLayerKind; - object: StaticPickedObject; -} +import { createPortLayers } from './layers/createPortLayers'; +import { createNavigationLayers } from './layers/createNavigationLayers'; +import { createMilitaryLayers } from './layers/createMilitaryLayers'; +import { createFacilityLayers } from './layers/createFacilityLayers'; interface StaticLayerConfig { ports: boolean; @@ -89,973 +29,38 @@ interface StaticLayerConfig { cnMilitary: boolean; jpPower: boolean; jpMilitary: boolean; - onPick: (info: StaticPickInfo) => void; - sizeScale?: number; // 줌 레벨별 스케일 배율 (기본 1.0) + onPick: (info: import('./layers/types').StaticPickInfo) => void; + sizeScale?: number; } -// ─── Color helpers ──────────────────────────────────────────────────────────── - -function hexToRgb(hex: string): [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]; -} - -// ─── Port SVG ──────────────────────────────────────────────────────────────── - -const PORT_COUNTRY_COLOR: Record = { - KR: '#3b82f6', - CN: '#ef4444', - JP: '#f472b6', - KP: '#f97316', - TW: '#10b981', -}; - -function portSvg(color: string, size: number): string { - return ` - - - - - `; -} - -// ─── Wind Turbine SVG ───────────────────────────────────────────────────────── - -const WIND_COLOR = '#00bcd4'; - -function windTurbineSvg(size: number): string { - return ` - - - - - - - - `; -} - -// ─── CoastGuard SVG ─────────────────────────────────────────────────────────── - -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 SVG ───────────────────────────────────────────────────────────── - -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 SVG ─────────────────────────────────────────────────────────── - -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 SVG ─────────────────────────────────────────────────────────────── - -function piracySvg(color: string, size: number): string { - return ` - - - - - - - - `; -} - -// ─── 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 ` - - - - `; -} - -// ─── Infra SVG ──────────────────────────────────────────────────────────────── - -const INFRA_SOURCE_COLOR: Record = { - nuclear: '#e040fb', - coal: '#795548', - gas: '#ff9800', - oil: '#5d4037', - hydro: '#2196f3', - solar: '#ffc107', - wind: '#00bcd4', - biomass: '#4caf50', -}; -const INFRA_SUBSTATION_COLOR = '#ffeb3b'; - -function infraColor(f: PowerFacility): string { - if (f.type === 'substation') return INFRA_SUBSTATION_COLOR; - return INFRA_SOURCE_COLOR[f.source ?? ''] ?? '#9e9e9e'; -} - -function infraSvg(f: PowerFacility): string { - const color = infraColor(f); - if (f.source === 'wind') { - return windTurbineSvg(14).replace(`stroke="${WIND_COLOR}"`, `stroke="${color}"`).replace(new RegExp(`fill="${WIND_COLOR}"`, 'g'), `fill="${color}"`); - } - const size = f.type === 'substation' ? 7 : 12; - return ` - - `; -} - -// ─── Memoized icon atlases ──────────────────────────────────────────────────── - -// We use individual Data URI per item via getIcon accessor instead of atlas -// ─── Main hook ─────────────────────────────────────────────────────────────── - export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { return useMemo(() => { - const layers: Layer[] = []; - const sc = config.sizeScale ?? 1.0; // 줌 레벨별 스케일 배율 + const fc = { sc: config.sizeScale ?? 1.0, onPick: config.onPick }; - // ── Ports ─────────────────────────────────────────────────────────────── - if (config.ports) { - // Build per-item data-uri icons: reuse by (country, type) key - const portIconCache = new Map(); - 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({ - 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, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'port', object: info.object }); - return true; - }, - }), - new TextLayer({ - id: 'static-ports-label', - data: EAST_ASIA_PORTS, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => d.nameKo.replace('항', ''), - getSize: 9 * 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, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Wind Farms ───────────────────────────────────────────────────────── - if (config.windFarm) { - const windUrl = svgToDataUri(windTurbineSvg(36)); - layers.push( - new IconLayer({ - id: 'static-windfarm-icon', - data: KOREA_WIND_FARMS, - getPosition: (d) => [d.lng, d.lat], - getIcon: () => ({ url: windUrl, width: 36, height: 36, anchorX: 18, anchorY: 18 }), - getSize: 18 * sc, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'windFarm', object: info.object }); - return true; - }, - }), - new TextLayer({ - 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: 9 * sc, - getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 10], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Coast Guard ──────────────────────────────────────────────────────── - if (config.coastGuard) { - const cgIconCache = new Map(); - 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, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.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: 8 * sc, - 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, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Airports ─────────────────────────────────────────────────────────── - if (config.airports) { - const apIconCache = new Map(); - 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, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.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: 9 * sc, - getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 10], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── NavWarning ───────────────────────────────────────────────────────── - if (config.navWarning) { - const nwIconCache = new Map(); - 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, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.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: 8 * sc, - 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, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Piracy ───────────────────────────────────────────────────────────── - if (config.piracy) { - const piracyIconCache = new Map(); - 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, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.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: 9 * sc, - 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, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Military Bases — TextLayer (이모지) ─────────────────────────────── - if (config.militaryBases) { - const TYPE_COLOR: Record = { - naval: '#3b82f6', airforce: '#f59e0b', army: '#22c55e', - missile: '#ef4444', joint: '#a78bfa', - }; - const TYPE_ICON: Record = { - naval: '⚓', airforce: '✈', army: '🪖', missile: '🚀', joint: '⭐', - }; - layers.push( - new TextLayer({ - id: 'static-militarybase-emoji', - data: MILITARY_BASES, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => TYPE_ICON[d.type] ?? '⭐', - getSize: 14 * sc, - getColor: [255, 255, 255, 220], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - characterSet: 'auto', - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.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: 8 * sc, - getColor: (d) => [...hexToRgb(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], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Gov Buildings — TextLayer (이모지) ───────────────────────────────── - if (config.govBuildings) { - const GOV_TYPE_COLOR: Record = { - executive: '#f59e0b', legislature: '#a78bfa', military_hq: '#ef4444', - intelligence: '#6366f1', foreign: '#3b82f6', maritime: '#06b6d4', defense: '#dc2626', - }; - const GOV_TYPE_ICON: Record = { - executive: '🏛', legislature: '🏛', military_hq: '⭐', - intelligence: '🔍', foreign: '🌐', maritime: '⚓', defense: '🛡', - }; - layers.push( - new TextLayer({ - id: 'static-govbuilding-emoji', - data: GOV_BUILDINGS, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛', - getSize: 12 * sc, - getColor: [255, 255, 255, 220], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - characterSet: 'auto', - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.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: 8 * sc, - 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, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── NK Launch Sites — TextLayer (이모지) ────────────────────────────── - if (config.nkLaunch) { - layers.push( - new TextLayer({ - id: 'static-nklaunch-emoji', - 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, - getColor: [255, 255, 255, 220], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - billboard: false, - characterSet: 'auto', - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.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: 8 * sc, - 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, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── NK Missile Events — IconLayer ───────────────────────────────────── - if (config.nkMissile) { - // Launch points (triangle) - const launchIconCache = new Map(); - function getLaunchIconUrl(type: string): string { - if (!launchIconCache.has(type)) { - launchIconCache.set(type, svgToDataUri(missileLaunchSvg(getMissileColor(type)))); - } - return launchIconCache.get(type)!; - } - // Impact points (X) - const impactIconCache = new Map(); - 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, - 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, - 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) config.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: 8 * sc, - getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 10], - fontFamily: 'monospace', - fontWeight: 700, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Infra ────────────────────────────────────────────────────────────── - if (config.infra && config.infraFacilities.length > 0) { - const infraIconCache = new Map(); - function getInfraIconUrl(f: PowerFacility): string { - const key = `${f.type}-${f.source ?? ''}`; - if (!infraIconCache.has(key)) { - infraIconCache.set(key, svgToDataUri(infraSvg(f))); - } - return infraIconCache.get(key)!; - } - - const plants = config.infraFacilities.filter(f => f.type === 'plant'); - const substations = config.infraFacilities.filter(f => f.type === 'substation'); - - layers.push( - new IconLayer({ - id: 'static-infra-substation', - data: substations, - getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => ({ url: getInfraIconUrl(d), width: 14, height: 14, anchorX: 7, anchorY: 7 }), - getSize: 7 * sc, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'infra', object: info.object }); - return true; - }, - }), - new IconLayer({ - id: 'static-infra-plant', - data: plants, - getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => ({ url: getInfraIconUrl(d), width: 24, height: 24, anchorX: 12, anchorY: 12 }), - getSize: 12 * sc, - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'infra', object: info.object }); - return true; - }, - }), - new TextLayer({ - id: 'static-infra-plant-label', - data: plants, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), - getSize: 8 * sc, - getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 8], - fontFamily: 'monospace', - fontWeight: 600, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - - // ── Hazard Facilities ────────────────────────────────────────────────── - if (config.hazardTypes.length > 0) { - const hazardTypeSet = new Set(config.hazardTypes); - const hazardData = HAZARD_FACILITIES.filter(f => hazardTypeSet.has(f.type)); - - const HAZARD_META: Record = { - petrochemical: { icon: '🏭', color: [249, 115, 22, 255] }, - lng: { icon: '🔵', color: [6, 182, 212, 255] }, - oilTank: { icon: '🛢️', color: [234, 179, 8, 255] }, - hazardPort: { icon: '⚠️', color: [239, 68, 68, 255] }, - nuclear: { icon: '☢️', color: [168, 85, 247, 255] }, - thermal: { icon: '🔥', color: [100, 116, 139, 255] }, - shipyard: { icon: '🚢', color: [14, 165, 233, 255] }, - wastewater: { icon: '💧', color: [16, 185, 129, 255] }, - heavyIndustry: { icon: '⚙️', color: [148, 163, 184, 255] }, - }; - - if (hazardData.length > 0) { - layers.push( - new TextLayer({ - id: 'static-hazard-emoji', - data: hazardData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️', - getSize: 16 * sc, - getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'hazard', object: info.object }); - return true; - }, - billboard: false, - characterSet: 'auto', - }), - ); - layers.push( - new TextLayer({ - id: 'static-hazard-label', - data: hazardData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, - getSize: 9 * sc, - getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 12], - fontFamily: 'monospace', - fontWeight: 600, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - } - - // ── CN Facilities ────────────────────────────────────────────────────── - { - const CN_META: Record = { - nuclear: { icon: '☢️', color: [239, 68, 68, 255] }, - thermal: { icon: '🔥', color: [249, 115, 22, 255] }, - naval: { icon: '⚓', color: [59, 130, 246, 255] }, - airbase: { icon: '✈️', color: [34, 211, 238, 255] }, - army: { icon: '🪖', color: [34, 197, 94, 255] }, - shipyard: { icon: '🚢', color: [148, 163, 184, 255] }, - }; - const cnData: CnFacility[] = [ - ...(config.cnPower ? CN_POWER_PLANTS : []), - ...(config.cnMilitary ? CN_MILITARY_FACILITIES : []), - ]; - if (cnData.length > 0) { - layers.push( - new TextLayer({ - id: 'static-cn-emoji', - data: cnData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => CN_META[d.subType]?.icon ?? '📍', - getSize: 16 * sc, - getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'cnFacility', object: info.object }); - return true; - }, - billboard: false, - characterSet: 'auto', - }), - ); - layers.push( - new TextLayer({ - id: 'static-cn-label', - data: cnData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => d.name, - getSize: 9 * sc, - getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 12], - fontFamily: 'monospace', - fontWeight: 600, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - } - - // ── JP Facilities ────────────────────────────────────────────────────── - { - const JP_META: Record = { - nuclear: { icon: '☢️', color: [239, 68, 68, 255] }, - thermal: { icon: '🔥', color: [249, 115, 22, 255] }, - naval: { icon: '⚓', color: [59, 130, 246, 255] }, - airbase: { icon: '✈️', color: [34, 211, 238, 255] }, - army: { icon: '🪖', color: [34, 197, 94, 255] }, - }; - const jpData: JpFacility[] = [ - ...(config.jpPower ? JP_POWER_PLANTS : []), - ...(config.jpMilitary ? JP_MILITARY_FACILITIES : []), - ]; - if (jpData.length > 0) { - layers.push( - new TextLayer({ - id: 'static-jp-emoji', - data: jpData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => JP_META[d.subType]?.icon ?? '📍', - getSize: 16 * sc, - getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - pickable: true, - onClick: (info: PickingInfo) => { - if (info.object) config.onPick({ kind: 'jpFacility', object: info.object }); - return true; - }, - billboard: false, - characterSet: 'auto', - }), - ); - layers.push( - new TextLayer({ - id: 'static-jp-label', - data: jpData, - getPosition: (d) => [d.lng, d.lat], - getText: (d) => d.name, - getSize: 9 * sc, - getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 12], - fontFamily: 'monospace', - fontWeight: 600, - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } - } - - return layers; - // infraFacilities는 배열 참조가 바뀌어야 갱신 - // eslint-disable-next-line react-hooks/exhaustive-deps + return [ + ...createPortLayers({ ports: config.ports, windFarm: config.windFarm }, fc), + ...createNavigationLayers({ + coastGuard: config.coastGuard, + airports: config.airports, + navWarning: config.navWarning, + piracy: config.piracy, + }, fc), + ...createMilitaryLayers({ + militaryBases: config.militaryBases, + govBuildings: config.govBuildings, + nkLaunch: config.nkLaunch, + nkMissile: config.nkMissile, + }, fc), + ...createFacilityLayers({ + infra: config.infra, + infraFacilities: config.infraFacilities, + hazardTypes: config.hazardTypes, + cnPower: config.cnPower, + cnMilitary: config.cnMilitary, + jpPower: config.jpPower, + jpMilitary: config.jpMilitary, + }, fc), + ]; }, [ config.ports, config.windFarm, @@ -1078,9 +83,3 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { config.sizeScale, ]); } - -// Re-export types that KoreaMap will need for Popup rendering -export type { Port, WindFarm, MilitaryBase, GovBuilding, NKLaunchSite, NKMissileEvent, CoastGuardFacility, KoreanAirport, NavWarning, PiracyZone, PowerFacility }; -export type { HazardFacility, HazardType, CnFacility, JpFacility }; -// Re-export label/color helpers used in Popup rendering -export { CG_TYPE_COLOR, PORT_COUNTRY_COLOR, WIND_COLOR, NW_ORG_COLOR, PIRACY_LEVEL_COLOR, getMissileColor };