You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/06/10 03:29:17 UTC
(skywalking-horizon-ui) 12/16: feat(layer): flow-ordered layout + draggable pods for Internal Topology
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 <wu...@foxmail.com>
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,
);