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

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

commit 72888e386893b65460d5a016615b4b5352f003f6
Author: Wu Sheng <[email protected]>
AuthorDate: Tue Jun 23 16:33:49 2026 +0800

    feat(explore): Trace inspect result body at parity with the Traces tab
    
    Extract the duration distribution scatter into a shared
    render/widgets/TraceDistribution.vue (click-to-select + drag-brush),
    reused by the per-layer Traces tab and Trace inspect. Bring Trace
    inspect to full parity: the "Traces / N" sw-card list header, an
    adaptive full-height result, a Custom time range (datetime-local →
    window.startMs/endMs, validated end-to-end), and the distribution above
    the list. type-check + lint + build green.
---
 .../src/features/operate/explore/ExploreView.vue   | 127 ++++++++-
 apps/ui/src/i18n/locales/de.json                   |   1 +
 apps/ui/src/i18n/locales/en.json                   |   1 +
 apps/ui/src/i18n/locales/es.json                   |   1 +
 apps/ui/src/i18n/locales/fr.json                   |   1 +
 apps/ui/src/i18n/locales/ja.json                   |   1 +
 apps/ui/src/i18n/locales/ko.json                   |   1 +
 apps/ui/src/i18n/locales/pt.json                   |   1 +
 apps/ui/src/i18n/locales/zh-CN.json                |   1 +
 apps/ui/src/layer/traces/LayerTracesView.vue       | 254 ++----------------
 apps/ui/src/render/widgets/TraceDistribution.vue   | 291 +++++++++++++++++++++
 11 files changed, 441 insertions(+), 239 deletions(-)

diff --git a/apps/ui/src/features/operate/explore/ExploreView.vue 
b/apps/ui/src/features/operate/explore/ExploreView.vue
index d092713..8cdfb54 100644
--- a/apps/ui/src/features/operate/explore/ExploreView.vue
+++ b/apps/ui/src/features/operate/explore/ExploreView.vue
@@ -36,6 +36,7 @@ import type {
   ExploreEntity,
   ExploreRequest,
   ExploreResolved,
+  ExploreWindow,
   NativeSpan,
   NativeTraceListResponse,
   NativeTraceListRow,
@@ -46,6 +47,7 @@ import { useLayers } from '@/shell/useLayers';
 import { useTraceDetail } from '@/layer/traces/useLayerTraces';
 import TraceListPanel from '@/render/widgets/TraceListPanel.vue';
 import TraceDetailCard from '@/render/widgets/TraceDetailCard.vue';
+import TraceDistribution from '@/render/widgets/TraceDistribution.vue';
 import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
 
 const props = defineProps<{ kind: 'trace' | 'log' }>();
@@ -195,6 +197,30 @@ const cond = reactive({
 const WINDOWS = [15, 30, 60, 180, 360, 720, 1440];
 const LIMITS = [20, 30, 50, 100];
 
+// Custom time range — same sentinel-on-windowMinutes shape the per-layer
+// Traces tab uses. When chosen, the Time select swaps to two
+// datetime-local inputs and the query carries explicit epoch-ms bounds.
+const CUSTOM_RANGE_SENTINEL = -1;
+const customStart = ref<string | null>(null);
+const customEnd = ref<string | null>(null);
+const isCustomRange = computed(() => cond.windowMinutes === 
CUSTOM_RANGE_SENTINEL);
+function fmtLocalInput(d: Date): string {
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 
1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+watch(isCustomRange, (custom) => {
+  if (custom) {
+    if (customStart.value && customEnd.value) return;
+    const end = new Date();
+    const start = new Date(end.getTime() - 30 * 60_000);
+    customStart.value = fmtLocalInput(start);
+    customEnd.value = fmtLocalInput(end);
+  } else {
+    customStart.value = null;
+    customEnd.value = null;
+  }
+});
+
 function parseTags(s: string): Array<{ key: string; value: string }> {
   return s
     .split(',')
@@ -217,6 +243,25 @@ const showResolved = ref(false);
 
 const canRun = computed(() => props.kind === 'trace' && traceSource.value === 
'native');
 
+/** Build the window the request carries: explicit epoch-ms bounds in
+ *  Custom mode (datetime-local strings are browser-local; `Date.parse`
+ *  reads them as local), else the rolling minutes preset. */
+function resolveWindow(): ExploreWindow {
+  if (isCustomRange.value) {
+    if (customStart.value && customEnd.value) {
+      const startMs = Date.parse(customStart.value);
+      const endMs = Date.parse(customEnd.value);
+      if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > 
startMs) {
+        return { startMs, endMs };
+      }
+    }
+    // Custom selected but bounds not yet valid — fall back to a sane
+    // rolling window rather than forwarding the -1 sentinel.
+    return { windowMinutes: 30 };
+  }
+  return { windowMinutes: cond.windowMinutes };
+}
+
 async function runQuery(): Promise<void> {
   running.value = true;
   hasQueried.value = true;
@@ -227,7 +272,7 @@ async function runQuery(): Promise<void> {
     kind: 'trace',
     traceSource: 'native',
     ...(entity ? { entity } : {}),
-    window: { windowMinutes: cond.windowMinutes },
+    window: resolveWindow(),
     pageSize: cond.limit,
     traceId: cond.traceId.trim() || undefined,
     traceState: cond.traceState,
@@ -385,10 +430,19 @@ function changeSelectedTraceId(id: string): void {
           <span>{{ t('Tags') }}</span>
           <input v-model="cond.tags" class="cf-input mono" type="text" 
:placeholder="t('http.status_code=500, …')" />
         </label>
-        <label class="cf">
+        <label class="cf" :class="{ 'cf-wide': isCustomRange }">
           <span>{{ t('Time') }}</span>
-          <select v-model.number="cond.windowMinutes" class="cf-input">
+          <template v-if="isCustomRange">
+            <span class="cf-range">
+              <input v-model="customStart" type="datetime-local" 
class="cf-input cf-range-num" />
+              <span class="cf-range-sep">–</span>
+              <input v-model="customEnd" type="datetime-local" class="cf-input 
cf-range-num" />
+              <button class="iq-range-reset" type="button" :title="t('Back to 
presets')" @click="cond.windowMinutes = 30">×</button>
+            </span>
+          </template>
+          <select v-else v-model.number="cond.windowMinutes" class="cf-input">
             <option v-for="w in WINDOWS" :key="w" :value="w">{{ w < 60 ? 
`${w}m` : `${w / 60}h` }}</option>
+            <option :value="CUSTOM_RANGE_SENTINEL">{{ t('Custom…') }}</option>
           </select>
         </label>
         <label class="cf">
@@ -410,19 +464,41 @@ function changeSelectedTraceId(id: string): void {
       </div>
       <pre v-if="resolved && showResolved" class="iq-resolved-body">{{ 
JSON.stringify(resolved.condition, null, 2) }}</pre>
 
+      <!-- Duration distribution — same dot-plot as the per-layer Traces
+           tab. Click a dot to open that trace. -->
+      <section v-if="hasQueried && !errorMsg && rows.length > 0" 
class="iq-scatter sw-card">
+        <header class="iq-scatter-head">
+          <span class="kicker">{{ t('Distribution') }}</span>
+          <span class="legend">
+            <span class="lg ok" /> {{ t('ok') }}
+            <span class="lg err" /> {{ t('err') }}
+          </span>
+        </header>
+        <TraceDistribution
+          :rows="rows"
+          :max-duration="maxDuration"
+          :selected-key="selectedRowKey"
+          @select="openRow"
+        />
+      </section>
+
       <div class="iq-result">
         <div v-if="!hasQueried" class="iq-empty">{{ t('Run a query — name a 
service or leave it blank.') }}</div>
         <div v-else-if="errorMsg" class="iq-err">{{ errorMsg }}</div>
         <div v-else-if="rows.length === 0" class="iq-empty">{{ t('No traces in 
this window.') }}</div>
         <div v-else class="iq-split">
-          <div class="iq-list-pane">
+          <article class="iq-list-card sw-card">
+            <header class="iq-list-head">
+              <h4>{{ t('Traces') }}</h4>
+              <span class="hint">{{ t('{n} traces', { n: rows.length }) 
}}</span>
+            </header>
             <TraceListPanel
               :rows="rows"
               :selected-key="selectedRowKey"
               :max-duration="maxDuration"
               @select="openRow"
             />
-          </div>
+          </article>
           <div class="iq-detail">
             <div v-if="!selectedTraceId" class="iq-empty sm">{{ t('Select a 
trace.') }}</div>
             <div v-else-if="detailFetching && waterfallSpans.length === 0" 
class="iq-empty sm">{{ t('Reading trace…') }}</div>
@@ -444,7 +520,13 @@ function changeSelectedTraceId(id: string): void {
 </template>
 
 <style scoped>
-.iq { display: flex; flex-direction: column; gap: 10px; height: 100%; 
min-height: 0; padding: 14px 16px; }
+/* Fill the viewport below the topbar so the result area (and the list /
+   detail inside it) flex to the available height instead of collapsing to
+   content. The shell `<main>` grows with content rather than imposing a
+   definite height, so a plain `height: 100%` would resolve to auto — the
+   viewport-anchored min-height gives the flex column real height to share
+   (52px ≈ the topbar row). */
+.iq { display: flex; flex-direction: column; gap: 10px; min-height: calc(100vh 
- 52px); padding: 14px 16px; }
 .iq-head h1 { font-size: 16px; margin: 0; }
 .iq-sub { color: var(--sw-fg-3); font-size: 12px; margin: 2px 0 0; }
 .iq-coming { padding: 40px; text-align: center; color: var(--sw-fg-3); 
font-size: 13px; }
@@ -486,11 +568,38 @@ function changeSelectedTraceId(id: string): void {
 .iq-resolved-tog { background: none; border: none; color: var(--sw-fg-3); 
font-size: 11px; cursor: pointer; }
 .iq-resolved-tog .dim { color: var(--sw-fg-4); margin-left: 6px; }
 .iq-resolved-body { margin: 0; padding: 8px 12px; font-family: var(--sw-mono); 
font-size: 11px; color: var(--sw-fg-2); background: var(--sw-bg-0); overflow: 
auto; max-height: 160px; border: 1px solid var(--sw-line); border-radius: 5px; }
-.iq-result { flex: 1; min-height: 0; border: 1px solid var(--sw-line); 
border-radius: 6px; overflow: hidden; }
+/* Distribution card — mirrors the per-layer Traces tab's scatter strip:
+   a tight kicker + ok/err legend head, the shared dot-plot below. */
+.iq-scatter { padding: 6px 10px 8px; display: flex; flex-direction: column; 
flex: 0 0 auto; }
+.iq-scatter-head { display: flex; align-items: baseline; gap: 10px; 
margin-bottom: 2px; flex: 0 0 auto; }
+.iq-scatter-head .legend { margin-left: auto; font-size: 10.5px; color: 
var(--sw-fg-3); display: inline-flex; gap: 10px; align-items: center; }
+.iq-scatter-head .lg { display: inline-block; width: 7px; height: 7px; 
border-radius: 50%; margin-right: 3px; vertical-align: middle; }
+.iq-scatter-head .lg.ok { background: var(--sw-accent); }
+.iq-scatter-head .lg.err { background: var(--sw-err); }
+.kicker { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; 
color: var(--sw-accent); font-weight: 600; }
+/* The dot-plot lives in TraceDistribution.vue; cap its height so the
+   distribution stays a strip and the list/detail below get the rest. */
+.iq-scatter :deep(.scatter-wrap) { flex: 0 0 auto; }
+.iq-scatter :deep(.scatter-svg) { min-height: 120px; max-height: 160px; }
+
+.iq-result { flex: 1; min-height: 0; }
 .iq-empty, .iq-err { padding: 24px; text-align: center; color: var(--sw-fg-3); 
font-size: 12px; }
 .iq-empty.sm { padding: 14px; }
 .iq-err { color: var(--sw-err); }
-.iq-split { display: grid; grid-template-columns: minmax(280px, 360px) 1fr; 
height: 100%; min-height: 0; }
-.iq-list-pane { display: flex; flex-direction: column; min-height: 0; 
overflow: auto; border-right: 1px solid var(--sw-line); }
+.iq-split { display: grid; grid-template-columns: minmax(280px, 360px) 1fr; 
gap: 12px; height: 100%; min-height: 0; }
+/* List pane — the same sw-card + header the per-layer Traces tab uses,
+   with the row list scrolling inside. */
+.iq-list-card { padding: 0; display: flex; flex-direction: column; min-height: 
0; }
+.iq-list-head { display: flex; align-items: baseline; gap: 8px; padding: 10px 
14px; border-bottom: 1px solid var(--sw-line); flex: 0 0 auto; }
+.iq-list-head h4 { margin: 0; font-size: 12px; font-weight: 600; color: 
var(--sw-fg-0); }
+.iq-list-head .hint { margin-left: auto; font-size: 10.5px; color: 
var(--sw-fg-3); }
 .iq-detail { overflow: auto; min-width: 0; }
+
+/* Custom-range reset (× back to presets). */
+.iq-range-reset {
+  flex: 0 0 auto; height: 28px; width: 24px; padding: 0;
+  background: transparent; border: 1px solid var(--sw-line-2); border-radius: 
4px;
+  color: var(--sw-fg-2); cursor: pointer; font-size: 13px; line-height: 1;
+}
+.iq-range-reset:hover { color: var(--sw-accent); border-color: 
var(--sw-accent); }
 </style>
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index b7239ee..9d9b0d3 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -516,6 +516,7 @@
   "Clear in-page filter": "In-Page-Filter zurücksetzen",
   "Reset": "Zurücksetzen",
   "picked": "ausgewählt",
+  "selected": "ausgewählt",
   "no traces": "keine Traces",
   "This OAP serves traces via": "Dieses OAP liefert Traces über",
   "Trace Query {label} API": "Trace-Query-{label}-API",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 8d77c6c..0b06fa3 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -486,6 +486,7 @@
   "Clear in-page filter": "Clear in-page filter",
   "Reset": "Reset",
   "picked": "picked",
+  "selected": "selected",
   "no traces": "no traces",
   "This OAP serves traces via": "This OAP serves traces via",
   "Trace Query {label} API": "Trace Query {label} API",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index a982d2b..3b5fcb2 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -516,6 +516,7 @@
   "Clear in-page filter": "Limpiar filtro de la página",
   "Reset": "Restablecer",
   "picked": "seleccionadas",
+  "selected": "seleccionado",
   "no traces": "sin trazas",
   "This OAP serves traces via": "Este OAP sirve trazas a través de",
   "Trace Query {label} API": "API Trace Query {label}",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 42fa778..2617b2f 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -516,6 +516,7 @@
   "Clear in-page filter": "Effacer le filtre de la page",
   "Reset": "Réinitialiser",
   "picked": "sélectionnés",
+  "selected": "sélectionné",
   "no traces": "aucune trace",
   "This OAP serves traces via": "Cet OAP sert les traces via",
   "Trace Query {label} API": "API Trace Query {label}",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index a92e591..eb2d482 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -516,6 +516,7 @@
   "Clear in-page filter": "ページ内フィルタをクリア",
   "Reset": "リセット",
   "picked": "選択中",
+  "selected": "選択済み",
   "no traces": "トレースなし",
   "This OAP serves traces via": "この OAP は次の経由でトレースを提供します:",
   "Trace Query {label} API": "Trace Query {label} API",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index f98271d..7504748 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -516,6 +516,7 @@
   "Clear in-page filter": "페이지 내 필터 지우기",
   "Reset": "재설정",
   "picked": "선택됨",
+  "selected": "선택됨",
   "no traces": "트레이스 없음",
   "This OAP serves traces via": "이 OAP는 다음을 통해 트레이스를 제공합니다:",
   "Trace Query {label} API": "Trace Query {label} API",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 367a0a1..f781bfe 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -516,6 +516,7 @@
   "Clear in-page filter": "Limpar filtro da página",
   "Reset": "Redefinir",
   "picked": "selecionado",
+  "selected": "selecionado",
   "no traces": "nenhum trace",
   "This OAP serves traces via": "Este OAP entrega traces via",
   "Trace Query {label} API": "API de consulta de traces {label}",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json 
b/apps/ui/src/i18n/locales/zh-CN.json
index b9ae868..ae53d88 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -516,6 +516,7 @@
   "Clear in-page filter": "清除页内筛选",
   "Reset": "重置",
   "picked": "已选",
+  "selected": "已选中",
   "no traces": "暂无 trace",
   "This OAP serves traces via": "此 OAP 通过以下方式提供 trace 数据:",
   "Trace Query {label} API": "Trace Query {label} API",
diff --git a/apps/ui/src/layer/traces/LayerTracesView.vue 
b/apps/ui/src/layer/traces/LayerTracesView.vue
index b65cc9f..7b7a5c8 100644
--- a/apps/ui/src/layer/traces/LayerTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerTracesView.vue
@@ -54,10 +54,10 @@ import TypeaheadSelect from 
'@/components/primitives/TypeaheadSelect.vue';
 import { useSelectedService } from '@/layer/useSelectedService';
 import { useLayerServiceName } from '@/layer/useLayerServiceName';
 import { useSetupStore } from '@/state/setup';
-import { fmtMetric } from '@/utils/formatters';
 import { bffClient } from '@/api/client';
 import TraceListPanel from '@/render/widgets/TraceListPanel.vue';
 import TraceDetailCard from '@/render/widgets/TraceDetailCard.vue';
+import TraceDistribution from '@/render/widgets/TraceDistribution.vue';
 
 const route = useRoute();
 const layerKey = computed(() => String(route.params.layerKey ?? ''));
@@ -337,74 +337,22 @@ const { nativeDetail, isFetching: detailFetching } = 
useTraceDetail(traceIdRef,
 // when present, else the on-demand `queryTrace` fetch.
 const detailSpans = computed<NativeSpan[]>(() => embeddedSpans.value ?? 
nativeDetail.value?.spans ?? []);
 
-// ── Render helpers ────────────────────────────────────────────────
-function fmtMs(v: number | null | undefined): string {
-  if (v === null || v === undefined) return '—';
-  return `${fmtMetric(v)} ms`;
-}
-function parseNativeStart(v: string): number {
-  const n = Number(v);
-  if (Number.isFinite(n) && n > 0) return n;
-  const ts = Date.parse(v);
-  return Number.isFinite(ts) ? ts : 0;
-}
-
 const maxTraceDuration = computed(() => {
   const arr = visibleTraces.value;
   if (arr.length === 0) return 0;
   return Math.max(...arr.map((tr) => tr.duration));
 });
 
-// ── Duration scatter ──────────────────────────────────────────────
-interface ScatterPoint { id: string; rowKey: string; x: number; y: number; 
isError: boolean; label: string; row: NativeTraceListRow; }
-const scatterPoints = computed<ScatterPoint[]>(() => {
-  const out: ScatterPoint[] = [];
-  if (native.value?.traces) {
-    for (const t of native.value.traces) {
-      const ts = parseNativeStart(t.start);
-      out.push({
-        id: t.key,
-        rowKey: t.key,
-        x: ts,
-        y: t.duration,
-        isError: t.isError,
-        label: t.endpointNames[0] ?? '—',
-        row: t,
-      });
-    }
-  }
-  return out;
-});
-const scatterBounds = computed(() => {
-  const pts = scatterPoints.value.filter((p) => p.x > 0 && 
Number.isFinite(p.y));
-  if (pts.length === 0) return null;
-  const xs = pts.map((p) => p.x);
-  const ys = pts.map((p) => p.y);
-  const rawYMax = Math.max(...ys, 1);
-  const yMax = rawYMax || 1;
-  return { xMin: Math.min(...xs), xMax: Math.max(...xs), yMin: 0, yMax };
-});
-const scatterXTicks = computed(() => {
-  const b = scatterBounds.value;
-  if (!b) return [];
-  const xCount = 3;
-  const span = Math.max(1, b.xMax - b.xMin);
-  return Array.from({ length: xCount }, (_, i) => {
-    const t = b.xMin + (span * i) / (xCount - 1);
-    const d = new Date(t);
-    const pad = (n: number) => String(n).padStart(2, '0');
-    return { frac: i / (xCount - 1), label: 
`${pad(d.getHours())}:${pad(d.getMinutes())}` };
-  });
-});
-
 /* ── Distribution selection (in-page filter) ─────────────────────
  *
  * Clicking a dot adds (or removes) the corresponding trace to the
  * `pickedTraceIds` set. Dragging a rectangle on the chart selects
- * every dot inside it. The list below is then filtered to only the
- * picked traces — no extra query fires. Reset clears the set.
+ * every dot inside it (the shared TraceDistribution emits the matched
+ * keys). The list below is then filtered to only the picked traces —
+ * no extra query fires. Reset clears the set.
  */
 const pickedTraceIds = ref<Set<string>>(new Set());
+const pickedKeys = computed(() => [...pickedTraceIds.value]);
 const isPicking = computed(() => pickedTraceIds.value.size > 0);
 function togglePick(rowKey: string): void {
   const s = new Set(pickedTraceIds.value);
@@ -415,83 +363,17 @@ function togglePick(rowKey: string): void {
 function resetPick(): void {
   pickedTraceIds.value = new Set();
 }
-
-// Drag-to-select state. Coords are SVG-space (viewBox 0..1000 in x,
-// 1000..0 in y). The drag rect renders while pointer is down.
-const scatterSvgRef = ref<SVGSVGElement | null>(null);
-const dragState = ref<{
-  active: boolean;
-  startVx: number; startVy: number; curVx: number; curVy: number;
-}>({ active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 });
-function clientToViewbox(ev: PointerEvent): { vx: number; vy: number } | null {
-  const svg = scatterSvgRef.value;
-  if (!svg) return null;
-  const rect = svg.getBoundingClientRect();
-  if (rect.width === 0 || rect.height === 0) return null;
-  const vx = ((ev.clientX - rect.left) / rect.width) * 1000;
-  const vy = ((ev.clientY - rect.top) / rect.height) * 1000;
-  return { vx, vy };
-}
-function onScatterDown(ev: PointerEvent): void {
-  const pt = clientToViewbox(ev);
-  if (!pt) return;
-  const target = ev.target as Element | null;
-  // Click on a dot is handled by its own click handler; the drag is
-  // for the empty plot area.
-  if (target?.classList.contains('scatter-dot')) return;
-  ev.preventDefault();
-  dragState.value = { active: true, startVx: pt.vx, startVy: pt.vy, curVx: 
pt.vx, curVy: pt.vy };
-  (ev.currentTarget as SVGSVGElement).setPointerCapture(ev.pointerId);
-}
-function onScatterMove(ev: PointerEvent): void {
-  if (!dragState.value.active) return;
-  const pt = clientToViewbox(ev);
-  if (!pt) return;
-  dragState.value = { ...dragState.value, curVx: pt.vx, curVy: pt.vy };
+// Dot click → toggle picked. The operator picks dots to filter; the
+// inline detail still opens via the list row click below.
+function onScatterSelect(row: NativeTraceListRow): void {
+  togglePick(row.key);
 }
-function onScatterUp(ev: PointerEvent): void {
-  if (!dragState.value.active) return;
-  const { startVx, startVy, curVx, curVy } = dragState.value;
-  dragState.value = { active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 
0 };
-  try {
-    (ev.currentTarget as SVGSVGElement).releasePointerCapture(ev.pointerId);
-  } catch { /* noop */ }
-  // No real drag (click without move) → don't change selection.
-  if (Math.abs(curVx - startVx) < 4 && Math.abs(curVy - startVy) < 4) return;
-  const b = scatterBounds.value;
-  if (!b) return;
-  const vxMin = Math.min(startVx, curVx);
-  const vxMax = Math.max(startVx, curVx);
-  const vyMin = Math.min(startVy, curVy);
-  const vyMax = Math.max(startVy, curVy);
+// Drag-brush → add every matched dot to the picked set.
+function onScatterBrush(keys: string[]): void {
   const next = new Set(pickedTraceIds.value);
-  for (const p of scatterPoints.value) {
-    const cx = b.xMax === b.xMin ? 500 : ((p.x - b.xMin) / (b.xMax - b.xMin)) 
* 1000;
-    const cy = 1000 - ((p.y - b.yMin) / (b.yMax - b.yMin || 1)) * 990;
-    if (cx >= vxMin && cx <= vxMax && cy >= vyMin && cy <= vyMax) {
-      next.add(p.rowKey);
-    }
-  }
+  for (const k of keys) next.add(k);
   pickedTraceIds.value = next;
 }
-const dragRect = computed(() => {
-  const s = dragState.value;
-  if (!s.active) return null;
-  return {
-    x: Math.min(s.startVx, s.curVx),
-    y: Math.min(s.startVy, s.curVy),
-    w: Math.abs(s.curVx - s.startVx),
-    h: Math.abs(s.curVy - s.startVy),
-  };
-});
-
-// Dot click → toggle picked. Replaces the earlier "open inline
-// detail" wiring: the operator picks dots to filter; the inline
-// detail still opens via the list row click below.
-function pickScatterDot(p: ScatterPoint, ev: MouseEvent): void {
-  ev.stopPropagation();
-  togglePick(p.rowKey);
-}
 
 // Filter the visible list by the picked set when any are picked.
 const visibleTraces = computed(() => {
@@ -673,60 +555,14 @@ onBeforeUnmount(() => 
window.removeEventListener('keydown', onPageKeyDown, true)
             @click="resetPick"
           >{{ t('Reset') }}</button>
         </header>
-        <div v-if="scatterPoints.length > 0 && scatterBounds" 
class="scatter-wrap">
-          <svg
-            ref="scatterSvgRef"
-            class="scatter-svg"
-            viewBox="0 0 1000 1000"
-            preserveAspectRatio="none"
-            @pointerdown="onScatterDown"
-            @pointermove="onScatterMove"
-            @pointerup="onScatterUp"
-            @pointercancel="onScatterUp"
-          >
-            <line x1="0" y1="998" x2="1000" y2="998" stroke="var(--sw-line-2)" 
stroke-width="1" vector-effect="non-scaling-stroke" />
-            <circle
-              v-for="p in scatterPoints"
-              :key="p.id"
-              :cx="scatterBounds.xMax === scatterBounds.xMin ? 500 : ((p.x - 
scatterBounds.xMin) / (scatterBounds.xMax - scatterBounds.xMin)) * 1000"
-              :cy="1000 - ((p.y - scatterBounds.yMin) / (scatterBounds.yMax - 
scatterBounds.yMin || 1)) * 990"
-              :r="pickedTraceIds.has(p.rowKey) ? 6 : 3.2"
-              :fill="p.isError ? 'var(--sw-err)' : 'var(--sw-accent)'"
-              :fill-opacity="pickedTraceIds.has(p.rowKey) ? 1 : (isPicking ? 
0.35 : 0.9)"
-              :stroke="pickedTraceIds.has(p.rowKey) ? 'var(--sw-fg-0)' : 
(p.isError ? 'var(--sw-err)' : 'var(--sw-accent-2)')"
-              :stroke-width="pickedTraceIds.has(p.rowKey) ? 1.8 : 0.8"
-              vector-effect="non-scaling-stroke"
-              class="scatter-dot"
-              @click="pickScatterDot(p, $event)"
-            >
-              <title>{{ p.label }} · {{ fmtMs(p.y) }}{{ 
pickedTraceIds.has(p.rowKey) ? ` · ${t('picked')}` : '' }}</title>
-            </circle>
-            <!-- Drag-select rectangle. -->
-            <rect
-              v-if="dragRect"
-              :x="dragRect.x"
-              :y="dragRect.y"
-              :width="dragRect.w"
-              :height="dragRect.h"
-              fill="var(--sw-accent)"
-              fill-opacity="0.12"
-              stroke="var(--sw-accent)"
-              stroke-width="1"
-              stroke-dasharray="4 3"
-              vector-effect="non-scaling-stroke"
-              pointer-events="none"
-            />
-          </svg>
-          <div class="scatter-x-axis">
-            <span
-              v-for="(t, i) in scatterXTicks"
-              :key="`x${i}`"
-              class="x-tick"
-              :class="{ first: i === 0, last: i === scatterXTicks.length - 1 }"
-            >{{ t.label }}</span>
-          </div>
-        </div>
-        <div v-else class="scatter-empty">{{ t('no traces') }}</div>
+        <TraceDistribution
+          :rows="native?.traces ?? []"
+          :max-duration="maxTraceDuration"
+          :selected-key="null"
+          :highlight-keys="pickedKeys"
+          @select="onScatterSelect"
+          @brush="onScatterBrush"
+        />
       </section>
     </div>
 
@@ -930,57 +766,15 @@ onBeforeUnmount(() => 
window.removeEventListener('keydown', onPageKeyDown, true)
 }
 .tr-scatter-head .lg.ok { background: var(--sw-accent); }
 .tr-scatter-head .lg.err { background: var(--sw-err); }
-.scatter-tools { margin-left: 6px; display: inline-flex; gap: 4px; }
 .pick-kicker {
   color: var(--sw-accent-2);
   font-weight: 700;
 }
 .reset-btn { margin-left: 6px; }
-.pick-hint {
-  background: var(--sw-accent-soft);
-  color: var(--sw-accent-2);
-  padding: 1px 6px;
-  border-radius: 8px;
-  font-weight: 600;
-}
-.scatter-svg { cursor: crosshair; }
-.scatter-dot { cursor: pointer; }
-.scatter-wrap {
-  padding: 0;
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  min-height: 0;
-}
-.scatter-svg {
-  width: 100%;
-  flex: 1;
-  min-height: 140px;
-  display: block;
-}
-.scatter-dot { cursor: pointer; transition: r 0.12s ease; }
-.scatter-dot:hover { stroke: var(--sw-fg-0); stroke-width: 1.6; }
-.scatter-x-axis {
-  display: flex;
-  justify-content: space-between;
-  padding: 4px 0 0;
-  font-size: 11px;
-  color: var(--sw-fg-2);
-  font-family: var(--sw-mono);
-  flex: 0 0 auto;
-}
-.x-tick { white-space: nowrap; }
-.x-tick.first { text-align: left; }
-.x-tick.last { text-align: right; }
-.scatter-empty {
-  flex: 1;
-  min-height: 140px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 11px;
-  color: var(--sw-fg-3);
-}
+/* The scatter chart internals (dots, axis, drag) live in
+   TraceDistribution.vue; this card only styles the head strip. The
+   shared component fills the remaining card height via its own flex. */
+.tr-scatter :deep(.scatter-wrap) { flex: 1; min-height: 0; }
 
 /* Browsing list */
 .tr-list-card { padding: 0; display: flex; flex-direction: column; }
diff --git a/apps/ui/src/render/widgets/TraceDistribution.vue 
b/apps/ui/src/render/widgets/TraceDistribution.vue
new file mode 100644
index 0000000..4a6458e
--- /dev/null
+++ b/apps/ui/src/render/widgets/TraceDistribution.vue
@@ -0,0 +1,291 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<!--
+  Shared trace duration-distribution scatter. The single dot-plot used by
+  BOTH the per-layer Traces tab and the cross-layer Trace inspect view:
+  X is the trace start time, Y the trace duration. The Y axis is omitted —
+  the duration is surfaced on the dot's tooltip and the list-row bar below.
+
+  Two operator gestures, both emitted upward so the host decides the
+  effect:
+    - Click a dot   → `select(row)`. (Traces tab toggles its in-page pick
+      filter; Trace inspect opens the trace detail.)
+    - Drag a region → `brush(keys)` with every row.key whose dot falls in
+      the rectangle. (Traces tab merges them into the pick set; hosts that
+      don't filter in-page simply ignore it.)
+
+  Presentational only: no selection / filter state lives here. `selectedKey`
+  (the host's active/picked row) drives the highlighted dot rendering; the
+  host owns the truth.
+-->
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { NativeTraceListRow } from '@/api/client';
+import { fmtMetric } from '@/utils/formatters';
+
+const { t } = useI18n({ useScope: 'global' });
+
+const props = withDefaults(
+  defineProps<{
+    rows: NativeTraceListRow[];
+    maxDuration: number;
+    /** The host's active/picked row key. Renders the matching dot enlarged
+     *  + outlined; null dims nothing. */
+    selectedKey: string | null;
+    /** Optional multi-highlight set (the Traces tab's in-page pick set).
+     *  Every dot whose key is here renders enlarged + outlined, and the
+     *  rest dim — the brush picks more than one trace at a time. When
+     *  empty the `selectedKey` single-highlight applies instead. */
+    highlightKeys?: string[];
+  }>(),
+  { highlightKeys: () => [] },
+);
+const emit = defineEmits<{
+  (e: 'select', row: NativeTraceListRow): void;
+  (e: 'brush', keys: string[]): void;
+}>();
+
+const highlightSet = computed(() => new Set(props.highlightKeys));
+/** A dot is highlighted when it's the single `selectedKey` or a member of
+ *  the multi-highlight set. */
+function isHot(rowKey: string): boolean {
+  return props.selectedKey === rowKey || highlightSet.value.has(rowKey);
+}
+/** True when something is highlighted (single or set) — non-hot dots dim. */
+const hasHot = computed(() => props.selectedKey !== null || 
highlightSet.value.size > 0);
+
+function fmtMs(v: number | null | undefined): string {
+  if (v === null || v === undefined) return '—';
+  return `${fmtMetric(v)} ms`;
+}
+function parseNativeStart(v: string): number {
+  const n = Number(v);
+  if (Number.isFinite(n) && n > 0) return n;
+  const ts = Date.parse(v);
+  return Number.isFinite(ts) ? ts : 0;
+}
+
+interface ScatterPoint { id: string; rowKey: string; x: number; y: number; 
isError: boolean; label: string; row: NativeTraceListRow; }
+const scatterPoints = computed<ScatterPoint[]>(() => {
+  const out: ScatterPoint[] = [];
+  for (const tr of props.rows) {
+    const ts = parseNativeStart(tr.start);
+    out.push({
+      id: tr.key,
+      rowKey: tr.key,
+      x: ts,
+      y: tr.duration,
+      isError: tr.isError,
+      label: tr.endpointNames[0] ?? '—',
+      row: tr,
+    });
+  }
+  return out;
+});
+const scatterBounds = computed(() => {
+  const pts = scatterPoints.value.filter((p) => p.x > 0 && 
Number.isFinite(p.y));
+  if (pts.length === 0) return null;
+  const xs = pts.map((p) => p.x);
+  const ys = pts.map((p) => p.y);
+  const rawYMax = Math.max(...ys, 1);
+  const yMax = rawYMax || 1;
+  return { xMin: Math.min(...xs), xMax: Math.max(...xs), yMin: 0, yMax };
+});
+const scatterXTicks = computed(() => {
+  const b = scatterBounds.value;
+  if (!b) return [];
+  const xCount = 3;
+  const span = Math.max(1, b.xMax - b.xMin);
+  return Array.from({ length: xCount }, (_, i) => {
+    const at = b.xMin + (span * i) / (xCount - 1);
+    const d = new Date(at);
+    const pad = (n: number) => String(n).padStart(2, '0');
+    return { frac: i / (xCount - 1), label: 
`${pad(d.getHours())}:${pad(d.getMinutes())}` };
+  });
+});
+
+// Drag-to-select state. Coords are SVG-space (viewBox 0..1000 in x,
+// 1000..0 in y). The drag rect renders while pointer is down.
+const scatterSvgRef = ref<SVGSVGElement | null>(null);
+const dragState = ref<{
+  active: boolean;
+  startVx: number; startVy: number; curVx: number; curVy: number;
+}>({ active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 });
+function clientToViewbox(ev: PointerEvent): { vx: number; vy: number } | null {
+  const svg = scatterSvgRef.value;
+  if (!svg) return null;
+  const rect = svg.getBoundingClientRect();
+  if (rect.width === 0 || rect.height === 0) return null;
+  const vx = ((ev.clientX - rect.left) / rect.width) * 1000;
+  const vy = ((ev.clientY - rect.top) / rect.height) * 1000;
+  return { vx, vy };
+}
+function onScatterDown(ev: PointerEvent): void {
+  const pt = clientToViewbox(ev);
+  if (!pt) return;
+  const target = ev.target as Element | null;
+  // Click on a dot is handled by its own click handler; the drag is
+  // for the empty plot area.
+  if (target?.classList.contains('scatter-dot')) return;
+  ev.preventDefault();
+  dragState.value = { active: true, startVx: pt.vx, startVy: pt.vy, curVx: 
pt.vx, curVy: pt.vy };
+  (ev.currentTarget as SVGSVGElement).setPointerCapture(ev.pointerId);
+}
+function onScatterMove(ev: PointerEvent): void {
+  if (!dragState.value.active) return;
+  const pt = clientToViewbox(ev);
+  if (!pt) return;
+  dragState.value = { ...dragState.value, curVx: pt.vx, curVy: pt.vy };
+}
+function onScatterUp(ev: PointerEvent): void {
+  if (!dragState.value.active) return;
+  const { startVx, startVy, curVx, curVy } = dragState.value;
+  dragState.value = { active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 
0 };
+  try {
+    (ev.currentTarget as SVGSVGElement).releasePointerCapture(ev.pointerId);
+  } catch { /* noop */ }
+  // No real drag (click without move) → don't change selection.
+  if (Math.abs(curVx - startVx) < 4 && Math.abs(curVy - startVy) < 4) return;
+  const b = scatterBounds.value;
+  if (!b) return;
+  const vxMin = Math.min(startVx, curVx);
+  const vxMax = Math.max(startVx, curVx);
+  const vyMin = Math.min(startVy, curVy);
+  const vyMax = Math.max(startVy, curVy);
+  const keys: string[] = [];
+  for (const p of scatterPoints.value) {
+    const cx = b.xMax === b.xMin ? 500 : ((p.x - b.xMin) / (b.xMax - b.xMin)) 
* 1000;
+    const cy = 1000 - ((p.y - b.yMin) / (b.yMax - b.yMin || 1)) * 990;
+    if (cx >= vxMin && cx <= vxMax && cy >= vyMin && cy <= vyMax) {
+      keys.push(p.rowKey);
+    }
+  }
+  if (keys.length > 0) emit('brush', keys);
+}
+const dragRect = computed(() => {
+  const s = dragState.value;
+  if (!s.active) return null;
+  return {
+    x: Math.min(s.startVx, s.curVx),
+    y: Math.min(s.startVy, s.curVy),
+    w: Math.abs(s.curVx - s.startVx),
+    h: Math.abs(s.curVy - s.startVy),
+  };
+});
+function onScatterDot(p: ScatterPoint, ev: MouseEvent): void {
+  ev.stopPropagation();
+  emit('select', p.row);
+}
+</script>
+
+<template>
+  <div v-if="scatterPoints.length > 0 && scatterBounds" class="scatter-wrap">
+    <svg
+      ref="scatterSvgRef"
+      class="scatter-svg"
+      viewBox="0 0 1000 1000"
+      preserveAspectRatio="none"
+      @pointerdown="onScatterDown"
+      @pointermove="onScatterMove"
+      @pointerup="onScatterUp"
+      @pointercancel="onScatterUp"
+    >
+      <line x1="0" y1="998" x2="1000" y2="998" stroke="var(--sw-line-2)" 
stroke-width="1" vector-effect="non-scaling-stroke" />
+      <circle
+        v-for="p in scatterPoints"
+        :key="p.id"
+        :cx="scatterBounds.xMax === scatterBounds.xMin ? 500 : ((p.x - 
scatterBounds.xMin) / (scatterBounds.xMax - scatterBounds.xMin)) * 1000"
+        :cy="1000 - ((p.y - scatterBounds.yMin) / (scatterBounds.yMax - 
scatterBounds.yMin || 1)) * 990"
+        :r="isHot(p.rowKey) ? 6 : 3.2"
+        :fill="p.isError ? 'var(--sw-err)' : 'var(--sw-accent)'"
+        :fill-opacity="isHot(p.rowKey) ? 1 : (hasHot ? 0.35 : 0.9)"
+        :stroke="isHot(p.rowKey) ? 'var(--sw-fg-0)' : (p.isError ? 
'var(--sw-err)' : 'var(--sw-accent-2)')"
+        :stroke-width="isHot(p.rowKey) ? 1.8 : 0.8"
+        vector-effect="non-scaling-stroke"
+        class="scatter-dot"
+        @click="onScatterDot(p, $event)"
+      >
+        <title>{{ p.label }} · {{ fmtMs(p.y) }}{{ isHot(p.rowKey) ? ` · 
${t('selected')}` : '' }}</title>
+      </circle>
+      <!-- Drag-select rectangle. -->
+      <rect
+        v-if="dragRect"
+        :x="dragRect.x"
+        :y="dragRect.y"
+        :width="dragRect.w"
+        :height="dragRect.h"
+        fill="var(--sw-accent)"
+        fill-opacity="0.12"
+        stroke="var(--sw-accent)"
+        stroke-width="1"
+        stroke-dasharray="4 3"
+        vector-effect="non-scaling-stroke"
+        pointer-events="none"
+      />
+    </svg>
+    <div class="scatter-x-axis">
+      <span
+        v-for="(tick, i) in scatterXTicks"
+        :key="`x${i}`"
+        class="x-tick"
+        :class="{ first: i === 0, last: i === scatterXTicks.length - 1 }"
+      >{{ tick.label }}</span>
+    </div>
+  </div>
+  <div v-else class="scatter-empty">{{ t('no traces') }}</div>
+</template>
+
+<style scoped>
+.scatter-svg { cursor: crosshair; }
+.scatter-wrap {
+  padding: 0;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.scatter-svg {
+  width: 100%;
+  flex: 1;
+  min-height: 140px;
+  display: block;
+}
+.scatter-dot { cursor: pointer; transition: r 0.12s ease; }
+.scatter-dot:hover { stroke: var(--sw-fg-0); stroke-width: 1.6; }
+.scatter-x-axis {
+  display: flex;
+  justify-content: space-between;
+  padding: 4px 0 0;
+  font-size: 11px;
+  color: var(--sw-fg-2);
+  font-family: var(--sw-mono);
+  flex: 0 0 auto;
+}
+.x-tick { white-space: nowrap; }
+.x-tick.first { text-align: left; }
+.x-tick.last { text-align: right; }
+.scatter-empty {
+  flex: 1;
+  min-height: 140px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 11px;
+  color: var(--sw-fg-3);
+}
+</style>


Reply via email to