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, );
