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 ` `; } // ─── Hazard SVG ─────────────────────────────────────────────────────────────── function nuclearSvg(color: string, size: number): string { return ` `; } function thermalSvg(color: string, size: number): string { return ` `; } function petrochemSvg(color: string, size: number): string { return ` `; } function lngSvg(color: string, size: number): string { return ` `; } function oilTankSvg(color: string, size: number): string { return ` `; } function hazPortSvg(color: string, size: number): string { return ` `; } function shipyardSvg(color: string, size: number): string { return ` `; } function wastewaterSvg(color: string, size: number): string { return ` `; } function heavyIndustrySvg(color: string, size: number): string { return ` `; } // ─── Naval/Airbase/Army SVG (reused from createMilitaryLayers pattern) ───────── function navalFacSvg(color: string, size: number): string { return ` `; } function airbaseFacSvg(color: string, size: number): string { return ` `; } function armyFacSvg(color: string, size: number): string { return ` `; } // ─── Module-level icon caches ───────────────────────────────────────────────── const infraIconCache = new Map(); const hazardIconCache = new Map(); const cnIconCache = new Map(); const jpIconCache = new Map(); // ─── Hazard icon helpers ─────────────────────────────────────────────────────── const HAZARD_SVG: Record string> = { petrochemical: petrochemSvg, lng: lngSvg, oilTank: oilTankSvg, hazardPort: hazPortSvg, nuclear: nuclearSvg, thermal: thermalSvg, shipyard: shipyardSvg, wastewater: wastewaterSvg, heavyIndustry: heavyIndustrySvg, }; const HAZARD_COLOR: Record = { petrochemical: '#f97316', lng: '#06b6d4', oilTank: '#eab308', hazardPort: '#ef4444', nuclear: '#a855f7', thermal: '#64748b', shipyard: '#0ea5e9', wastewater: '#10b981', heavyIndustry: '#94a3b8', }; function getHazardIconUrl(type: string): string { if (!hazardIconCache.has(type)) { const color = HAZARD_COLOR[type] ?? '#888'; const svgFn = HAZARD_SVG[type] ?? hazPortSvg; hazardIconCache.set(type, svgToDataUri(svgFn(color, 64))); } return hazardIconCache.get(type)!; } // ─── CN icon helpers ─────────────────────────────────────────────────────────── const CN_SVG: Record string> = { nuclear: nuclearSvg, thermal: thermalSvg, naval: navalFacSvg, airbase: airbaseFacSvg, army: armyFacSvg, shipyard: shipyardSvg, }; const CN_COLOR: Record = { nuclear: '#ef4444', thermal: '#f97316', naval: '#3b82f6', airbase: '#22d3ee', army: '#22c55e', shipyard: '#94a3b8', }; function getCnIconUrl(subType: string): string { if (!cnIconCache.has(subType)) { const color = CN_COLOR[subType] ?? '#888'; const svgFn = CN_SVG[subType] ?? armyFacSvg; cnIconCache.set(subType, svgToDataUri(svgFn(color, 64))); } return cnIconCache.get(subType)!; } // ─── JP icon helpers ─────────────────────────────────────────────────────────── const JP_SVG: Record string> = { nuclear: nuclearSvg, thermal: thermalSvg, naval: navalFacSvg, airbase: airbaseFacSvg, army: armyFacSvg, }; const JP_COLOR: Record = { nuclear: '#ef4444', thermal: '#f97316', naval: '#3b82f6', airbase: '#22d3ee', army: '#22c55e', }; function getJpIconUrl(subType: string): string { if (!jpIconCache.has(subType)) { const color = JP_COLOR[subType] ?? '#888'; const svgFn = JP_SVG[subType] ?? armyFacSvg; jpIconCache.set(subType, svgToDataUri(svgFn(color, 64))); } return jpIconCache.get(subType)!; } // ─── 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) { 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, updateTriggers: { getSize: [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, updateTriggers: { getSize: [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: 12 * sc * fc.fs, updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 8], fontFamily: 'monospace', fontWeight: 600, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], 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: { color: [249, 115, 22, 255] }, lng: { color: [6, 182, 212, 255] }, oilTank: { color: [234, 179, 8, 255] }, hazardPort: { color: [239, 68, 68, 255] }, nuclear: { color: [168, 85, 247, 255] }, thermal: { color: [100, 116, 139, 255] }, shipyard: { color: [14, 165, 233, 255] }, wastewater: { color: [16, 185, 129, 255] }, heavyIndustry: { color: [148, 163, 184, 255] }, }; if (hazardData.length > 0) { layers.push( new IconLayer({ id: 'static-hazard-icon', data: hazardData, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: getHazardIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }), getSize: 18 * sc, updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'hazard', object: info.object }); return true; }, }), ); 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: 12 * sc * fc.fs, updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 12], fontFamily: 'monospace', fontWeight: 600, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), ); } } // ── CN Facilities ────────────────────────────────────────────────────── { const CN_META: Record = { nuclear: { color: [239, 68, 68, 255] }, thermal: { color: [249, 115, 22, 255] }, naval: { color: [59, 130, 246, 255] }, airbase: { color: [34, 211, 238, 255] }, army: { color: [34, 197, 94, 255] }, shipyard: { 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 IconLayer({ id: 'static-cn-icon', data: cnData, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: getCnIconUrl(d.subType), width: 64, height: 64, anchorX: 32, anchorY: 32 }), getSize: 18 * sc, updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'cnFacility', object: info.object }); return true; }, }), ); layers.push( new TextLayer({ id: 'static-cn-label', data: cnData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, getSize: 12 * sc * fc.fs, updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 12], fontFamily: 'monospace', fontWeight: 600, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), ); } } // ── JP Facilities ────────────────────────────────────────────────────── { const JP_META: Record = { nuclear: { color: [239, 68, 68, 255] }, thermal: { color: [249, 115, 22, 255] }, naval: { color: [59, 130, 246, 255] }, airbase: { color: [34, 211, 238, 255] }, army: { 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 IconLayer({ id: 'static-jp-icon', data: jpData, getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: getJpIconUrl(d.subType), width: 64, height: 64, anchorX: 32, anchorY: 32 }), getSize: 18 * sc, updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'jpFacility', object: info.object }); return true; }, }), ); layers.push( new TextLayer({ id: 'static-jp-label', data: jpData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, getSize: 12 * sc * fc.fs, updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 12], fontFamily: 'monospace', fontWeight: 600, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), ); } } return layers; }