<scriptsetuplang="ts">import{IconDeselect}from"@tabler/icons-vue";import{useDebounceFn,useResizeObserver}from"@vueuse/core";importChartSidebarfrom"components/ChartSidebar.vue";importLoaderOverlayfrom"components/LoaderOverlay.vue";importSidebarListfrom"components/SidebarList.vue";import{useChartSelection}from"composables/useChartSelection";import{useChartStats}from"composables/useChartStats";importPlotly,{typePlotlyHTMLElement}from"plotly.js-dist-min";import{countries}from"src/countries";import{useChartStore,useUIStore}from"src/store";import{excludeCountry,excludeIP,hoverlabel,mapChartMargin,mapCoastlineColor,mapCountryColor,mapLandColor,mapMarkerBaseSize,mapMarkerLineColor,mapMarkerLineWidth,mapMarkerScaleFactor,mapOceanColor,modebar,}from"utils/chart";importtype{ChartDataPoint}from"utils/filters";import{computed,onBeforeUnmount,onMounted,ref,useTemplateRef,watch,typeRef,}from"vue";constprops=defineProps<{chartData:ChartDataPoint[];loading:boolean;connectionStatus?:string;}>();constchartStore=useChartStore();constchartContainer=useTemplateRef<HTMLDivElement>("chartContainer",)asRef<HTMLDivElement>;constuiStore=useUIStore();constallColors=ref<number[]>([]);constallRemoteAddrs=ref<any[]>([]);constallSizes=ref<number[]>([]);constallCounts=ref<number[]>([]);constallCountries=ref<string[]>([]);constlastScale=ref(1);constlastCenter=ref({lat:0,lon:0});constlastDataLength=ref(0);letlastDataRef:ChartDataPoint[]|null=null;constgroups=newMap<string,{lat:number;lon:number;count:number;ips:Set<string>;events:Set<string>;city:string;country:string;remoteAddrInts:number[];remoteAddrs:string[];}>();/**
* Calculates the latitude range for a Mercator projection to fill the container.
*/functioncalculateLatRange(width:number,height:number):[number,number]{if(!width||!height)return[-60,60];constratio=height/width;constrange=Math.min(90,119*ratio);return[-range,range];}constdebouncedUpdateStore=useDebounceFn(()=>{constscale=lastScale.value;const{lat,lon}=lastCenter.value;if(scale>1.1){constlatSpan=90/scale;constlonSpan=180/scale;chartStore.state.lat_range=[(lat-latSpan).toString(),(lat+latSpan).toString(),];chartStore.state.lon_range=[(lon-lonSpan).toString(),(lon+lonSpan).toString(),];}else{chartStore.state.lat_range=[];chartStore.state.lon_range=[];}},400);asyncfunctiondrawMap(force=false){if(!chartContainer.value)return;constdata=props.chartData;constisIncremental=!force&&data===lastDataRef&&data.length>=lastDataLength.value&&lastDataLength.value>0;// Skip full redraw if the data hasn't changed
if(!force&&data===lastDataRef&&data.length===lastDataLength.value&&lastDataLength.value>0)return;// If not incremental (e.g. data replaced or forced), clear current state
if(!isIncremental){groups.clear();lastDataLength.value=0;}constnewData=isIncremental?data.slice(lastDataLength.value):data;newData.forEach((point)=>{if(point.latitude!==undefined&&point.longitude!==undefined){constkey=`${point.latitude.toFixed(4)}_${point.longitude.toFixed(4)}`;if(!groups.has(key)){groups.set(key,{lat:point.latitude,lon:point.longitude,count:0,ips:newSet(),events:newSet(),city:point.city||"",country:point.country||"",remoteAddrInts:[],remoteAddrs:[],});}constg=groups.get(key)!;g.count++;g.ips.add(point.remote_addr);if(point.event)g.events.add(point.event);g.remoteAddrInts.push(point.remote_addr_int);g.remoteAddrs.push(point.remote_addr);}});lastDataRef=data;lastDataLength.value=data.length;constlat:number[]=[];constlon:number[]=[];consttext:string[]=[];constsizes:number[]=[];constcolors:number[]=[];constremoteAddrs:any[]=[];constcounts:number[]=[];constcountryCodes:string[]=[];groups.forEach((g)=>{lat.push(g.lat);lon.push(g.lon);// Log-ish scaling for sizes
constsize=mapMarkerBaseSize+Math.log10(g.count+1)*mapMarkerScaleFactor;sizes.push(size);constipFreq=newMap<string,number>();g.remoteAddrs.forEach((ip)=>ipFreq.set(ip,(ipFreq.get(ip)||0)+1));constsortedIPs=Array.from(g.ips).sort((a,b)=>(ipFreq.get(b)||0)-(ipFreq.get(a)||0),);constipList=sortedIPs.slice(0,5).join(", ");constipSuffix=sortedIPs.length>5?` (+${sortedIPs.length-5} more)`:"";consteventList=Array.from(g.events).sort().slice(0,3).join(", ");constcountryInfo=countries.get(g.country);constcountryName=countryInfo?`${countryInfo[1]}`:g.country||"Unknown";constflag=countryInfo?countryInfo[0]:"";text.push(`<b>${flag}${g.city||"Unknown City"}, ${countryName}</b><br>`+`Events: ${g.count}<br>`+`IPs: ${ipList}${ipSuffix}<br>`+`Types: ${eventList}`,);// For selection/coloring, use the most frequent or first data point
colors.push(size);remoteAddrs.push(sortedIPs);counts.push(g.count);countryCodes.push(g.country);});consttraces:Plotly.Data[]=[{type:uiStore.mapType,mode:"markers",lat:lat,lon:lon,text:text,marker:{size:sizes,color:colors,showscale:false,line:{color:mapMarkerLineColor,width:mapMarkerLineWidth,},},hoverinfo:"text",hoverlabel:hoverlabel,},];constwidth=chartContainer.value.clientWidth;constheight=chartContainer.value.clientHeight;constcurrentLatRange=calculateLatRange(width,height);constlayout:Partial<Plotly.Layout>={autosize:true,width:width,height:height,geo:{projection:{scale:lastScale.value,type:"mercator",},center:lastCenter.value,showland:true,showocean:true,showcountries:true,coastlinecolor:mapCoastlineColor,countrycolor:mapCountryColor,landcolor:mapLandColor,oceancolor:mapOceanColor,showsubunits:true,subunitcolor:mapCountryColor,bgcolor:"transparent",resolution:110,showframe:false,lataxis:{range:currentLatRange,},lonaxis:{range:[-180,180],},},margin:mapChartMargin,paper_bgcolor:"transparent",plot_bgcolor:"transparent",map:{style:"dark",},modebar:modebar,};constconfig:Partial<Plotly.Config>={responsive:true,displaylogo:false,};awaitPlotly.react(chartContainer.value,traces,layout,config);// Set reactive refs AFTER the plot is ready, so watchers (e.g. applyHighlight
// calling Plotly.restyle) don't fire before the map style has loaded.
allColors.value=colors;allRemoteAddrs.value=remoteAddrs;allSizes.value=sizes;allCounts.value=counts;allCountries.value=countryCodes;constplotDiv=chartContainer.valueasunknownasPlotlyHTMLElement;plotDiv.removeAllListeners?.("plotly_click");plotDiv.on("plotly_click",(eventData)=>{constclickedIdx=eventData.points[0]?.pointIndex;if(clickedIdx!==undefined){letclickedIP=allRemoteAddrs.value[clickedIdx];if(Array.isArray(clickedIP)){clickedIP=clickedIP[0];}if(clickedIP){highlightIP(clickedIP);}}});plotDiv.on("plotly_selected",(eventData:any)=>{if(eventData&&eventData.points){uiStore.selectedIP=undefined;uiStore.selectedPort=undefined;lettotalCount=0;constuniqueIPs=newSet<string>();eventData.points.forEach((p:any)=>{totalCount+=allCounts.value[p.pointIndex]||0;constips=allRemoteAddrs.value[p.pointIndex];if(ips){if(Array.isArray(ips))ips.forEach((i)=>uniqueIPs.add(i));elseuniqueIPs.add(ips);}});uiStore.selectedCount=totalCount;uiStore.selectedIPCount=uniqueIPs.size;}else{uiStore.selectedCount=0;uiStore.selectedIPCount=0;}});plotDiv.removeAllListeners?.("plotly_relayout");plotDiv.on("plotly_relayout",(eventData:any)=>{if(eventData["geo.autorange"]){chartStore.state.lat_range=[];chartStore.state.lon_range=[];lastScale.value=1;lastCenter.value={lat:0,lon:0};return;}letscale=lastScale.value;if(eventData["geo.projection.scale"]!==undefined){scale=eventData["geo.projection.scale"];}elseif(eventData.geo?.projection?.scale!==undefined){scale=eventData.geo.projection.scale;}letlat=lastCenter.value.lat;letlon=lastCenter.value.lon;if(eventData["geo.center.lat"]!==undefined){lat=eventData["geo.center.lat"];}if(eventData["geo.center.lon"]!==undefined){lon=eventData["geo.center.lon"];}if(eventData["geo.center"]!==undefined){if(eventData["geo.center"].lat!==undefined)lat=eventData["geo.center"].lat;if(eventData["geo.center"].lon!==undefined)lon=eventData["geo.center"].lon;}if(eventData.geo?.center!==undefined){if(eventData.geo.center.lat!==undefined)lat=eventData.geo.center.lat;if(eventData.geo.center.lon!==undefined)lon=eventData.geo.center.lon;}lastScale.value=scale;lastCenter.value={lat,lon};debouncedUpdateStore();});}const{selectedIP,selectedCountry,selectedColor,highlightIP,highlightCountry,resetSelection,}=useChartSelection(chartContainer,allColors,allRemoteAddrs,undefined,allSizes,allCounts,allCountries,);constxRange=computed(()=>chartStore.state.x_range);constyRange=computed(()=>chartStore.state.y_range);constlatRange=computed(()=>chartStore.state.lat_range);constlonRange=computed(()=>chartStore.state.lon_range);const{ipCounts,countryCounts}=useChartStats(computed(()=>props.chartData),xRange,yRange,latRange,lonRange,);watch(()=>props.chartData,()=>{drawMap();},{deep:true},);onBeforeUnmount(()=>{if(chartContainer.value){Plotly.purge(chartContainer.value);}});constdebouncedResize=useDebounceFn(()=>{if(chartContainer.value){constel=chartContainer.value;if((elasany).data?.length){constwidth=el.clientWidth;constheight=el.clientHeight;constlatRange=calculateLatRange(width,height);// Chart already exists — just update dimensions and axis range
Plotly.relayout(el,{width:width,height:height,"geo.lataxis.range":latRange,}asany);}else{// First render hasn't happened yet, do full draw
drawMap(true);}}},100);useResizeObserver(chartContainer,debouncedResize);constisMounted=ref(false);onMounted(()=>{isMounted.value=true;});</script><template><teleportv-if="isMounted" to="#chart-actions"><buttonv-if="selectedIP || selectedCountry || uiStore.selectedCount > 0"type="button"@click="resetSelection()"class="btn-secondary py-0.5 whitespace-nowrap"><IconDeselectclass="text-[10px]":color="selectedColor"/>{{uiStore.selectedCount}}events({{uiStore.selectedIPCount}}IPs)<kbdclass="ml-1 text-[10px] opacity-50">ESC</kbd></button><divclass="flex items-center gap-2"><labelfor="map-type"class="filter-label">MapType:</label><selectid="map-type"v-model="uiStore.mapType"class="select"@change="drawMap(true)"><optionvalue="scattergeo">Simple</option><optionvalue="scattermap">CartoMap</option></select></div></teleport><divclass="flex h-full flex-col-reverse gap-4 md:flex-row"><ChartSidebar><SidebarListv-if="ipCounts.length > 0"opentitle="Top IPs":items="ipCounts"type="ip":selected-item="selectedIP"@click="highlightIP"@exclude="excludeIP"/><SidebarListv-if="countryCounts.length > 0"title="Top Countries":items="countryCounts"type="country":selected-item="selectedCountry"@click="highlightCountry"@exclude="excludeCountry"/></ChartSidebar><divclass="chart-container"><LoaderOverlayv-if="loading"/><divref="chartContainer"class="h-full min-h-0 w-full touch-none overflow-hidden"></div></div></div></template>