This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch fix/3d-fps-and-layer-rehydrate in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 4fe7d02b6467ed7bdda7a5f7af3e601f354f8a57 Author: Wu Sheng <[email protected]> AuthorDate: Sun May 31 10:02:26 2026 +0800 feat(layer): zoom/pan + fit-to-view for the API dependency graph - The graph fits-to-view by default (viewBox + preserveAspectRatio), so every column is visible — the 'extra line' was an edge to a node clipped off the old horizontal-scroll viewport; fit reveals its real target. - Wheel + drag-pan + +/−/fit buttons (toolbar over the canvas, not the header). - Clip node text to the box interior (clipPath) + tighter truncation so long endpoint names no longer overflow the boundary. - Drop the L0/L+1 column-name headers (operator feedback: not useful). --- .../LayerEndpointDependencyView.vue | 184 ++++++++++++++++----- 1 file changed, 143 insertions(+), 41 deletions(-) diff --git a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue index 90a72d6..6e506b4 100644 --- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue +++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue @@ -432,6 +432,71 @@ const visibleCalls = computed<EndpointDependencyCall[]>(() => { return calls.value.filter((c) => ids.has(c.source) && ids.has(c.target)); }); +// ── Pan / zoom. The SVG fits the whole graph by default (viewBox = full +// extent, aspect-preserved), so every column is visible — no edge dangles +// off a clipped column. Wheel + +/−/fit buttons zoom; drag pans. +const svgRef = ref<SVGSVGElement | null>(null); +const viewBox = ref<{ x: number; y: number; w: number; h: number } | null>(null); +const viewBoxStr = computed(() => { + const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value }; + return `${v.x} ${v.y} ${v.w} ${v.h}`; +}); +function fitView(): void { + viewBox.value = { x: 0, y: 0, w: W.value, h: H.value }; +} +// Refit when the graph itself changes (focus pick / first load / refresh +// that adds or drops a column). Operator zoom/pan persists otherwise. +watch([focusedId, () => layerColumns.value.length], () => fitView(), { immediate: true }); + +/** Rendered scale + letterbox offset for the current viewBox under + * preserveAspectRatio="xMidYMid meet" — so cursor zoom + drag pan map + * screen pixels to graph coordinates exactly. */ +function viewMetrics() { + const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value }; + const r = svgRef.value?.getBoundingClientRect(); + const rw = r?.width || v.w; + const rh = r?.height || v.h; + const scale = Math.min(rw / v.w, rh / v.h) || 1; + return { v, left: r?.left ?? 0, top: r?.top ?? 0, scale, offX: (rw - v.w * scale) / 2, offY: (rh - v.h * scale) / 2 }; +} +function clientToView(clientX: number, clientY: number): { x: number; y: number } { + const { v, left, top, scale, offX, offY } = viewMetrics(); + return { x: v.x + (clientX - left - offX) / scale, y: v.y + (clientY - top - offY) / scale }; +} +function zoomAround(factor: number, cx: number, cy: number): void { + const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value }; + // viewBox width bounded to [30%, 160%] of the full graph (zoom-in / out caps). + const newW = Math.min(W.value * 1.6, Math.max(W.value * 0.3, v.w * factor)); + const k = newW / v.w; + viewBox.value = { x: cx - (cx - v.x) * k, y: cy - (cy - v.y) * k, w: newW, h: v.h * k }; +} +function onWheel(e: WheelEvent): void { + e.preventDefault(); + const p = clientToView(e.clientX, e.clientY); + zoomAround(e.deltaY > 0 ? 1.12 : 0.89, p.x, p.y); +} +function zoomBtn(factor: number): void { + const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value }; + zoomAround(factor, v.x + v.w / 2, v.y + v.h / 2); +} +// Drag-pan from the background (node/edge clicks keep their own handlers). +let panning = false; +let panStart = { cx: 0, cy: 0, vx: 0, vy: 0 }; +function onPanStart(e: PointerEvent): void { + panning = true; + const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value }; + panStart = { cx: e.clientX, cy: e.clientY, vx: v.x, vy: v.y }; + (e.target as Element).setPointerCapture?.(e.pointerId); +} +function onPanMove(e: PointerEvent): void { + if (!panning) return; + const { v, scale } = viewMetrics(); + viewBox.value = { ...v, x: panStart.vx - (e.clientX - panStart.cx) / scale, y: panStart.vy - (e.clientY - panStart.cy) / scale }; +} +function onPanEnd(): void { + panning = false; +} + // Kind colour band — uses the endpoint's `type` field, then service // name fallbacks (db/cache/mq/ext). /** @@ -724,26 +789,45 @@ function edgeRowCrosshair(rowId: string): number | null { </span> </header> - <!-- Layer headers row --> - <div class="layer-hdr-row" :style="{ minWidth: W + 'px' }"> - <div - v-for="(col, i) in layerColumns" - :key="col.index" - class="layer-hdr" - :style="{ left: 40 + i * COL_GAP + 'px', width: NW + 'px' }" - > - <span>{{ col.label }}</span> - <span v-if="col.hidden > 0" class="hdr-overflow">+{{ col.hidden }} more</span> - </div> - </div> - <div class="ep-scroll"> + <!-- Zoom toolbar — over the canvas (not the header); wheel + drag + also work directly on the graph. --> + <div v-if="layoutNodes.length > 0" class="ep-zoom"> + <button type="button" title="Zoom in" @click="zoomBtn(0.8)">+</button> + <button type="button" title="Zoom out" @click="zoomBtn(1.25)">−</button> + <button type="button" title="Fit to view" @click="fitView">⤢</button> + </div> <svg v-if="layoutNodes.length > 0" - :viewBox="`0 0 ${W} ${H}`" - :style="{ width: W + 'px', height: H + 'px', display: 'block' }" + ref="svgRef" + class="ep-svg" + :viewBox="viewBoxStr" + preserveAspectRatio="xMidYMid meet" + @wheel="onWheel" > <!-- No arrow markers — the animated dots advertise direction. --> + <defs> + <!-- Clip node text to the box interior so long endpoint names + are cut at the boundary instead of overflowing it. Evaluated + in each node's local space, so one def clips every node. --> + <clipPath id="ep-node-text-clip"> + <rect :x="8" :y="0" :width="NW - 16" :height="NH" /> + </clipPath> + </defs> + <!-- Background pan target. Behind everything; node / edge clicks + keep their own handlers. --> + <rect + class="ep-pan-bg" + :x="-W" + :y="-H" + :width="W * 3" + :height="H * 3" + fill="transparent" + @pointerdown="onPanStart" + @pointermove="onPanMove" + @pointerup="onPanEnd" + @pointerleave="onPanEnd" + /> <!-- column guide lines --> <line @@ -904,9 +988,10 @@ function edgeRowCrosshair(rowId: string): number | null { fill="var(--sw-fg-3)" font-size="10" font-family="var(--sw-mono)" + clip-path="url(#ep-node-text-clip)" > <title>{{ n.serviceName }}</title> - {{ identity(n.serviceName).display.length > 26 ? identity(n.serviceName).display.slice(0, 24) + '…' : identity(n.serviceName).display }} + {{ identity(n.serviceName).display.length > 24 ? identity(n.serviceName).display.slice(0, 22) + '…' : identity(n.serviceName).display }} </text> <!-- Row 2: API (endpoint) name — the headline. --> <text @@ -916,9 +1001,10 @@ function edgeRowCrosshair(rowId: string): number | null { font-size="12" font-family="var(--sw-mono)" :font-weight="n.id === focusedId ? 700 : 600" + clip-path="url(#ep-node-text-clip)" > <title>{{ n.name }}</title> - {{ n.name.length > 28 ? n.name.slice(0, 26) + '…' : n.name }} + {{ n.name.length > 21 ? n.name.slice(0, 19) + '…' : n.name }} </text> <!-- Row 3: configured `center` metric (typically RPM). Coloured in the ring band so the visual signal @@ -1306,6 +1392,7 @@ function edgeRowCrosshair(rowId: string): number | null { border-radius: 2px; } .ep-graph { + position: relative; min-width: 0; display: flex; flex-direction: column; @@ -1524,37 +1611,52 @@ function edgeRowCrosshair(rowId: string): number | null { font-weight: 600; color: var(--sw-fg-0); } -.layer-hdr-row { +/* The graph fits-to-view by default and zooms via the viewBox, so the + container no longer scrolls — it just gives the SVG its height. */ +.ep-scroll { position: relative; - height: 30px; - border-bottom: 1px solid var(--sw-line); + flex: 1; + min-height: 0; + overflow: hidden; } -.layer-hdr { +.ep-svg { + width: 100%; + height: 100%; + display: block; + /* Stop the page from scrolling while wheel-zooming / dragging. */ + touch-action: none; +} +.ep-pan-bg { + cursor: grab; +} +.ep-pan-bg:active { + cursor: grabbing; +} +/* Zoom toolbar — top-right of the graph column. */ +.ep-zoom { position: absolute; top: 8px; + right: 8px; + z-index: 2; display: flex; - align-items: baseline; - gap: 8px; - font-size: 9.5px; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--sw-fg-3); - font-weight: 700; + gap: 4px; } -.hdr-overflow { - font-size: 9px; - color: var(--sw-fg-2); - padding: 1px 5px; - background: var(--sw-bg-2); - border-radius: 3px; - text-transform: none; - letter-spacing: 0; - font-weight: 500; +.ep-zoom button { + width: 24px; + height: 24px; + display: grid; + place-items: center; + font-size: 13px; + line-height: 1; + color: var(--sw-fg-1); + background: var(--sw-bg-1); + border: 1px solid var(--sw-line); + border-radius: 6px; + cursor: pointer; } -.ep-scroll { - position: relative; - overflow: auto; - max-height: 640px; +.ep-zoom button:hover { + border-color: var(--sw-accent); + color: var(--sw-fg-0); } .ep-node { cursor: pointer; } .ep-node:hover rect { stroke: var(--sw-accent-2); }
