This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new e6b2c8b  ui: network-profiling topology — booster-style hexagon layout
e6b2c8b is described below

commit e6b2c8b15f1a31d3f0d42b7ac2fd54954dc4fde9
Author: Wu Sheng <[email protected]>
AuthorDate: Wed May 20 10:24:56 2026 +0800

    ui: network-profiling topology — booster-style hexagon layout
    
    Replaced the d3-force process graph with the booster-ui hexagon layout
    that ui-17.png shows. The force sim gave drifting positions, weak edge
    affordances, and no pod-boundary semantics; this is the process-topology
    vocabulary operators expect.
    
      - Hexagon boundary represents the profiled pod/instance. Processes
        inside the pod (`isReal` or synthetic `UNKNOWN_LOCAL`) lay out on a
        flat-top hex grid inside it; external peers sit on concentric rings
        outside (bottom 230°–310° arc kept clear for the instance label).
        Boundary radius is fixed; inner cells thin out by node count
        (≤7 macro cells, else sub-clustered + shuffled) so dense pods stay
        readable. Ported hexGrid / axialToPixel / circlePoints / boundary
        from booster's Graph/layout.ts.
      - Each node is an isometric cube glyph (top + two shaded side faces)
        standing in for a container/process; inside nodes accent-tinted,
        external nodes grey. Label truncates under the cube.
      - Positions are computed (stable), not force-driven; nodes are
        draggable (edges + pills follow live).
      - Edges are directed dashed quadratic-bezier curves with an arrow
        marker; reverse edges of a pair arc the opposite way. Clickable
        (path + protocol pill) → emits select-call for the edge detail
        panel. Selected edge highlights accent. Protocol pill (TCP / HTTP /
        HTTPS / TLS) derived from source+target components, at the bezier
        midpoint. Pan/zoom retained; click empty canvas clears selection.
---
 .../src/layer/profiling/ProcessTopologyGraph.vue   | 496 ++++++++++++++-------
 1 file changed, 324 insertions(+), 172 deletions(-)

diff --git a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue 
b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
index d3589b9..a03c505 100644
--- a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
+++ b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
@@ -15,36 +15,35 @@
   limitations under the License.
 -->
 <!--
-  Process-level topology for network profiling. Renders the
-  `getProcessTopology` payload as a d3-force-directed graph with:
-    - real processes drawn as filled circles
-    - virtual peers (`isReal: false`) drawn as hollow triangles
-    - directional curved edges with detect-point pills
-    - drag to reposition, click to select
+  Process-level topology for network profiling — booster-ui hexagon
+  layout. The hexagon boundary represents the profiled service instance
+  (pod): processes that live INSIDE the pod (`isReal` or the synthetic
+  `UNKNOWN_LOCAL`) are laid out on a hex grid inside the boundary;
+  external peers (other pods / services the eBPF probe saw traffic to)
+  sit on concentric rings OUTSIDE it. Each node is an isometric "cube"
+  glyph standing in for a container/process. Positions are computed
+  (not force-simulated) so the map is stable; nodes are draggable.
+  Edges are directed quadratic-bezier curves with a protocol pill and
+  are clickable (drives the edge detail panel).
 
   Emits:
-    select-node — full ProcessNode object
-    select-call — full ProcessCall object (with attached source/target)
+    select-node — full ProcessNode object (or null)
+    select-call — full ProcessCall object (or null)
 -->
 <script setup lang="ts">
-import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
 import * as d3 from 'd3';
 import type { ProcessCall, ProcessNode } from '@/api/client';
 
-interface SimNode extends ProcessNode {
-  x?: number;
-  y?: number;
-  vx?: number;
-  vy?: number;
-  fx?: number | null;
-  fy?: number | null;
-}
-interface SimLink {
+interface Pt { x: number; y: number }
+type PositionedNode = ProcessNode & Pt;
+interface PositionedCall {
   id: string;
-  source: SimNode;
-  target: SimNode;
+  source: PositionedNode;
+  target: PositionedNode;
   detectPoints: string[];
-  arcOffset: number;
+  protocol: string;
+  lowerArc: boolean;
 }
 
 const props = defineProps<{ nodes: ProcessNode[]; calls: ProcessCall[] }>();
@@ -57,59 +56,260 @@ const host = ref<HTMLDivElement | null>(null);
 const selectedNodeId = ref<string | null>(null);
 const selectedCallId = ref<string | null>(null);
 
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-let simulation: any = null;
+// ── Hexagon layout primitives (flat-top orientation), ported from
+//    booster-ui's network-profiling Graph/layout.ts. ──────────────────
+const SQRT3 = Math.sqrt(3);
+const HEX_RADIUS = 210; // pod boundary radius
+
+/** Flat-top axial → pixel. */
+function axialToPixel(ax: number, ay: number, r: number, origin: Pt): Pt {
+  return {
+    x: ((3 / 2) * ax) * r + origin.x,
+    y: ((SQRT3 / 2) * ax + SQRT3 * ay) * r + origin.y,
+  };
+}
+/** Hex grid of ring-count `n` at the given spacing radius. n=1 ⇒ 7 cells. */
+function hexGrid(n: number, r: number, origin: Pt): Pt[] {
+  const pos: Pt[] = [];
+  for (let x = -n; x <= n; x++) {
+    const yLo = Math.max(-n, -x - n);
+    const yHi = Math.min(n, -x + n);
+    for (let y = yLo; y <= yHi; y++) pos.push(axialToPixel(x, y, r, origin));
+  }
+  return pos;
+}
+/** Points around a circle, skipping the 230°–310° arc so the bottom
+ *  stays clear for the instance label (matches booster). */
+function circlePoints(r: number, stepDeg: number, origin: Pt): Pt[] {
+  const out: Pt[] = [];
+  for (let deg = 0; deg < 360; deg += stepDeg) {
+    if (deg >= 230 && deg <= 310) continue;
+    const rad = (Math.PI * 2 * deg) / 360;
+    out.push({ x: Math.cos(rad) * r + origin.x, y: Math.sin(rad) * r + 
origin.y });
+  }
+  return out;
+}
+/** Closed hexagon boundary path string. */
+function hexBoundaryPath(r: number, origin: Pt): string {
+  const verts: [number, number][] = [];
+  for (let i = 0; i < 6; i++) {
+    const rad = Math.PI * 2 * (i / 6);
+    verts.push([Math.cos(rad) * r + origin.x, Math.sin(rad) * r + origin.y]);
+  }
+  const line = d3.line().curve(d3.curveLinearClosed);
+  return line(verts) ?? '';
+}
+function shuffle<T>(a: T[]): T[] {
+  for (let i = a.length - 1; i > 0; i--) {
+    const j = Math.floor(Math.random() * (i + 1));
+    [a[i], a[j]] = [a[j], a[i]];
+  }
+  return a;
+}
+
+function isInside(n: ProcessNode): boolean {
+  return n.isReal || n.name === 'UNKNOWN_LOCAL';
+}
+function protocolOf(c: ProcessCall): string {
+  const types = [...(c.sourceComponents ?? []), ...(c.targetComponents ?? 
[])].map((t) =>
+    t.toLowerCase(),
+  );
+  if (types.includes('https')) return 'HTTPS';
+  if (types.includes('tls')) return 'TLS';
+  if (types.includes('http')) return 'HTTP';
+  return 'TCP';
+}
+
+/** Compute fixed positions for inside (hex grid) + outside (rings). */
+function layout(origin: Pt): PositionedNode[] {
+  const inside = props.nodes.filter(isInside).map((n) => ({ ...n }) as 
PositionedNode);
+  const outside = props.nodes.filter((n) => !isInside(n)).map((n) => ({ ...n 
}) as PositionedNode);
+
+  const count = inside.length;
+  let cells: Pt[];
+  if (count > 7) {
+    // Sub-divide each of the 7 macro-cells into a small cluster, then
+    // thin the cluster by total count so dense pods stay readable.
+    const macro = hexGrid(1, 68, origin);
+    const cubes: Pt[] = [];
+    for (const c of macro) {
+      let sub = hexGrid(1, 20, c);
+      if (count < 15) sub = [sub[0], sub[5]];
+      else if (count < 22) sub = [sub[0], sub[2], sub[5]];
+      cubes.push(...sub);
+    }
+    cells = shuffle(cubes);
+  } else {
+    cells = hexGrid(1, 68, origin);
+  }
+  inside.forEach((n, i) => {
+    const p = cells[i] ?? origin;
+    n.x = p.x;
+    n.y = p.y;
+  });
 
-function buildLinks(): SimLink[] {
-  const seen = new Map<string, number>();
-  const links: SimLink[] = [];
-  const idx = new Map<string, SimNode>();
-  for (const n of props.nodes) idx.set(n.id, n as SimNode);
+  // Outside peers on expanding rings.
+  let r = 250;
+  let ring = circlePoints(r, 30, origin);
+  outside.forEach((n, i) => {
+    if (!ring[i]) {
+      r += 80;
+      ring = [...ring, ...circlePoints(r, 30, origin)];
+    }
+    const p = ring[i] ?? { x: origin.x, y: origin.y - r };
+    n.x = p.x;
+    n.y = p.y;
+  });
+
+  return [...inside, ...outside];
+}
+
+function buildCalls(byId: Map<string, PositionedNode>): PositionedCall[] {
+  const seen = new Set<string>();
+  const out: PositionedCall[] = [];
   for (const c of props.calls) {
-    const src = idx.get(c.source);
-    const tgt = idx.get(c.target);
-    if (!src || !tgt) continue;
-    const pairKey = [c.source, c.target].sort().join('|');
-    const offset = seen.get(pairKey) ?? 0;
-    seen.set(pairKey, offset + 1);
-    links.push({
+    const s = byId.get(c.source);
+    const t = byId.get(c.target);
+    if (!s || !t) continue;
+    // Reverse direction of an already-seen pair arcs the opposite way
+    // so the two directed edges don't overlap.
+    const fwd = `${c.source}|${c.target}`;
+    const rev = `${c.target}|${c.source}`;
+    const lowerArc = seen.has(rev);
+    seen.add(fwd);
+    out.push({
       id: c.id,
-      source: src,
-      target: tgt,
+      source: s,
+      target: t,
       detectPoints: c.detectPoints ?? [],
-      arcOffset: offset,
+      protocol: protocolOf(c),
+      lowerArc,
     });
   }
-  return links;
+  return out;
+}
+
+// Quadratic-bezier control point perpendicular to the s→t chord.
+function controlPoint(s: Pt, t: Pt, lowerArc: boolean): Pt {
+  const dx = t.x - s.x;
+  const dy = t.y - s.y;
+  const theta = Math.atan2(dy, dx) - Math.PI / 2;
+  const len = (Math.sqrt(dx * dx + dy * dy) / 2) * 0.5;
+  const cx = (s.x + t.x) / 2 + len * Math.cos(theta);
+  let cy = (s.y + t.y) / 2 + len * Math.sin(theta);
+  if (lowerArc) cy = (s.y + t.y) - cy;
+  return { x: cx, y: cy };
+}
+function edgePath(c: PositionedCall): string {
+  const s = { x: c.source.x, y: c.source.y };
+  const t = { x: c.target.x, y: c.target.y };
+  const cp = controlPoint(s, t, c.lowerArc);
+  return `M ${s.x} ${s.y} Q ${cp.x} ${cp.y} ${t.x} ${t.y}`;
+}
+function edgeMid(c: PositionedCall): Pt {
+  const s = { x: c.source.x, y: c.source.y };
+  const t = { x: c.target.x, y: c.target.y };
+  const cp = controlPoint(s, t, c.lowerArc);
+  // midpoint of the quadratic bezier at t=0.5
+  return {
+    x: 0.25 * s.x + 0.5 * cp.x + 0.25 * t.x,
+    y: 0.25 * s.y + 0.5 * cp.y + 0.25 * t.y,
+  };
+}
+
+// ── Cube glyph — isometric box drawn around (0,0). ──────────────────
+function appendCube(g: d3.Selection<SVGGElement, unknown, null, undefined>, 
inside: boolean): void {
+  const w = 13; // half width
+  const h = 7; // top-face half height
+  const d = 14; // body depth
+  const topFill = inside ? 'var(--sw-accent, #f97316)' : 'var(--sw-bg-3, 
#2a2d36)';
+  const leftFill = inside ? 'var(--sw-accent-2, #c2570f)' : 'var(--sw-bg-2, 
#1f2129)';
+  const rightFill = inside ? '#8a3d0a' : 'var(--sw-bg-1, #15171c)';
+  const stroke = 'var(--sw-line-2, #3a3d47)';
+  // top rhombus
+  g.append('path')
+    .attr('d', `M 0 ${-h - d / 2} L ${w} ${-d / 2} L 0 ${h - d / 2} L ${-w} 
${-d / 2} Z`)
+    .attr('fill', topFill)
+    .attr('stroke', stroke)
+    .attr('stroke-width', 1);
+  // left face
+  g.append('path')
+    .attr('d', `M ${-w} ${-d / 2} L 0 ${h - d / 2} L 0 ${h + d / 2} L ${-w} 
${d / 2} Z`)
+    .attr('fill', leftFill)
+    .attr('stroke', stroke)
+    .attr('stroke-width', 1);
+  // right face
+  g.append('path')
+    .attr('d', `M ${w} ${-d / 2} L 0 ${h - d / 2} L 0 ${h + d / 2} L ${w} ${d 
/ 2} Z`)
+    .attr('fill', rightFill)
+    .attr('stroke', stroke)
+    .attr('stroke-width', 1);
+}
+
+let positioned: PositionedNode[] = [];
+let edgeSel: d3.Selection<SVGPathElement, PositionedCall, SVGGElement, 
unknown> | null = null;
+let pillSel: d3.Selection<SVGGElement, PositionedCall, SVGGElement, unknown> | 
null = null;
+let nodeSel: d3.Selection<SVGGElement, PositionedNode, SVGGElement, unknown> | 
null = null;
+
+function instanceLabel(): string {
+  const inside = props.nodes.find(isInside);
+  return inside?.serviceInstanceName ?? '';
+}
+
+function restyleEdges(): void {
+  edgeSel
+    ?.attr('stroke', (d) =>
+      d.id === selectedCallId.value ? 'var(--sw-accent, #f97316)' : 
'var(--sw-line-2, #3a3d47)',
+    )
+    .attr('stroke-width', (d) => (d.id === selectedCallId.value ? 2.4 : 1.4));
+}
+
+function refreshPositions(): void {
+  edgeSel?.attr('d', edgePath);
+  pillSel?.attr('transform', (d) => {
+    const m = edgeMid(d);
+    return `translate(${m.x},${m.y})`;
+  });
+  nodeSel?.attr('transform', (d) => `translate(${d.x},${d.y})`);
 }
 
 function render(): void {
   if (!host.value) return;
   host.value.innerHTML = '';
-  const w = host.value.getBoundingClientRect().width || 640;
-  const h = host.value.getBoundingClientRect().height || 480;
+  const rect = host.value.getBoundingClientRect();
+  const w = rect.width || 720;
+  const h = rect.height || 520;
+  const origin: Pt = { x: 0, y: 0 };
 
-  const svg = d3
-    .select(host.value)
-    .append('svg')
-    .attr('width', w)
-    .attr('height', h);
+  positioned = layout(origin);
+  const byId = new Map(positioned.map((n) => [n.id, n]));
+  const calls = buildCalls(byId);
 
-  // Pan/zoom group
-  const g = svg.append('g').attr('class', 'g-root');
+  const svg = d3.select(host.value).append('svg').attr('width', 
w).attr('height', h);
+  // Root group is centred; zoom/pan rides on top.
+  const root = svg.append('g').attr('transform', `translate(${w / 2},${h / 
2})`);
+  const g = root.append('g');
   svg.call(
     d3
       .zoom<SVGSVGElement, unknown>()
-      .scaleExtent([0.3, 4])
+      .scaleExtent([0.3, 3])
       .on('zoom', (ev) => g.attr('transform', ev.transform.toString())) as 
never,
   );
-  // Arrow marker
+  // Click empty canvas → clear selection.
+  svg.on('click', () => {
+    selectedNodeId.value = null;
+    selectedCallId.value = null;
+    restyleEdges();
+    emit('select-node', null);
+    emit('select-call', null);
+  });
+
   svg
     .append('defs')
     .append('marker')
     .attr('id', 'arrow-pn')
     .attr('viewBox', '0 -5 10 10')
-    .attr('refX', 16)
+    .attr('refX', 18)
     .attr('refY', 0)
     .attr('markerWidth', 6)
     .attr('markerHeight', 6)
@@ -118,172 +318,123 @@ function render(): void {
     .attr('d', 'M0,-5 L10,0 L0,5')
     .attr('fill', 'var(--sw-fg-3, #6c7080)');
 
-  const nodes = props.nodes.map((n) => ({ ...(n as SimNode) }));
-  const links = buildLinks();
-  // Re-target SimLinks against the mutable nodes (forceLink mutates them).
-  const byId = new Map(nodes.map((n) => [n.id, n]));
-  for (const l of links) {
-    l.source = byId.get(l.source.id)!;
-    l.target = byId.get(l.target.id)!;
-  }
-
-  simulation = d3
-    .forceSimulation<SimNode>(nodes)
-    .force(
-      'link',
-      d3
-        .forceLink<SimNode, SimLink>(links)
-        .id((d) => d.id)
-        .distance(120)
-        .strength(0.6),
-    )
-    .force('charge', d3.forceManyBody().strength(-300))
-    .force('center', d3.forceCenter(w / 2, h / 2))
-    .force('collide', d3.forceCollide<SimNode>().radius(28));
+  // Hexagon boundary + instance label.
+  g.append('path')
+    .attr('d', hexBoundaryPath(HEX_RADIUS, origin))
+    .attr('fill', 'var(--sw-bg-1, #15171c)')
+    .attr('fill-opacity', 0.35)
+    .attr('stroke', 'var(--sw-line, #2a2d36)')
+    .attr('stroke-width', 1.5);
+  g.append('text')
+    .attr('x', origin.x)
+    .attr('y', origin.y + HEX_RADIUS + 18)
+    .attr('text-anchor', 'middle')
+    .attr('fill', 'var(--sw-fg-2, #b4b7c2)')
+    .style('font-family', 'var(--sw-mono, monospace)')
+    .style('font-size', '11px')
+    .text(instanceLabel());
 
-  // Edges
+  // Edges.
   const linkG = g.append('g').attr('class', 'links');
-  const edge = linkG
-    .selectAll('path.edge')
-    .data(links)
+  edgeSel = linkG
+    .selectAll<SVGPathElement, PositionedCall>('path.edge')
+    .data(calls)
     .enter()
     .append('path')
     .attr('class', 'edge')
     .attr('fill', 'none')
-    .attr('stroke', 'var(--sw-line-2, #3a3d47)')
-    .attr('stroke-width', 1.4)
+    .attr('stroke-dasharray', '5 4')
     .attr('marker-end', 'url(#arrow-pn)')
     .style('cursor', 'pointer')
-    .on('click', (_ev, d) => {
+    .on('click', (ev, d) => {
+      ev.stopPropagation();
       selectedCallId.value = d.id;
       selectedNodeId.value = null;
       restyleEdges();
-      emit(
-        'select-call',
-        props.calls.find((c) => c.id === d.id) ?? null,
-      );
+      emit('select-call', props.calls.find((c) => c.id === d.id) ?? null);
     });
 
-  // Highlight the selected edge (accent + thicker) so the operator sees
-  // which conversation the detail panel is bound to.
-  function restyleEdges(): void {
-    edge
-      .attr('stroke', (d) =>
-        d.id === selectedCallId.value ? 'var(--sw-accent, #f97316)' : 
'var(--sw-line-2, #3a3d47)',
-      )
-      .attr('stroke-width', (d) => (d.id === selectedCallId.value ? 2.4 : 
1.4));
-  }
-  // Detect-point pills
-  const pills = linkG
-    .selectAll('g.pill')
-    .data(links.filter((l) => l.detectPoints.length))
+  // Protocol pills at edge midpoints.
+  pillSel = linkG
+    .selectAll<SVGGElement, PositionedCall>('g.pill')
+    .data(calls)
     .enter()
     .append('g')
-    .attr('class', 'pill');
-  pills
+    .attr('class', 'pill')
+    .style('cursor', 'pointer')
+    .on('click', (ev, d) => {
+      ev.stopPropagation();
+      selectedCallId.value = d.id;
+      selectedNodeId.value = null;
+      restyleEdges();
+      emit('select-call', props.calls.find((c) => c.id === d.id) ?? null);
+    });
+  pillSel
     .append('rect')
-    .attr('x', -22)
+    .attr('x', -19)
     .attr('y', -7)
-    .attr('width', 44)
+    .attr('width', 38)
     .attr('height', 14)
     .attr('rx', 7)
     .attr('fill', 'var(--sw-bg-2, #1f2129)')
     .attr('stroke', 'var(--sw-line, #2a2d36)');
-  pills
+  pillSel
     .append('text')
     .attr('text-anchor', 'middle')
     .attr('dy', '0.32em')
     .attr('fill', 'var(--sw-fg-2, #b4b7c2)')
     .style('font-family', 'var(--sw-mono, monospace)')
-    .style('font-size', '9.5px')
-    .text((d) => d.detectPoints.join(','));
-
-  // Nodes
-  const nodeG = g.append('g').attr('class', 'nodes');
-  const node = nodeG
-    .selectAll<SVGGElement, SimNode>('g.node')
-    .data(nodes, (d) => d.id)
+    .style('font-size', '9px')
+    .text((d) => d.protocol);
+
+  // Nodes.
+  nodeSel = g
+    .append('g')
+    .attr('class', 'nodes')
+    .selectAll<SVGGElement, PositionedNode>('g.node')
+    .data(positioned, (d) => d.id)
     .enter()
     .append('g')
     .attr('class', 'node')
     .style('cursor', 'grab')
-    .on('click', (_ev, d) => {
+    .on('click', (ev, d) => {
+      ev.stopPropagation();
       selectedNodeId.value = d.id;
       selectedCallId.value = null;
       restyleEdges();
-      emit(
-        'select-node',
-        props.nodes.find((n) => n.id === d.id) ?? null,
-      );
+      emit('select-node', props.nodes.find((n) => n.id === d.id) ?? null);
     })
     .call(
       d3
-        .drag<SVGGElement, SimNode>()
-        .on('start', (event, d) => {
-          if (!event.active) simulation.alphaTarget(0.3).restart();
-          d.fx = d.x;
-          d.fy = d.y;
-        })
-        .on('drag', (event, d) => {
-          d.fx = event.x;
-          d.fy = event.y;
-        })
-        .on('end', (event, d) => {
-          if (!event.active) simulation.alphaTarget(0);
-          d.fx = null;
-          d.fy = null;
+        .drag<SVGGElement, PositionedNode>()
+        .on('drag', (ev, d) => {
+          d.x = ev.x;
+          d.y = ev.y;
+          refreshPositions();
         }) as never,
     );
-  // Real process — circle. Virtual peer — triangle.
-  node
-    .filter((d) => d.isReal)
-    .append('circle')
-    .attr('r', 9)
-    .attr('fill', 'var(--sw-accent, #f97316)')
-    .attr('stroke', 'var(--sw-bg-1, #15171c)')
-    .attr('stroke-width', 1.5);
-  node
-    .filter((d) => !d.isReal)
-    .append('path')
-    .attr('d', 'M-9,8 L9,8 L0,-8 Z')
-    .attr('fill', 'var(--sw-bg-2, #1f2129)')
-    .attr('stroke', 'var(--sw-line-2, #3a3d47)')
-    .attr('stroke-width', 1.4);
-  node
+  nodeSel.each(function (d) {
+    appendCube(d3.select(this), isInside(d));
+  });
+  nodeSel
     .append('text')
-    .attr('x', 14)
-    .attr('dy', '0.32em')
+    .attr('x', 0)
+    .attr('y', 22)
+    .attr('text-anchor', 'middle')
     .attr('fill', 'var(--sw-fg-1, #d4d6dd)')
     .style('font-family', 'var(--sw-mono, monospace)')
-    .style('font-size', '11px')
-    .text((d) => d.name);
-
-  simulation.on('tick', () => {
-    edge.attr('d', (d) => {
-      const sx = d.source.x ?? 0;
-      const sy = d.source.y ?? 0;
-      const tx = d.target.x ?? 0;
-      const ty = d.target.y ?? 0;
-      const dx = tx - sx;
-      const dy = ty - sy;
-      const dist = Math.sqrt(dx * dx + dy * dy) || 1;
-      // Curve more for each repeat of the (s,t) pair so directed
-      // reverse edges don't overlap.
-      const curve = d.arcOffset * 30 + (d.source.id > d.target.id ? 18 : 0);
-      return `M ${sx} ${sy} A ${dist + curve} ${dist + curve} 0 0 1 ${tx} 
${ty}`;
-    });
-    pills.attr('transform', (d) => {
-      const mx = ((d.source.x ?? 0) + (d.target.x ?? 0)) / 2;
-      const my = ((d.source.y ?? 0) + (d.target.y ?? 0)) / 2;
-      return `translate(${mx},${my})`;
-    });
-    node.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`);
-  });
+    .style('font-size', '10.5px')
+    .text((d) => (d.name.length > 16 ? `${d.name.slice(0, 16)}…` : d.name));
+
+  refreshPositions();
+  restyleEdges();
 }
 
 onMounted(render);
 watch(() => [props.nodes, props.calls], render);
-onBeforeUnmount(() => simulation?.stop());
+onBeforeUnmount(() => {
+  if (host.value) host.value.innerHTML = '';
+});
 </script>
 
 <template>
@@ -294,6 +445,7 @@ onBeforeUnmount(() => simulation?.stop());
 .topo-host {
   width: 100%;
   height: 100%;
+  min-height: 360px;
   overflow: hidden;
   background: var(--sw-bg-0, #0d0f14);
 }

Reply via email to