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

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

commit 70cd5827b338f91219bb7ca795f51aaf89e39e4e
Author: Wu Sheng <[email protected]>
AuthorDate: Tue Jun 9 20:07:17 2026 +0800

    feat(layer): flow-ordered layout + draggable pods for Internal Topology
    
    Layout now borrows the service-map's flow ordering, at the pod level:
    - pods within a cluster are topologically ordered (Kahn) over the
      intra-cluster pod call graph, so chains lay out left-to-right (e.g. data
      hot -> warm -> cold) and their edges stop crossing hexes; cycle/leftover
      pods append by name.
    - clusters are ordered upstream->downstream by inter-cluster edge in-degree
      (e.g. liaison left of data, so liaison->data flows rightward).
    
    Pods are draggable: dragging any hex moves the whole pod (main + siblings)
    as a unit via a per-pod delta layered over the computed positions. d3.drag
    (clickDistance 4 so click-select still works); the zoom filter already bows
    out for node targets so dragging never pans. Deltas reset on service change,
    persist across same-structure auto-refresh.
---
 .../LayerServiceInternalTopologyView.vue           | 145 +++++++++++++++++++--
 1 file changed, 134 insertions(+), 11 deletions(-)

diff --git a/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue 
b/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
index 6ed0755..66058d0 100644
--- a/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceInternalTopologyView.vue
@@ -246,21 +246,104 @@ const CLUSTER_GAP_Y = 56;
 const CLUSTER_PAD = 24;
 const HEAD_H = 30;
 const MAX_ROW_W = 1280;
+const MAX_PODS_PER_ROW = 6;
 interface Pos { cx: number; cy: number; r: number }
 interface ClusterRect { key: string | null; label: string; x: number; y: 
number; w: number; h: number; boxed: boolean }
-interface Layout { pos: Map<string, Pos>; rects: ClusterRect[]; w: number; h: 
number }
+interface Layout { pos: Map<string, Pos>; rects: ClusterRect[]; w: number; h: 
number; nodeToPod: Map<string, string> }
+
+const podIdOf = (clusterKey: string | null, siblingKey: string): string => 
`${clusterKey ?? ''}␟${siblingKey}`;
+
+/** Order a cluster's pods upstream→downstream (Kahn topological sort over the
+ *  intra-cluster pod call graph; cycle/leftover pods appended by name). This
+ *  is the service-map's flow ordering applied at the pod level — chains like
+ *  hot→warm→cold lay out left-to-right so their edges don't cross hexes. */
+function topoOrderPods(podIds: string[], edges: Array<[string, string]>, 
nameOf: (id: string) => string): string[] {
+  const idSet = new Set(podIds);
+  const succ = new Map<string, string[]>();
+  const inDeg = new Map<string, number>();
+  for (const id of podIds) { succ.set(id, []); inDeg.set(id, 0); }
+  for (const [s, t] of edges) {
+    if (!idSet.has(s) || !idSet.has(t) || s === t) continue;
+    succ.get(s)!.push(t);
+    inDeg.set(t, (inDeg.get(t) ?? 0) + 1);
+  }
+  const byName = (a: string, b: string): number => 
nameOf(a).localeCompare(nameOf(b));
+  const queue = podIds.filter((id) => (inDeg.get(id) ?? 0) === 0).sort(byName);
+  const out: string[] = [];
+  const seen = new Set<string>();
+  while (queue.length) {
+    const id = queue.shift()!;
+    if (seen.has(id)) continue;
+    seen.add(id);
+    out.push(id);
+    const next = succ.get(id)!.filter((t) => !seen.has(t)).sort(byName);
+    for (const t of next) {
+      inDeg.set(t, (inDeg.get(t) ?? 0) - 1);
+      if ((inDeg.get(t) ?? 0) <= 0) queue.push(t);
+    }
+  }
+  // Cycle-only / leftover pods (never reached) — append deterministically.
+  for (const id of [...podIds].sort(byName)) if (!seen.has(id)) out.push(id);
+  return out;
+}
+
 const layout = computed<Layout>(() => {
   const pos = new Map<string, Pos>();
   const rects: ClusterRect[] = [];
+  const nodeToPod = new Map<string, string>();
   const showBoxes = !!clusterBy.value;
+
+  // node → podId, podId → clusterKey
+  const podCluster = new Map<string, string | null>();
+  for (const cl of clusters.value) {
+    for (const pod of cl.pods) {
+      const pid = podIdOf(cl.key, pod.siblingKey);
+      podCluster.set(pid, cl.key);
+      for (const node of [pod.main, ...pod.siblings]) nodeToPod.set(node.id, 
pid);
+    }
+  }
+  // Directed pod edges (deduped). Intra-cluster ones drive pod flow order;
+  // inter-cluster ones drive cluster order (fewer incoming = more upstream).
+  const intraByCluster = new Map<string, Array<[string, string]>>();
+  const interInDeg = new Map<string, number>();
+  for (const cl of clusters.value) interInDeg.set(cl.key ?? '', 0);
+  const seen = new Set<string>();
+  for (const c of calls.value) {
+    const sp = nodeToPod.get(c.source);
+    const tp = nodeToPod.get(c.target);
+    if (!sp || !tp || sp === tp) continue;
+    const sig = `${sp}>${tp}`;
+    if (seen.has(sig)) continue;
+    seen.add(sig);
+    const sc = podCluster.get(sp) ?? '';
+    const tc = podCluster.get(tp) ?? '';
+    if (sc === tc) {
+      if (!intraByCluster.has(sc)) intraByCluster.set(sc, []);
+      intraByCluster.get(sc)!.push([sp, tp]);
+    } else {
+      interInDeg.set(tc, (interInDeg.get(tc) ?? 0) + 1);
+    }
+  }
+  // Cluster order: upstream (fewest incoming inter-cluster edges) first.
+  const orderedClusters = [...clusters.value].sort((a, b) => {
+    const ia = interInDeg.get(a.key ?? '') ?? 0;
+    const ib = interInDeg.get(b.key ?? '') ?? 0;
+    if (ia !== ib) return ia - ib;
+    if (a.key === null) return 1;
+    if (b.key === null) return -1;
+    return a.key.localeCompare(b.key);
+  });
+
   let cursorX = 0;
   let cursorY = 0;
   let rowMaxH = 0;
   let maxW = 0;
-  for (const cl of clusters.value) {
-    const count = cl.pods.length;
-    const cols = Math.max(1, Math.min(5, Math.ceil(Math.sqrt(count))));
-    const rows = Math.max(1, Math.ceil(count / cols));
+  for (const cl of orderedClusters) {
+    const podById = new Map(cl.pods.map((p) => [podIdOf(cl.key, p.siblingKey), 
p]));
+    const ids = [...podById.keys()];
+    const order = topoOrderPods(ids, intraByCluster.get(cl.key ?? '') ?? [], 
(id) => podById.get(id)!.main.name);
+    const cols = Math.max(1, Math.min(MAX_PODS_PER_ROW, order.length));
+    const rows = Math.max(1, Math.ceil(order.length / cols));
     const boxW = cols * POD_DX + CLUSTER_PAD * 2;
     const headH = showBoxes ? HEAD_H : 0;
     const boxH = rows * POD_DY + CLUSTER_PAD * 2 + headH;
@@ -271,7 +354,8 @@ const layout = computed<Layout>(() => {
     }
     const boxX = cursorX;
     const boxY = cursorY;
-    cl.pods.forEach((pod, i) => {
+    order.forEach((pid, i) => {
+      const pod = podById.get(pid)!;
       const col = i % cols;
       const row = Math.floor(i / cols);
       const cx = boxX + CLUSTER_PAD + col * POD_DX + POD_DX / 2;
@@ -287,9 +371,22 @@ const layout = computed<Layout>(() => {
     rowMaxH = Math.max(rowMaxH, boxH);
     maxW = Math.max(maxW, boxX + boxW);
   }
-  return { pos, rects, w: Math.max(320, maxW), h: Math.max(240, cursorY + 
rowMaxH) };
+  return { pos, rects, w: Math.max(320, maxW), h: Math.max(240, cursorY + 
rowMaxH), nodeToPod };
+});
+const basePos = computed(() => layout.value.pos);
+const nodeToPod = computed(() => layout.value.nodeToPod);
+// Per-pod drag offsets (move a whole pod — main + its siblings — as a unit).
+// Keyed by podId; reset when the selected service changes.
+const podDelta = ref<Map<string, { dx: number; dy: number }>>(new Map());
+const pos = computed<Map<string, Pos>>(() => {
+  if (podDelta.value.size === 0) return basePos.value;
+  const m = new Map<string, Pos>();
+  for (const [id, p] of basePos.value) {
+    const d = podDelta.value.get(nodeToPod.value.get(id) ?? '');
+    m.set(id, d ? { cx: p.cx + d.dx, cy: p.cy + d.dy, r: p.r } : p);
+  }
+  return m;
 });
-const pos = computed(() => layout.value.pos);
 const W = computed(() => layout.value.w);
 const H = computed(() => layout.value.h);
 function posR(id: string): number {
@@ -394,10 +491,36 @@ function installZoom(): void {
   sel.on('dblclick.zoom', null);
   sel.on('dblclick', () => fitToScreen(true));
 }
+// Drag a pod (any hex in it) to reposition the whole pod — main + its
+// siblings move together. The zoom filter bows out for `[data-node-id]`
+// targets, so dragging never pans. d3.drag's event.dx/dy are post-transform
+// (zoom-aware), so they apply straight to the pod delta. Re-bound on every
+// (re)render since Vue recreates the node `<g>` elements.
+function installNodeDrag(): void {
+  if (!zoomLayerEl.value) return;
+  const sel = d3.select(zoomLayerEl.value).selectAll<SVGGElement, 
unknown>('g.sit-node');
+  sel.on('.drag', null);
+  sel.call(
+    d3
+      .drag<SVGGElement, unknown>()
+      .clickDistance(4)
+      .on('start', (event) => { (event.sourceEvent as 
MouseEvent).stopPropagation(); })
+      .on('drag', function (event) {
+        const id = (this as SVGGElement).getAttribute('data-node-id');
+        if (!id) return;
+        const pid = nodeToPod.value.get(id);
+        if (!pid) return;
+        const cur = podDelta.value.get(pid) ?? { dx: 0, dy: 0 };
+        const m = new Map(podDelta.value);
+        m.set(pid, { dx: cur.dx + event.dx, dy: cur.dy + event.dy });
+        podDelta.value = m;
+      }),
+  );
+}
 function installZoomAndFit(): void {
   if (!svgEl.value || !zoomLayerEl.value) return;
   installZoom();
-  void nextTick(() => fitToScreen(false));
+  void nextTick(() => { installNodeDrag(); fitToScreen(false); });
 }
 // The <svg> lives behind a v-else and unmounts whenever a new service's
 // data is in flight, then remounts when it lands — so re-bind zoom on every
@@ -406,7 +529,7 @@ function installZoomAndFit(): void {
 watch(svgEl, (el) => { if (el && zoomLayerEl.value) installZoomAndFit(); }, { 
flush: 'post' });
 watch(
   () => 
`${nodes.value.length}|${visibleCalls.value.length}|${clusters.value.length}`,
-  () => { if (svgEl.value && zoomBehaviour) void nextTick(() => 
fitToScreen(false)); },
+  () => { if (svgEl.value && zoomBehaviour) void nextTick(() => { 
installNodeDrag(); fitToScreen(false); }); },
 );
 
 // ── Selection (edge → sidebar, node → popover). Reset on service change.
@@ -420,7 +543,7 @@ function selectNode(id: string): void {
   selectedCallId.value = null;
   popoverNodeId.value = popoverNodeId.value === id ? null : id;
 }
-watch(selectedId, () => { selectedCallId.value = null; popoverNodeId.value = 
null; });
+watch(selectedId, () => { selectedCallId.value = null; popoverNodeId.value = 
null; podDelta.value = new Map(); });
 const selectedCall = computed<ServiceInternalTopologyCall | null>(
   () => calls.value.find((c) => c.id === selectedCallId.value) ?? null,
 );

Reply via email to