internal/dashboard/frontend/src/views/MapChart.vue

<script setup lang="ts">
import { IconDeselect } from "@tabler/icons-vue";
import { useDebounceFn, useResizeObserver } from "@vueuse/core";
import ChartSidebar from "components/ChartSidebar.vue";
import LoaderOverlay from "components/LoaderOverlay.vue";
import SidebarList from "components/SidebarList.vue";
import { useChartSelection } from "composables/useChartSelection";
import { useChartStats } from "composables/useChartStats";
import Plotly, { type PlotlyHTMLElement } 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";
import type { ChartDataPoint } from "utils/filters";
import {
  computed,
  onBeforeUnmount,
  onMounted,
  ref,
  useTemplateRef,
  watch,
  type Ref,
} from "vue";

const props = defineProps<{
  chartData: ChartDataPoint[];
  loading: boolean;
  connectionStatus?: string;
}>();

const chartStore = useChartStore();
const chartContainer = useTemplateRef<HTMLDivElement>(
  "chartContainer",
) as Ref<HTMLDivElement>;

const uiStore = useUIStore();

const allColors = ref<number[]>([]);
const allRemoteAddrs = ref<any[]>([]);
const allSizes = ref<number[]>([]);
const allCounts = ref<number[]>([]);
const allCountries = ref<string[]>([]);
const lastScale = ref(1);
const lastCenter = ref({ lat: 0, lon: 0 });
const lastDataLength = ref(0);
let lastDataRef: ChartDataPoint[] | null = null;

const groups = new Map<
  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.
 */
function calculateLatRange(width: number, height: number): [number, number] {
  if (!width || !height) return [-60, 60];
  const ratio = height / width;
  const range = Math.min(90, 119 * ratio);
  return [-range, range];
}

const debouncedUpdateStore = useDebounceFn(() => {
  const scale = lastScale.value;
  const { lat, lon } = lastCenter.value;

  if (scale > 1.1) {
    const latSpan = 90 / scale;
    const lonSpan = 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);

async function drawMap(force = false) {
  if (!chartContainer.value) return;

  const data = props.chartData;
  const isIncremental =
    !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;
  }

  const newData = isIncremental ? data.slice(lastDataLength.value) : data;

  newData.forEach((point) => {
    if (point.latitude !== undefined && point.longitude !== undefined) {
      const key = `${point.latitude.toFixed(4)}_${point.longitude.toFixed(4)}`;
      if (!groups.has(key)) {
        groups.set(key, {
          lat: point.latitude,
          lon: point.longitude,
          count: 0,
          ips: new Set(),
          events: new Set(),
          city: point.city || "",
          country: point.country || "",
          remoteAddrInts: [],
          remoteAddrs: [],
        });
      }
      const g = 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;

  const lat: number[] = [];
  const lon: number[] = [];
  const text: string[] = [];
  const sizes: number[] = [];
  const colors: number[] = [];
  const remoteAddrs: any[] = [];
  const counts: number[] = [];
  const countryCodes: string[] = [];

  groups.forEach((g) => {
    lat.push(g.lat);
    lon.push(g.lon);

    // Log-ish scaling for sizes
    const size =
      mapMarkerBaseSize + Math.log10(g.count + 1) * mapMarkerScaleFactor;
    sizes.push(size);

    const ipFreq = new Map<string, number>();
    g.remoteAddrs.forEach((ip) => ipFreq.set(ip, (ipFreq.get(ip) || 0) + 1));
    const sortedIPs = Array.from(g.ips).sort(
      (a, b) => (ipFreq.get(b) || 0) - (ipFreq.get(a) || 0),
    );

    const ipList = sortedIPs.slice(0, 5).join(", ");
    const ipSuffix =
      sortedIPs.length > 5 ? ` (+${sortedIPs.length - 5} more)` : "";
    const eventList = Array.from(g.events).sort().slice(0, 3).join(", ");

    const countryInfo = countries.get(g.country);
    const countryName = countryInfo
      ? `${countryInfo[1]}`
      : g.country || "Unknown";
    const flag = 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);
  });

  const traces: 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,
    },
  ];

  const width = chartContainer.value.clientWidth;
  const height = chartContainer.value.clientHeight;
  const currentLatRange = calculateLatRange(width, height);

  const layout: 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,
  };

  const config: Partial<Plotly.Config> = {
    responsive: true,
    displaylogo: false,
  };

  await Plotly.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;

  const plotDiv = chartContainer.value as unknown as PlotlyHTMLElement;
  plotDiv.removeAllListeners?.("plotly_click");
  plotDiv.on("plotly_click", (eventData) => {
    const clickedIdx = eventData.points[0]?.pointIndex;
    if (clickedIdx !== undefined) {
      let clickedIP = 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;
      let totalCount = 0;
      const uniqueIPs = new Set<string>();
      eventData.points.forEach((p: any) => {
        totalCount += allCounts.value[p.pointIndex] || 0;
        const ips = allRemoteAddrs.value[p.pointIndex];
        if (ips) {
          if (Array.isArray(ips)) ips.forEach((i) => uniqueIPs.add(i));
          else uniqueIPs.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;
    }

    let scale = lastScale.value;
    if (eventData["geo.projection.scale"] !== undefined) {
      scale = eventData["geo.projection.scale"];
    } else if (eventData.geo?.projection?.scale !== undefined) {
      scale = eventData.geo.projection.scale;
    }

    let lat = lastCenter.value.lat;
    let lon = 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,
);

const xRange = computed(() => chartStore.state.x_range);
const yRange = computed(() => chartStore.state.y_range);
const latRange = computed(() => chartStore.state.lat_range);
const lonRange = 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);
  }
});

const debouncedResize = useDebounceFn(() => {
  if (chartContainer.value) {
    const el = chartContainer.value;
    if ((el as any).data?.length) {
      const width = el.clientWidth;
      const height = el.clientHeight;
      const latRange = calculateLatRange(width, height);

      // Chart already exists — just update dimensions and axis range
      Plotly.relayout(el, {
        width: width,
        height: height,
        "geo.lataxis.range": latRange,
      } as any);
    } else {
      // First render hasn't happened yet, do full draw
      drawMap(true);
    }
  }
}, 100);

useResizeObserver(chartContainer, debouncedResize);

const isMounted = ref(false);
onMounted(() => {
  isMounted.value = true;
});
</script>

<template>
  <teleport v-if="isMounted" to="#chart-actions">
    <button
      v-if="selectedIP || selectedCountry || uiStore.selectedCount > 0"
      type="button"
      @click="resetSelection()"
      class="btn-secondary py-0.5 whitespace-nowrap"
    >
      <IconDeselect class="text-[10px]" :color="selectedColor" />
      {{ uiStore.selectedCount }} events ({{ uiStore.selectedIPCount }} IPs)
      <kbd class="ml-1 text-[10px] opacity-50">ESC</kbd>
    </button>
    <div class="flex items-center gap-2">
      <label for="map-type" class="filter-label">Map Type:</label>
      <select
        id="map-type"
        v-model="uiStore.mapType"
        class="select"
        @change="drawMap(true)"
      >
        <option value="scattergeo">Simple</option>
        <option value="scattermap">Carto Map</option>
      </select>
    </div>
  </teleport>

  <div class="flex h-full flex-col-reverse gap-4 md:flex-row">
    <ChartSidebar>
      <SidebarList
        v-if="ipCounts.length > 0"
        open
        title="Top IPs"
        :items="ipCounts"
        type="ip"
        :selected-item="selectedIP"
        @click="highlightIP"
        @exclude="excludeIP"
      />

      <SidebarList
        v-if="countryCounts.length > 0"
        title="Top Countries"
        :items="countryCounts"
        type="country"
        :selected-item="selectedCountry"
        @click="highlightCountry"
        @exclude="excludeCountry"
      />
    </ChartSidebar>

    <div class="chart-container">
      <LoaderOverlay v-if="loading" />

      <div
        ref="chartContainer"
        class="h-full min-h-0 w-full touch-none overflow-hidden"
      ></div>
    </div>
  </div>
</template>