← back to Exo

dashboard/src/lib/components/TopologyGraph.svelte

1251 lines

<script lang="ts">
  import { onMount, onDestroy } from "svelte";
  import * as d3 from "d3";
  import {
    topologyData,
    isTopologyMinimized,
    debugMode,
    nodeThunderboltBridge,
    nodeRdmaCtl,
    nodeIdentities,
    type NodeInfo,
  } from "$lib/stores/app.svelte";

  interface Props {
    class?: string;
    highlightedNodes?: Set<string>;
    filteredNodes?: Set<string>;
    onNodeClick?: (nodeId: string) => void;
  }

  let {
    class: className = "",
    highlightedNodes = new Set(),
    filteredNodes = new Set(),
    onNodeClick,
  }: Props = $props();

  let svgContainer: SVGSVGElement | undefined = $state();
  let resizeObserver: ResizeObserver | undefined;
  let hoveredNodeId = $state<string | null>(null);

  const isMinimized = $derived(isTopologyMinimized());
  const data = $derived(topologyData());
  const debugEnabled = $derived(debugMode());
  const tbBridgeData = $derived(nodeThunderboltBridge());
  const rdmaCtlData = $derived(nodeRdmaCtl());
  const identitiesData = $derived(nodeIdentities());

  function getNodeLabel(nodeId: string): string {
    const node = data?.nodes?.[nodeId];
    return node?.friendly_name || nodeId.slice(0, 8);
  }

  function getInterfaceLabel(
    nodeId: string,
    ip?: string,
  ): { label: string; missing: boolean } {
    if (!ip) return { label: "?", missing: true };

    // Strip port if present (e.g., "192.168.1.1:8080" -> "192.168.1.1")
    const cleanIp =
      ip.includes(":") && !ip.includes("[") ? ip.split(":")[0] : ip;

    // Helper to check a node's interfaces
    function checkNode(node: NodeInfo | undefined): string | null {
      if (!node) return null;

      const matchFromInterfaces = node.network_interfaces?.find((iface) =>
        (iface.addresses || []).some((addr) => addr === cleanIp || addr === ip),
      );
      if (matchFromInterfaces?.name) {
        return matchFromInterfaces.name;
      }

      if (node.ip_to_interface) {
        const mapped =
          node.ip_to_interface[cleanIp] ||
          (ip ? node.ip_to_interface[ip] : undefined);
        if (mapped && mapped.trim().length > 0) {
          return mapped;
        }
      }
      return null;
    }

    // Try specified node first
    const result = checkNode(data?.nodes?.[nodeId]);
    if (result) return { label: result, missing: false };

    // Fallback: search all nodes for this IP
    for (const [, otherNode] of Object.entries(data?.nodes || {})) {
      const otherResult = checkNode(otherNode);
      if (otherResult) return { label: otherResult, missing: false };
    }

    return { label: "?", missing: true };
  }

  function wrapLine(text: string, maxLen: number): string[] {
    if (text.length <= maxLen) return [text];
    const words = text.split(" ");
    const lines: string[] = [];
    let current = "";
    for (const word of words) {
      if (word.length > maxLen) {
        if (current) {
          lines.push(current);
          current = "";
        }
        for (let i = 0; i < word.length; i += maxLen) {
          lines.push(word.slice(i, i + maxLen));
        }
      } else if ((current + " " + word).trim().length > maxLen) {
        lines.push(current);
        current = word;
      } else {
        current = current ? `${current} ${word}` : word;
      }
    }
    if (current) lines.push(current);
    return lines;
  }

  // Apple logo path for MacBook Pro screen
  const APPLE_LOGO_PATH =
    "M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z";
  const LOGO_NATIVE_WIDTH = 814;
  const LOGO_NATIVE_HEIGHT = 1000;

  function formatBytes(bytes: number, decimals = 1): string {
    if (!bytes || bytes === 0) return "0B";
    const k = 1024;
    const sizes = ["B", "KB", "MB", "GB", "TB"];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
  }

  function getTemperatureColor(temp: number): string {
    // Default for N/A temp - light gray
    if (isNaN(temp) || temp === null) return "rgba(179, 179, 179, 0.8)";

    const coolTemp = 45; // Temp for pure blue
    const midTemp = 57.5; // Temp for pure yellow
    const hotTemp = 75; // Temp for pure red

    const coolColor = { r: 93, g: 173, b: 226 }; // #5DADE2 (Blue)
    const midColor = { r: 255, g: 215, b: 0 }; // #FFD700 (Yellow)
    const hotColor = { r: 244, g: 67, b: 54 }; // #F44336 (Red)

    let r: number, g: number, b: number;

    if (temp <= coolTemp) {
      ({ r, g, b } = coolColor);
    } else if (temp <= midTemp) {
      const ratio = (temp - coolTemp) / (midTemp - coolTemp);
      r = Math.round(coolColor.r * (1 - ratio) + midColor.r * ratio);
      g = Math.round(coolColor.g * (1 - ratio) + midColor.g * ratio);
      b = Math.round(coolColor.b * (1 - ratio) + midColor.b * ratio);
    } else if (temp < hotTemp) {
      const ratio = (temp - midTemp) / (hotTemp - midTemp);
      r = Math.round(midColor.r * (1 - ratio) + hotColor.r * ratio);
      g = Math.round(midColor.g * (1 - ratio) + hotColor.g * ratio);
      b = Math.round(midColor.b * (1 - ratio) + hotColor.b * ratio);
    } else {
      ({ r, g, b } = hotColor);
    }

    return `rgb(${r}, ${g}, ${b})`;
  }

  function renderGraph() {
    if (!svgContainer || !data) return;

    d3.select(svgContainer).selectAll("*").remove();

    const nodes = data.nodes || {};
    const edges = data.edges || [];
    const nodeIds = Object.keys(nodes);

    const rect = svgContainer.getBoundingClientRect();
    const width = rect.width;
    const height = rect.height;
    const centerX = width / 2;
    const centerY = height / 2;

    const svg = d3.select(svgContainer);

    // Add defs for clip paths and filters
    const defs = svg.append("defs");

    // Glow filter
    const glowFilter = defs
      .append("filter")
      .attr("id", "glow")
      .attr("x", "-50%")
      .attr("y", "-50%")
      .attr("width", "200%")
      .attr("height", "200%");
    glowFilter
      .append("feGaussianBlur")
      .attr("stdDeviation", "2")
      .attr("result", "coloredBlur");
    const glowMerge = glowFilter.append("feMerge");
    glowMerge.append("feMergeNode").attr("in", "coloredBlur");
    glowMerge.append("feMergeNode").attr("in", "SourceGraphic");

    // Arrowhead marker for directional edges
    const marker = defs
      .append("marker")
      .attr("id", "arrowhead")
      .attr("viewBox", "0 0 10 10")
      .attr("refX", "10")
      .attr("refY", "5")
      .attr("markerWidth", "11")
      .attr("markerHeight", "11")
      .attr("orient", "auto-start-reverse");
    marker
      .append("path")
      .attr("d", "M 0 0 L 10 5 L 0 10")
      .attr("fill", "none")
      .attr("stroke", "var(--exo-light-gray, #B3B3B3)")
      .attr("stroke-width", "1.6")
      .attr("stroke-linecap", "round")
      .attr("stroke-linejoin", "round")
      .style("animation", "none");

    if (nodeIds.length === 0) {
      svg
        .append("text")
        .attr("x", centerX)
        .attr("y", centerY)
        .attr("text-anchor", "middle")
        .attr("dominant-baseline", "middle")
        .attr("fill", "rgba(255,215,0,0.4)")
        .attr("font-size", isMinimized ? 10 : 12)
        .attr("font-family", "SF Mono, monospace")
        .attr("letter-spacing", "0.1em")
        .text("AWAITING NODES");
      return;
    }

    const numNodes = nodeIds.length;
    const minDimension = Math.min(width, height);

    // Dynamic scaling - larger nodes for big displays
    const sizeScale =
      numNodes === 1 ? 1 : Math.max(0.6, 1 - (numNodes - 1) * 0.1);
    const baseNodeRadius = isMinimized
      ? Math.max(36, Math.min(60, minDimension * 0.22))
      : Math.min(120, minDimension * 0.2);
    const nodeRadius = baseNodeRadius * sizeScale;

    // Orbit radius - balanced spacing for nodes
    const circumference = numNodes * nodeRadius * 4;
    const radiusFromCircumference = circumference / (2 * Math.PI);
    const minOrbitRadius = Math.max(
      radiusFromCircumference,
      minDimension * 0.18,
    );
    const maxOrbitRadius = minDimension * 0.3;
    const orbitRadius = isMinimized
      ? Math.min(maxOrbitRadius, Math.max(minOrbitRadius, minDimension * 0.26))
      : Math.min(
          maxOrbitRadius,
          Math.max(minOrbitRadius, minDimension * (0.22 + numNodes * 0.02)),
        );

    // Determine display mode based on space and node count
    const showFullLabels = !isMinimized && numNodes <= 4;
    const showCompactLabels = !isMinimized && numNodes > 4;

    // Add padding for labels (top/bottom)
    const topPadding = 70; // Space for "NETWORK TOPOLOGY" label and node names
    const bottomPadding = 70; // Space for stats and bottom label
    const safeCenterY = topPadding + (height - topPadding - bottomPadding) / 2;

    // Calculate node positions
    const nodesWithPositions = nodeIds.map((id, index) => {
      if (numNodes === 1) {
        // Single node: center it
        return {
          id,
          data: nodes[id],
          x: centerX,
          y: safeCenterY,
        };
      }
      // Distribute nodes around the orbit
      // Start from top (-90 degrees) and go clockwise
      const angle = (index / numNodes) * 2 * Math.PI - Math.PI / 2;
      return {
        id,
        data: nodes[id],
        x: centerX + orbitRadius * Math.cos(angle),
        y: safeCenterY + orbitRadius * Math.sin(angle),
      };
    });

    const positionById: Record<string, { x: number; y: number }> = {};
    nodesWithPositions.forEach((n) => {
      positionById[n.id] = { x: n.x, y: n.y };
    });

    // Draw edges
    const linksGroup = svg.append("g").attr("class", "links-group");
    const arrowsGroup = svg.append("g").attr("class", "arrows-group");
    const debugLabelsGroup = svg.append("g").attr("class", "debug-edge-labels");

    type ConnectionInfo = {
      from: string;
      to: string;
      ip: string;
      ifaceLabel: string;
      missingIface: boolean;
    };
    type PairEntry = {
      a: string;
      b: string;
      aToB: boolean;
      bToA: boolean;
      connections: ConnectionInfo[];
    };
    type DebugEdgeLabelEntry = {
      connections: ConnectionInfo[];
      isLeft: boolean;
      isTop: boolean;
      mx: number;
      my: number;
    };
    const pairMap = new Map<string, PairEntry>();
    const debugEdgeLabels: DebugEdgeLabelEntry[] = [];
    edges.forEach((edge) => {
      if (!edge.source || !edge.target || edge.source === edge.target) return;
      if (!positionById[edge.source] || !positionById[edge.target]) return;

      const a = edge.source < edge.target ? edge.source : edge.target;
      const b = edge.source < edge.target ? edge.target : edge.source;
      const key = `${a}|${b}`;
      const entry = pairMap.get(key) || {
        a,
        b,
        aToB: false,
        bToA: false,
        connections: [],
      };

      if (edge.source === a) entry.aToB = true;
      else entry.bToA = true;

      let ip: string;
      let ifaceLabel: string;
      let missingIface: boolean;

      if (edge.sourceRdmaIface || edge.sinkRdmaIface) {
        ip = "RDMA";
        ifaceLabel = `${edge.sourceRdmaIface || "?"} \u2192 ${edge.sinkRdmaIface || "?"}`;
        missingIface = false;
      } else {
        ip = edge.sendBackIp || "?";
        const ifaceInfo = getInterfaceLabel(edge.source, ip);
        ifaceLabel = ifaceInfo.label;
        missingIface = ifaceInfo.missing;
      }

      entry.connections.push({
        from: edge.source,
        to: edge.target,
        ip,
        ifaceLabel,
        missingIface,
      });
      pairMap.set(key, entry);
    });

    pairMap.forEach((entry) => {
      const posA = positionById[entry.a];
      const posB = positionById[entry.b];
      if (!posA || !posB) return;

      // Base dashed line
      linksGroup
        .append("line")
        .attr("x1", posA.x)
        .attr("y1", posA.y)
        .attr("x2", posB.x)
        .attr("y2", posB.y)
        .attr("class", "graph-link");

      // Calculate midpoint and direction for arrows
      const dx = posB.x - posA.x;
      const dy = posB.y - posA.y;
      const len = Math.hypot(dx, dy) || 1;
      const ux = dx / len;
      const uy = dy / len;
      const mx = (posA.x + posB.x) / 2;
      const my = (posA.y + posB.y) / 2;
      const tipOffset = 16; // Distance from center for arrow tips
      const carrier = 2; // Short segment length for arrow orientation

      // Arrow A -> B (if connection exists in that direction)
      if (entry.aToB) {
        const tipX = mx - ux * tipOffset;
        const tipY = my - uy * tipOffset;
        arrowsGroup
          .append("line")
          .attr("x1", tipX - ux * carrier)
          .attr("y1", tipY - uy * carrier)
          .attr("x2", tipX)
          .attr("y2", tipY)
          .attr("stroke", "none")
          .attr("fill", "none")
          .attr("marker-end", "url(#arrowhead)");
      }

      // Arrow B -> A (if connection exists in that direction)
      if (entry.bToA) {
        const tipX = mx + ux * tipOffset;
        const tipY = my + uy * tipOffset;
        arrowsGroup
          .append("line")
          .attr("x1", tipX + ux * carrier)
          .attr("y1", tipY + uy * carrier)
          .attr("x2", tipX)
          .attr("y2", tipY)
          .attr("stroke", "none")
          .attr("fill", "none")
          .attr("marker-end", "url(#arrowhead)");
      }

      // Collect debug labels for later positioning at edges
      if (debugEnabled && entry.connections.length > 0) {
        // Determine which side of viewport based on edge midpoint
        const isLeft = mx < centerX;
        const isTop = my < safeCenterY;

        // Store for batch rendering after all edges processed
        debugEdgeLabels.push({
          connections: entry.connections,
          isLeft,
          isTop,
          mx,
          my,
        });
      }
    });

    // Render debug labels at viewport edges/corners
    if (debugEdgeLabels && debugEdgeLabels.length > 0) {
      const fontSize = isMinimized ? 10 : 12;
      const lineHeight = fontSize + 4;
      const padding = 10;

      // Helper to get arrow based on direction vector
      function getArrow(fromId: string, toId: string): string {
        const fromPos = positionById[fromId];
        const toPos = positionById[toId];
        if (!fromPos || !toPos) return "→";

        const dirX = toPos.x - fromPos.x;
        const dirY = toPos.y - fromPos.y;
        const absX = Math.abs(dirX);
        const absY = Math.abs(dirY);

        if (absX > absY * 2) {
          return dirX > 0 ? "→" : "←";
        } else if (absY > absX * 2) {
          return dirY > 0 ? "↓" : "↑";
        } else {
          if (dirX > 0 && dirY > 0) return "↘";
          if (dirX > 0 && dirY < 0) return "↗";
          if (dirX < 0 && dirY > 0) return "↙";
          return "↖";
        }
      }

      // Group by quadrant: topLeft, topRight, bottomLeft, bottomRight
      const quadrants: Record<string, DebugEdgeLabelEntry[]> = {
        topLeft: [],
        topRight: [],
        bottomLeft: [],
        bottomRight: [],
      };

      debugEdgeLabels.forEach((edge) => {
        const key =
          (edge.isTop ? "top" : "bottom") + (edge.isLeft ? "Left" : "Right");
        quadrants[key].push(edge);
      });

      // Render each quadrant
      Object.entries(quadrants).forEach(([quadrant, quadrantEdges]) => {
        if (quadrantEdges.length === 0) return;

        const isLeft = quadrant.includes("Left");
        const isTop = quadrant.includes("top");

        let baseX = isLeft ? padding : width - padding;
        let baseY = isTop ? padding : height - padding;
        const textAnchor = isLeft ? "start" : "end";

        let currentY = baseY;

        quadrantEdges.forEach((edge) => {
          edge.connections.forEach((conn) => {
            const arrow = getArrow(conn.from, conn.to);
            const label = `${arrow} ${conn.ip} ${conn.ifaceLabel}`;
            debugLabelsGroup
              .append("text")
              .attr("x", baseX)
              .attr("y", currentY)
              .attr("text-anchor", textAnchor)
              .attr("dominant-baseline", isTop ? "hanging" : "auto")
              .attr("font-size", fontSize)
              .attr("font-family", "SF Mono, monospace")
              .attr(
                "fill",
                conn.missingIface
                  ? "rgba(248,113,113,0.9)"
                  : "rgba(255,255,255,0.85)",
              )
              .text(label);
            currentY += isTop ? lineHeight : -lineHeight;
          });
        });
      });
    }

    // Draw nodes
    const nodesGroup = svg.append("g").attr("class", "nodes-group");

    nodesWithPositions.forEach((nodeInfo) => {
      const node = nodeInfo.data;
      const macmon = node.macmon_info;
      const modelId = node.system_info?.model_id || "Unknown";
      const friendlyName = node.friendly_name || modelId;

      let ramUsagePercent = 0;
      let gpuTemp = NaN;
      let ramTotal = 0;
      let ramUsed = 0;
      let gpuUsagePercent = 0;
      let sysPower: number | null = null;

      if (macmon) {
        if (macmon.memory && macmon.memory.ram_total > 0) {
          ramUsagePercent =
            (macmon.memory.ram_usage / macmon.memory.ram_total) * 100;
          ramTotal = macmon.memory.ram_total;
          ramUsed = macmon.memory.ram_usage;
        }
        if (macmon.temp && typeof macmon.temp.gpu_temp_avg === "number") {
          gpuTemp = Math.max(30, macmon.temp.gpu_temp_avg);
        }
        if (macmon.gpu_usage) {
          gpuUsagePercent = macmon.gpu_usage[1] * 100;
        }
        if (macmon.sys_power) {
          sysPower = macmon.sys_power;
        }
      }

      let iconBaseWidth = nodeRadius * 1.2;
      let iconBaseHeight = nodeRadius * 1.0;
      const clipPathId = `clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;

      const modelLower = modelId.toLowerCase();

      // Check node states for styling
      const isHighlighted = highlightedNodes.has(nodeInfo.id);
      const isInFilter =
        filteredNodes.size > 0 && filteredNodes.has(nodeInfo.id);
      const isFilteredOut =
        filteredNodes.size > 0 && !filteredNodes.has(nodeInfo.id);
      const isHovered = hoveredNodeId === nodeInfo.id && !isInFilter;

      // Holographic wireframe colors - bright yellow for filter, subtle yellow for hover, grey for filtered out
      const wireColor = isInFilter
        ? "rgba(255,215,0,1)" // Bright yellow for filter selection
        : isHovered
          ? "rgba(255,215,0,0.7)" // Subtle yellow for hover
          : isHighlighted
            ? "rgba(255,215,0,0.9)" // Yellow for instance highlight
            : isFilteredOut
              ? "rgba(140,140,140,0.6)" // Grey for filtered out
              : "rgba(179,179,179,0.8)"; // Default
      const wireColorBright = "rgba(255,255,255,0.9)";
      const fillColor = isInFilter
        ? "rgba(255,215,0,0.25)"
        : isHovered
          ? "rgba(255,215,0,0.12)"
          : isHighlighted
            ? "rgba(255,215,0,0.15)"
            : "rgba(255,215,0,0.08)";
      const strokeWidth = isInFilter
        ? 3
        : isHovered
          ? 2
          : isHighlighted
            ? 2.5
            : 1.5;
      const screenFill = "rgba(0,20,40,0.9)";
      const glowColor = "rgba(255,215,0,0.3)";

      const nodeG = nodesGroup
        .append("g")
        .attr("class", "graph-node")
        .style("cursor", onNodeClick ? "pointer" : "default")
        .style("opacity", isFilteredOut ? 0.5 : 1);

      // Add click and hover handlers - hover just updates state, styling is applied during render
      nodeG
        .on("click", (event: MouseEvent) => {
          if (onNodeClick) {
            event.stopPropagation();
            onNodeClick(nodeInfo.id);
          }
        })
        .on("mouseenter", () => {
          if (onNodeClick) {
            hoveredNodeId = nodeInfo.id;
          }
        })
        .on("mouseleave", () => {
          if (hoveredNodeId === nodeInfo.id) {
            hoveredNodeId = null;
          }
        });

      // Add tooltip
      nodeG
        .append("title")
        .text(
          `${friendlyName}\nID: ${nodeInfo.id.slice(-8)}\nMemory: ${formatBytes(ramUsed)}/${formatBytes(ramTotal)}`,
        );

      if (modelLower === "mac studio") {
        // Mac Studio - classic cube with memory fill
        iconBaseWidth = nodeRadius * 1.25;
        iconBaseHeight = nodeRadius * 0.85;
        const x = nodeInfo.x - iconBaseWidth / 2;
        const y = nodeInfo.y - iconBaseHeight / 2;
        const cornerRadius = 4;
        const topSurfaceHeight = iconBaseHeight * 0.15;

        // Create clip path for memory fill area (front body)
        const studioClipId = `studio-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
        defs
          .append("clipPath")
          .attr("id", studioClipId)
          .append("rect")
          .attr("x", x)
          .attr("y", y + topSurfaceHeight)
          .attr("width", iconBaseWidth)
          .attr("height", iconBaseHeight - topSurfaceHeight)
          .attr("rx", cornerRadius - 1);

        // Main body (uniform color)
        nodeG
          .append("rect")
          .attr("class", "node-outline")
          .attr("x", x)
          .attr("y", y)
          .attr("width", iconBaseWidth)
          .attr("height", iconBaseHeight)
          .attr("rx", cornerRadius)
          .attr("fill", "#1a1a1a")
          .attr("stroke", wireColor)
          .attr("stroke-width", strokeWidth);

        // Memory fill (fills from bottom up)
        if (ramUsagePercent > 0) {
          const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
          const memFillActualHeight =
            (ramUsagePercent / 100) * memFillTotalHeight;
          nodeG
            .append("rect")
            .attr("x", x)
            .attr(
              "y",
              y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight),
            )
            .attr("width", iconBaseWidth)
            .attr("height", memFillActualHeight)
            .attr("fill", "rgba(255,215,0,0.75)")
            .attr("clip-path", `url(#${studioClipId})`);
        }

        // Front panel details - vertical slots
        const detailColor = "rgba(0,0,0,0.35)";
        const slotHeight = iconBaseHeight * 0.14;
        const vSlotWidth = iconBaseWidth * 0.05;
        const vSlotY =
          y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.6;
        const vSlot1X = x + iconBaseWidth * 0.18;
        const vSlot2X = x + iconBaseWidth * 0.28;

        [vSlot1X, vSlot2X].forEach((vx) => {
          nodeG
            .append("rect")
            .attr("x", vx - vSlotWidth / 2)
            .attr("y", vSlotY)
            .attr("width", vSlotWidth)
            .attr("height", slotHeight)
            .attr("fill", detailColor)
            .attr("rx", 1.5);
        });

        // Horizontal slot (SD card)
        const hSlotWidth = iconBaseWidth * 0.2;
        const hSlotX = x + iconBaseWidth * 0.5 - hSlotWidth / 2;
        nodeG
          .append("rect")
          .attr("x", hSlotX)
          .attr("y", vSlotY)
          .attr("width", hSlotWidth)
          .attr("height", slotHeight * 0.6)
          .attr("fill", detailColor)
          .attr("rx", 1);
      } else if (modelLower === "mac mini") {
        // Mac Mini - classic flat box with memory fill
        iconBaseWidth = nodeRadius * 1.3;
        iconBaseHeight = nodeRadius * 0.7;
        const x = nodeInfo.x - iconBaseWidth / 2;
        const y = nodeInfo.y - iconBaseHeight / 2;
        const cornerRadius = 3;
        const topSurfaceHeight = iconBaseHeight * 0.2;

        // Create clip path for memory fill area
        const miniClipId = `mini-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
        defs
          .append("clipPath")
          .attr("id", miniClipId)
          .append("rect")
          .attr("x", x)
          .attr("y", y + topSurfaceHeight)
          .attr("width", iconBaseWidth)
          .attr("height", iconBaseHeight - topSurfaceHeight)
          .attr("rx", cornerRadius - 1);

        // Main body (uniform color)
        nodeG
          .append("rect")
          .attr("class", "node-outline")
          .attr("x", x)
          .attr("y", y)
          .attr("width", iconBaseWidth)
          .attr("height", iconBaseHeight)
          .attr("rx", cornerRadius)
          .attr("fill", "#1a1a1a")
          .attr("stroke", wireColor)
          .attr("stroke-width", strokeWidth);

        // Memory fill (fills from bottom up)
        if (ramUsagePercent > 0) {
          const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
          const memFillActualHeight =
            (ramUsagePercent / 100) * memFillTotalHeight;
          nodeG
            .append("rect")
            .attr("x", x)
            .attr(
              "y",
              y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight),
            )
            .attr("width", iconBaseWidth)
            .attr("height", memFillActualHeight)
            .attr("fill", "rgba(255,215,0,0.75)")
            .attr("clip-path", `url(#${miniClipId})`);
        }

        // Front panel details - vertical slots (no horizontal slot for Mini)
        const detailColor = "rgba(0,0,0,0.35)";
        const slotHeight = iconBaseHeight * 0.2;
        const vSlotWidth = iconBaseWidth * 0.045;
        const vSlotY =
          y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.45;
        const vSlot1X = x + iconBaseWidth * 0.2;
        const vSlot2X = x + iconBaseWidth * 0.3;

        [vSlot1X, vSlot2X].forEach((vx) => {
          nodeG
            .append("rect")
            .attr("x", vx - vSlotWidth / 2)
            .attr("y", vSlotY)
            .attr("width", vSlotWidth)
            .attr("height", slotHeight)
            .attr("fill", detailColor)
            .attr("rx", 1.2);
        });
      } else if (
        modelLower === "macbook pro" ||
        modelLower.includes("macbook")
      ) {
        // MacBook Pro - classic style with memory fill on screen
        iconBaseWidth = nodeRadius * 1.6;
        iconBaseHeight = nodeRadius * 1.15;
        const x = nodeInfo.x - iconBaseWidth / 2;
        const y = nodeInfo.y - iconBaseHeight / 2;

        const screenHeight = iconBaseHeight * 0.7;
        const baseHeight = iconBaseHeight * 0.3;
        const screenWidth = iconBaseWidth * 0.85;
        const screenX = nodeInfo.x - screenWidth / 2;
        const screenBezel = 3;

        // Create clip path for screen content
        const screenClipId = `screen-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
        defs
          .append("clipPath")
          .attr("id", screenClipId)
          .append("rect")
          .attr("x", screenX + screenBezel)
          .attr("y", y + screenBezel)
          .attr("width", screenWidth - screenBezel * 2)
          .attr("height", screenHeight - screenBezel * 2)
          .attr("rx", 2);

        // Screen outer frame
        nodeG
          .append("rect")
          .attr("class", "node-outline")
          .attr("x", screenX)
          .attr("y", y)
          .attr("width", screenWidth)
          .attr("height", screenHeight)
          .attr("rx", 3)
          .attr("fill", "#1a1a1a")
          .attr("stroke", wireColor)
          .attr("stroke-width", strokeWidth);

        // Screen inner (dark background)
        nodeG
          .append("rect")
          .attr("x", screenX + screenBezel)
          .attr("y", y + screenBezel)
          .attr("width", screenWidth - screenBezel * 2)
          .attr("height", screenHeight - screenBezel * 2)
          .attr("rx", 2)
          .attr("fill", "#0a0a12");

        // Memory fill on screen (fills from bottom up - classic style)
        if (ramUsagePercent > 0) {
          const memFillTotalHeight = screenHeight - screenBezel * 2;
          const memFillActualHeight =
            (ramUsagePercent / 100) * memFillTotalHeight;
          nodeG
            .append("rect")
            .attr("x", screenX + screenBezel)
            .attr(
              "y",
              y + screenBezel + (memFillTotalHeight - memFillActualHeight),
            )
            .attr("width", screenWidth - screenBezel * 2)
            .attr("height", memFillActualHeight)
            .attr("fill", "rgba(255,215,0,0.85)")
            .attr("clip-path", `url(#${screenClipId})`);
        }

        // Apple logo on screen (centered, on top of memory fill)
        const targetLogoHeight = screenHeight * 0.22;
        const logoScale = targetLogoHeight / LOGO_NATIVE_HEIGHT;
        const logoX = nodeInfo.x - (LOGO_NATIVE_WIDTH * logoScale) / 2;
        const logoY =
          y + screenHeight / 2 - (LOGO_NATIVE_HEIGHT * logoScale) / 2;
        nodeG
          .append("path")
          .attr("d", APPLE_LOGO_PATH)
          .attr(
            "transform",
            `translate(${logoX}, ${logoY}) scale(${logoScale})`,
          )
          .attr("fill", "#FFFFFF")
          .attr("opacity", 0.9);

        // Base (keyboard) - trapezoidal
        const baseY = y + screenHeight;
        const baseTopWidth = screenWidth;
        const baseBottomWidth = iconBaseWidth;
        const baseTopX = nodeInfo.x - baseTopWidth / 2;
        const baseBottomX = nodeInfo.x - baseBottomWidth / 2;

        nodeG
          .append("path")
          .attr(
            "d",
            `M ${baseTopX} ${baseY} L ${baseTopX + baseTopWidth} ${baseY} L ${baseBottomX + baseBottomWidth} ${baseY + baseHeight} L ${baseBottomX} ${baseY + baseHeight} Z`,
          )
          .attr("fill", "#2c2c2c")
          .attr("stroke", wireColor)
          .attr("stroke-width", 1);

        // Keyboard area
        const keyboardX = baseTopX + 6;
        const keyboardY = baseY + 3;
        const keyboardWidth = baseTopWidth - 12;
        const keyboardHeight = baseHeight * 0.55;
        nodeG
          .append("rect")
          .attr("x", keyboardX)
          .attr("y", keyboardY)
          .attr("width", keyboardWidth)
          .attr("height", keyboardHeight)
          .attr("fill", "rgba(0,0,0,0.2)")
          .attr("rx", 2);

        // Trackpad
        const trackpadWidth = baseTopWidth * 0.4;
        const trackpadX = nodeInfo.x - trackpadWidth / 2;
        const trackpadY = baseY + keyboardHeight + 5;
        const trackpadHeight = baseHeight * 0.3;
        nodeG
          .append("rect")
          .attr("x", trackpadX)
          .attr("y", trackpadY)
          .attr("width", trackpadWidth)
          .attr("height", trackpadHeight)
          .attr("fill", "rgba(255,255,255,0.08)")
          .attr("rx", 2);
      } else {
        // Default/Unknown - holographic hexagon
        const hexRadius = nodeRadius * 0.6;
        const hexPoints = Array.from({ length: 6 }, (_, i) => {
          const angle = ((i * 60 - 30) * Math.PI) / 180;
          return `${nodeInfo.x + hexRadius * Math.cos(angle)},${nodeInfo.y + hexRadius * Math.sin(angle)}`;
        }).join(" ");

        // Main shape
        nodeG
          .append("polygon")
          .attr("class", "node-outline")
          .attr("points", hexPoints)
          .attr("fill", fillColor)
          .attr("stroke", wireColor)
          .attr("stroke-width", strokeWidth);
      }

      // --- Vertical GPU Bar (right side of icon) ---
      // Show in both full mode and minimized mode (scaled appropriately)
      if (showFullLabels || isMinimized) {
        const gpuBarWidth = isMinimized
          ? Math.max(16, nodeRadius * 0.32)
          : Math.max(28, nodeRadius * 0.3);
        const gpuBarHeight = iconBaseHeight * 0.95;
        const barXOffset = iconBaseWidth / 2 + (isMinimized ? 5 : 10);
        const gpuBarX = nodeInfo.x + barXOffset;
        const gpuBarY = nodeInfo.y - gpuBarHeight / 2;

        // GPU Bar Background (grey, no border)
        nodeG
          .append("rect")
          .attr("x", gpuBarX)
          .attr("y", gpuBarY)
          .attr("width", gpuBarWidth)
          .attr("height", gpuBarHeight)
          .attr("fill", "rgba(80, 80, 90, 0.7)")
          .attr("rx", 2);

        // GPU Bar Fill (from bottom up, colored by temperature)
        if (gpuUsagePercent > 0) {
          const fillHeight = (gpuUsagePercent / 100) * gpuBarHeight;
          const gpuFillColor = getTemperatureColor(gpuTemp);
          nodeG
            .append("rect")
            .attr("x", gpuBarX)
            .attr("y", gpuBarY + (gpuBarHeight - fillHeight))
            .attr("width", gpuBarWidth)
            .attr("height", fillHeight)
            .attr("fill", gpuFillColor)
            .attr("opacity", 0.9)
            .attr("rx", 2);
        }

        // GPU Stats Text (centered on bar, multiline, bigger and bold)
        const gpuTextX = gpuBarX + gpuBarWidth / 2;
        const gpuTextY = gpuBarY + gpuBarHeight / 2;
        const gpuTextFontSize = isMinimized
          ? Math.max(10, gpuBarWidth * 0.6)
          : Math.min(16, Math.max(12, gpuBarWidth * 0.55));
        const lineSpacing = gpuTextFontSize * 1.25;

        const gpuUsageText = `${gpuUsagePercent.toFixed(0)}%`;
        const tempText = !isNaN(gpuTemp) ? `${gpuTemp.toFixed(0)}°C` : "-";
        const powerText = sysPower !== null ? `${sysPower.toFixed(0)}W` : "-";

        // GPU Usage %
        nodeG
          .append("text")
          .attr("x", gpuTextX)
          .attr("y", gpuTextY - lineSpacing)
          .attr("text-anchor", "middle")
          .attr("dominant-baseline", "middle")
          .attr("fill", "#FFFFFF")
          .attr("font-size", gpuTextFontSize)
          .attr("font-weight", "700")
          .attr("font-family", "SF Mono, Monaco, monospace")
          .text(gpuUsageText);

        // Temperature
        nodeG
          .append("text")
          .attr("x", gpuTextX)
          .attr("y", gpuTextY)
          .attr("text-anchor", "middle")
          .attr("dominant-baseline", "middle")
          .attr("fill", "#FFFFFF")
          .attr("font-size", gpuTextFontSize)
          .attr("font-weight", "700")
          .attr("font-family", "SF Mono, Monaco, monospace")
          .text(tempText);

        // Power (Watts)
        nodeG
          .append("text")
          .attr("x", gpuTextX)
          .attr("y", gpuTextY + lineSpacing)
          .attr("text-anchor", "middle")
          .attr("dominant-baseline", "middle")
          .attr("fill", "#FFFFFF")
          .attr("font-size", gpuTextFontSize)
          .attr("font-weight", "700")
          .attr("font-family", "SF Mono, Monaco, monospace")
          .text(powerText);
      }

      // Labels - adapt based on mode
      if (showFullLabels) {
        // FULL MODE: Name above, memory info below (1-4 nodes)
        const nameY = nodeInfo.y - iconBaseHeight / 2 - 15;
        const fontSize = Math.max(10, nodeRadius * 0.16);

        // Truncate name based on node count
        const maxNameLen =
          numNodes === 1 ? 22 : numNodes === 2 ? 18 : numNodes === 3 ? 16 : 14;
        const displayName =
          friendlyName.length > maxNameLen
            ? friendlyName.slice(0, maxNameLen - 2) + ".."
            : friendlyName;

        // Name label above
        nodeG
          .append("text")
          .attr("x", nodeInfo.x)
          .attr("y", nameY)
          .attr("text-anchor", "middle")
          .attr("dominant-baseline", "middle")
          .attr("fill", "#FFD700")
          .attr("font-size", fontSize)
          .attr("font-weight", 500)
          .attr("font-family", "SF Mono, Monaco, monospace")
          .text(displayName);

        // Memory info below - used in grey, total in yellow
        const infoY = nodeInfo.y + iconBaseHeight / 2 + 16;
        const memText = nodeG
          .append("text")
          .attr("x", nodeInfo.x)
          .attr("y", infoY)
          .attr("text-anchor", "middle")
          .attr("font-size", fontSize * 0.85)
          .attr("font-family", "SF Mono, Monaco, monospace");
        memText
          .append("tspan")
          .attr("fill", "rgba(255,215,0,0.9)")
          .text(`${formatBytes(ramUsed)}`);
        memText
          .append("tspan")
          .attr("fill", "rgba(179,179,179,0.9)")
          .text(`/${formatBytes(ramTotal)}`);
        memText
          .append("tspan")
          .attr("fill", "rgba(179,179,179,0.7)")
          .text(` (${ramUsagePercent.toFixed(0)}%)`);
      } else if (showCompactLabels) {
        // COMPACT MODE: Just name and basic info (4+ nodes)
        const fontSize = Math.max(7, nodeRadius * 0.11);

        // Very compact name below icon
        const nameY = nodeInfo.y + iconBaseHeight / 2 + 9;
        const shortName =
          friendlyName.length > 10
            ? friendlyName.slice(0, 8) + ".."
            : friendlyName;
        nodeG
          .append("text")
          .attr("x", nodeInfo.x)
          .attr("y", nameY)
          .attr("text-anchor", "middle")
          .attr("fill", "#FFD700")
          .attr("font-size", fontSize)
          .attr("font-family", "SF Mono, Monaco, monospace")
          .text(shortName);

        // Single line of key stats
        const statsY = nameY + 9;
        nodeG
          .append("text")
          .attr("x", nodeInfo.x)
          .attr("y", statsY)
          .attr("text-anchor", "middle")
          .attr("fill", "rgba(255,215,0,0.7)")
          .attr("font-size", fontSize * 0.85)
          .attr("font-family", "SF Mono, Monaco, monospace")
          .text(
            `${ramUsagePercent.toFixed(0)}%${!isNaN(gpuTemp) ? " " + gpuTemp.toFixed(0) + "°C" : ""}`,
          );
      } else {
        // MINIMIZED MODE: Show name above and memory info below (like main topology)
        const fontSize = 8;

        // Friendly name (shortened) above icon
        const nameY = nodeInfo.y - iconBaseHeight / 2 - 8;
        const shortName =
          friendlyName.length > 12
            ? friendlyName.slice(0, 10) + ".."
            : friendlyName;
        nodeG
          .append("text")
          .attr("x", nodeInfo.x)
          .attr("y", nameY)
          .attr("text-anchor", "middle")
          .attr("fill", "#FFD700")
          .attr("font-size", fontSize)
          .attr("font-weight", "500")
          .attr("font-family", "SF Mono, Monaco, monospace")
          .text(shortName);

        // Memory info below icon - used in grey, total in yellow (same as main topology)
        const infoY = nodeInfo.y + iconBaseHeight / 2 + 10;
        const memTextMini = nodeG
          .append("text")
          .attr("x", nodeInfo.x)
          .attr("y", infoY)
          .attr("text-anchor", "middle")
          .attr("font-size", fontSize * 0.85)
          .attr("font-family", "SF Mono, Monaco, monospace");
        memTextMini
          .append("tspan")
          .attr("fill", "rgba(255,215,0,0.9)")
          .text(`${formatBytes(ramUsed)}`);
        memTextMini
          .append("tspan")
          .attr("fill", "rgba(179,179,179,0.9)")
          .text(`/${formatBytes(ramTotal)}`);
        memTextMini
          .append("tspan")
          .attr("fill", "rgba(179,179,179,0.7)")
          .text(` (${ramUsagePercent.toFixed(0)}%)`);
      }

      // Debug mode: Show TB bridge and RDMA status
      if (debugEnabled) {
        let debugLabelY =
          nodeInfo.y +
          iconBaseHeight / 2 +
          (showFullLabels ? 32 : showCompactLabels ? 26 : 22);
        const debugFontSize = showFullLabels ? 9 : 7;
        const debugLineHeight = showFullLabels ? 11 : 9;

        const tbStatus = tbBridgeData[nodeInfo.id];
        if (tbStatus) {
          const tbColor = tbStatus.enabled
            ? "rgba(234,179,8,0.9)"
            : "rgba(100,100,100,0.7)";
          const tbText = tbStatus.enabled ? "TB:ON" : "TB:OFF";
          nodeG
            .append("text")
            .attr("x", nodeInfo.x)
            .attr("y", debugLabelY)
            .attr("text-anchor", "middle")
            .attr("fill", tbColor)
            .attr("font-size", debugFontSize)
            .attr("font-family", "SF Mono, Monaco, monospace")
            .text(tbText);
          debugLabelY += debugLineHeight;
        }

        const rdmaStatus = rdmaCtlData[nodeInfo.id];
        if (rdmaStatus !== undefined) {
          const rdmaColor = rdmaStatus.enabled
            ? "rgba(74,222,128,0.9)"
            : "rgba(100,100,100,0.7)";
          const rdmaText = rdmaStatus.enabled ? "RDMA:ON" : "RDMA:OFF";
          nodeG
            .append("text")
            .attr("x", nodeInfo.x)
            .attr("y", debugLabelY)
            .attr("text-anchor", "middle")
            .attr("fill", rdmaColor)
            .attr("font-size", debugFontSize)
            .attr("font-family", "SF Mono, Monaco, monospace")
            .text(rdmaText);
          debugLabelY += debugLineHeight;
        }

        const identity = identitiesData[nodeInfo.id];
        if (identity?.osVersion) {
          nodeG
            .append("text")
            .attr("x", nodeInfo.x)
            .attr("y", debugLabelY)
            .attr("text-anchor", "middle")
            .attr("fill", "rgba(179,179,179,0.7)")
            .attr("font-size", debugFontSize)
            .attr("font-family", "SF Mono, Monaco, monospace")
            .text(
              `macOS ${identity.osVersion}${identity.osBuildVersion ? ` (${identity.osBuildVersion})` : ""}`,
            );
        }
      }
    });
  }

  $effect(() => {
    // Track all reactive dependencies that affect rendering
    const _data = data;
    const _hoveredNodeId = hoveredNodeId;
    const _filteredNodes = filteredNodes;
    const _highlightedNodes = highlightedNodes;
    if (_data) {
      renderGraph();
    }
  });

  onMount(() => {
    if (svgContainer) {
      resizeObserver = new ResizeObserver(() => {
        renderGraph();
      });
      resizeObserver.observe(svgContainer);
    }
  });

  onDestroy(() => {
    resizeObserver?.disconnect();
  });
</script>

<svg bind:this={svgContainer} class="w-full h-full {className}"></svg>

<style>
  :global(.graph-node) {
    /* Only transition opacity for filtered-out nodes, no transition on hover stroke changes */
    transition: opacity 0.2s ease;
  }
  :global(.graph-link) {
    stroke: var(--exo-light-gray, #b3b3b3);
    stroke-width: 1px;
    stroke-dasharray: 4, 4;
    opacity: 0.8;
    animation: flowAnimation 0.75s linear infinite;
  }
  @keyframes flowAnimation {
    from {
      stroke-dashoffset: 0;
    }
    to {
      stroke-dashoffset: -10;
    }
  }
</style>