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 328f861  ui: process topology — straight edges, compact honeycomb 
(ui-18), alt labels; edge modal full-width + ESC + mm:ss axis
328f861 is described below

commit 328f861bdc1bc35674d371268447eaba208fbfa6
Author: Wu Sheng <[email protected]>
AuthorDate: Wed May 20 13:09:44 2026 +0800

    ui: process topology — straight edges, compact honeycomb (ui-18), alt
    labels; edge modal full-width + ESC + mm:ss axis
    
    Topology graph:
      - Edges are straight lines now (no bezier arcs / spiral look); a
        reverse-direction edge of a pair shifts onto a parallel track so both
        stay visible. Arrow + animated dashes + protocol pill unchanged.
      - Inside processes pack a compact honeycomb cluster (cells touch),
        matching ui-18, instead of wide spread rows. Bigger cells
        (26–50px) that scale down as the cluster grows.
      - Inside labels alternate BELOW / ABOVE by index (cell 0 below, 1
        above, 2 below, …) so they don't collide in the tight cluster;
        external peers label below. Label font 12px mono / 600, same on
        inside + outside nodes.
    
    Edge dashboard modal:
      - Width now `.dlg.edge-dlg` (beats the base `.dlg` 560px regardless of
        rule order) → 92vw × 92vh; body flex-fills + scrolls. Charts 150px.
      - ESC closes the modal (window keydown).
      - X-axis labels are elapsed mm:ss across the profiling window instead
        of relative `-Nm` markers (which blow up for long tasks). New
        optional `xLabels` prop on TimeChart, fed from the task window +
        series length.
---
 apps/ui/src/components/charts/TimeChart.vue        |   9 +-
 .../layer/profiling/LayerNetworkProfilingView.vue  |  43 +++++++--
 .../src/layer/profiling/ProcessTopologyGraph.vue   | 101 ++++++++++++++-------
 3 files changed, 115 insertions(+), 38 deletions(-)

diff --git a/apps/ui/src/components/charts/TimeChart.vue 
b/apps/ui/src/components/charts/TimeChart.vue
index 9ef8b16..3c835f6 100644
--- a/apps/ui/src/components/charts/TimeChart.vue
+++ b/apps/ui/src/components/charts/TimeChart.vue
@@ -54,6 +54,10 @@ const props = withDefaults(
      *  values to integers (pod counts, replica counts, error counts).
      *  Defaults to the compact-readable rule. */
     format?: 'int' | 'decimal' | 'compact';
+    /** Explicit x-axis bucket labels. When provided (and length-matched)
+     *  these replace the default relative `-Nm` markers — e.g. a caller
+     *  with a known window can pass `mm:ss` elapsed labels. */
+    xLabels?: string[];
   }>(),
   {
     height: 180,
@@ -112,7 +116,10 @@ function buildOption(): echarts.EChartsCoreOption {
   // implied to be MINUTE-stepped over the last 15m), so we label the
   // axis with relative "-Nm" markers.
   const length = props.series[0]?.data.length ?? 0;
-  const xLabels = Array.from({ length }, (_, i) => `-${length - i - 1}m`);
+  const xLabels =
+    props.xLabels && props.xLabels.length === length
+      ? props.xLabels
+      : Array.from({ length }, (_, i) => `-${length - i - 1}m`);
   return {
     backgroundColor: 'transparent',
     tooltip: {
diff --git a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue 
b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
index 1652309..7b330ed 100644
--- a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
@@ -31,7 +31,7 @@
     └──────────────┴─────────────────────────────────────────────┘
 -->
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue';
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
 import { useLayerInstances } from '@/layer/useLayerInstances';
 import { useSelectedService } from '@/layer/useSelectedService';
@@ -225,6 +225,34 @@ const targetProcessName = computed(
   () => (selectedCall.value && nodeById(selectedCall.value.target)?.name) || 
'—',
 );
 
+/** Edge-chart x-axis labels as elapsed `mm:ss` across the profiling
+ *  window — compact and meaningful regardless of how long the task ran
+ *  (relative `-Nm` markers blow up for multi-hour tasks). Derived from
+ *  the metric series length + the task's run window. */
+const edgeXLabels = computed<string[]>(() => {
+  const m = relationMetrics.value;
+  const n = m?.client[0]?.values.length ?? m?.server[0]?.values.length ?? 0;
+  if (n <= 0) return [];
+  const task = currentTask.value;
+  const spanMs =
+    task?.taskStartTime && task.fixedTriggerDuration
+      ? task.fixedTriggerDuration * 1000
+      : windowMinutes.value * 60_000;
+  const stepMs = n > 1 ? spanMs / (n - 1) : spanMs;
+  return Array.from({ length: n }, (_, i) => {
+    const sec = Math.round((i * stepMs) / 1000);
+    const mm = Math.floor(sec / 60);
+    const ss = sec % 60;
+    return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
+  });
+});
+
+function onEdgeKeydown(ev: KeyboardEvent): void {
+  if (ev.key === 'Escape' && selectedCall.value) closeEdge();
+}
+onMounted(() => window.addEventListener('keydown', onEdgeKeydown));
+onBeforeUnmount(() => window.removeEventListener('keydown', onEdgeKeydown));
+
 async function keepAlive(): Promise<void> {
   if (!currentTask.value) return;
   aliveStatus.value = null;
@@ -426,7 +454,8 @@ function fmtTime(ms: number): string {
                 </div>
                 <TimeChart
                   :series="[{ label: m.label, data: m.values, unit: m.unit }]"
-                  :height="120"
+                  :x-labels="edgeXLabels"
+                  :height="150"
                   :unit="m.unit"
                 />
               </div>
@@ -759,9 +788,11 @@ function fmtTime(ms: number): string {
   font-size: 10.5px;
   color: var(--sw-fg-1);
 }
-/* Edge dashboard modal — wide; client | server side-by-side, each side
-   a 2-up widget grid → 4 widgets per row (2 client + 2 server). */
-.edge-dlg { width: 1600px; max-width: 96vw; max-height: 90vh; }
+/* Edge dashboard modal — near-fullscreen; client | server side-by-side,
+   each side a 2-up widget grid → 4 widgets per row (2 client + 2 server). */
+/* `.dlg.edge-dlg` (not `.edge-dlg`) so this beats the base `.dlg`
+ * width:560px regardless of rule order. */
+.dlg.edge-dlg { width: 92vw; max-width: 92vw; height: 92vh; max-height: 92vh; }
 .edge-dlg-title {
   display: flex;
   align-items: center;
@@ -779,7 +810,7 @@ function fmtTime(ms: number): string {
   border-radius: 10px;
   padding: 1px 8px;
 }
-.edge-dlg-body { overflow-y: auto; padding: 12px 14px; }
+.edge-dlg-body { flex: 1 1 0; min-height: 0; overflow-y: auto; padding: 12px 
14px; }
 .edge-cols {
   display: grid;
   grid-template-columns: 1fr 1fr;
diff --git a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue 
b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
index 60d2fb9..1ca3ddd 100644
--- a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
+++ b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
@@ -53,6 +53,32 @@ const host = ref<HTMLDivElement | null>(null);
 const selectedCallId = ref<string | null>(null);
 
 // ── Hex geometry (flat-top) ─────────────────────────────────────────
+const SQRT3 = Math.sqrt(3);
+/** Flat-top axial → pixel. Neighbour centres land √3·r apart, so a cell
+ *  hexagon of radius r tiles (touches) its neighbours — a honeycomb. */
+function axialToPixel(ax: number, ay: number, r: number, o: Pt): Pt {
+  return { x: 1.5 * ax * r + o.x, y: ((SQRT3 / 2) * ax + SQRT3 * ay) * r + o.y 
};
+}
+/** Axial coords in spiral order from the centre — packs a compact
+ *  honeycomb cluster (centre cell, then rings around it). */
+function spiralHex(n: number): Array<{ x: number; y: number }> {
+  const dirs = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]];
+  const out = [{ x: 0, y: 0 }];
+  let k = 1;
+  while (out.length < n) {
+    let cx = dirs[4][0] * k;
+    let cy = dirs[4][1] * k;
+    for (let side = 0; side < 6 && out.length < n + 6; side++) {
+      for (let step = 0; step < k; step++) {
+        out.push({ x: cx, y: cy });
+        cx += dirs[side][0];
+        cy += dirs[side][1];
+      }
+    }
+    k++;
+  }
+  return out.slice(0, n);
+}
 function hexCellPath(cx: number, cy: number, R: number): string {
   const v: [number, number][] = [];
   for (let i = 0; i < 6; i++) {
@@ -80,21 +106,17 @@ function layout(o: Pt, w: number, h: number): 
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);
 
-  // Inside processes: centred LEFT-RIGHT rows (not an arc). Columns bias
-  // slightly wide so the block reads horizontally; the last row is
-  // centred on its own count.
+  // Inside processes: a compact honeycomb cluster (cells touch), packed
+  // spiral-from-centre. Cell size scales down as the cluster grows.
   const n = inside.length;
-  const cols = Math.max(1, Math.min(n, Math.ceil(Math.sqrt(n * 1.7))));
-  const rows = Math.max(1, Math.ceil(n / cols));
-  cellRadius = Math.max(16, Math.min(34, 240 / (Math.max(cols, rows) + 1)));
-  const dx = cellRadius * 1.95;
-  const dy = cellRadius * 2.0;
+  const rings = Math.max(1, Math.ceil((-3 + Math.sqrt(9 + 12 * Math.max(1, n - 
1))) / 6));
+  cellRadius = Math.max(26, Math.min(50, 130 / (rings + 0.5)));
+  const cells = spiralHex(n);
   inside.forEach((node, i) => {
-    const r = Math.floor(i / cols);
-    const c = i % cols;
-    const rowCount = r < rows - 1 ? cols : n - cols * (rows - 1);
-    node.x = o.x + (c - (rowCount - 1) / 2) * dx;
-    node.y = o.y + (r - (rows - 1) / 2) * dy;
+    const c = cells[i] ?? { x: 0, y: 0 };
+    const p = axialToPixel(c.x, c.y, cellRadius, o);
+    node.x = p.x;
+    node.y = p.y;
   });
 
   // External peers spread on a canvas-filling ellipse (skipping the
@@ -143,26 +165,34 @@ function buildCalls(byId: Map<string, PositionedNode>): 
PositionedCall[] {
   }
   return out;
 }
-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 };
+/** Straight-line endpoints. A reverse-direction edge of the same pair is
+ *  shifted onto a parallel track so the two directed lines stay visible
+ *  instead of overlapping. */
+function edgeEnds(c: PositionedCall): { sx: number; sy: number; tx: number; 
ty: number } {
+  let sx = c.source.x;
+  let sy = c.source.y;
+  let tx = c.target.x;
+  let ty = c.target.y;
+  if (c.lowerArc) {
+    const dx = tx - sx;
+    const dy = ty - sy;
+    const len = Math.hypot(dx, dy) || 1;
+    const nx = (-dy / len) * 7;
+    const ny = (dx / len) * 7;
+    sx += nx;
+    sy += ny;
+    tx += nx;
+    ty += ny;
+  }
+  return { sx, sy, tx, ty };
 }
 function edgePath(c: PositionedCall): string {
-  const cp = controlPoint(c.source, c.target, c.lowerArc);
-  return `M ${c.source.x} ${c.source.y} Q ${cp.x} ${cp.y} ${c.target.x} 
${c.target.y}`;
+  const e = edgeEnds(c);
+  return `M ${e.sx} ${e.sy} L ${e.tx} ${e.ty}`;
 }
 function edgeMid(c: PositionedCall): Pt {
-  const cp = controlPoint(c.source, c.target, c.lowerArc);
-  return {
-    x: 0.25 * c.source.x + 0.5 * cp.x + 0.25 * c.target.x,
-    y: 0.25 * c.source.y + 0.5 * cp.y + 0.25 * c.target.y,
-  };
+  const e = edgeEnds(c);
+  return { x: (e.sx + e.tx) / 2, y: (e.sy + e.ty) / 2 };
 }
 
 // ── Node info popover (floating window) ─────────────────────────────
@@ -364,14 +394,23 @@ function render(): void {
     .attr('fill-opacity', (d) => (isInside(d) ? 0.85 : 0.75))
     .attr('stroke', 'var(--sw-bg-0, #0d0f14)')
     .attr('stroke-width', 1.5);
+  // Inside cells touch, so their labels alternate BELOW / ABOVE the
+  // honeycomb by index (cell 0 below, cell 1 above, cell 2 below, …) to
+  // avoid colliding with neighbours. External peers always label below
+  // their (isolated) cell. `positioned` is [inside…, outside…] so the
+  // datum index `i` is the inside index for inside cells.
+  function labelY(d: PositionedNode, i: number): number {
+    if (!isInside(d)) return 30;
+    return i % 2 === 1 ? -(cellRadius + 8) : cellRadius + 16;
+  }
   nodeSel
     .append('text')
     .attr('x', 0)
-    .attr('y', (d) => (isInside(d) ? cellRadius + 11 : 30))
+    .attr('y', labelY)
     .attr('text-anchor', 'middle')
     .attr('fill', 'var(--sw-fg-1, #d4d6dd)')
     .style('font-family', 'var(--sw-mono, monospace)')
-    .style('font-size', '16px')
+    .style('font-size', '12px')
     .style('font-weight', '600')
     .text((d) => {
       const base = d.name.split('.')[0];

Reply via email to