fix(query-tool): lazy-load detail, multi-root trees, timeline UX overhaul

- Tree: render separate ECharts series per wafer-lot root instead of
  overlapping single-data trees
- Lazy loading: resolve builds tree only; detail/timeline load on node
  click to reduce initial resource consumption
- Timeline: group tracks by WORKCENTER_GROUP × LOT ID × Equipment with
  multi-line Y-axis labels (LOT ID + 機台編號)
- Timeline: backend enriches history rows with WORKCENTER_GROUP via
  filter_cache; timeRange derives only from history bars for dynamic
  updates on filter/selection change
- TimelineChart: teleport tooltip to body (fixed positioning) to prevent
  clipping; adaptive chart width scaling; edge-aware boundary detection
- Build script: add reject-history HTML copy; feature flag registered

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-22 09:13:56 +08:00
parent 05d907ac72
commit 75fbdf2f88
7 changed files with 331 additions and 191 deletions

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build && cp ../src/mes_dashboard/static/dist/src/portal-shell/index.html ../src/mes_dashboard/static/dist/portal-shell.html && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-overview/index.html ../src/mes_dashboard/static/dist/hold-overview.html && cp ../src/mes_dashboard/static/dist/src/hold-history/index.html ../src/mes_dashboard/static/dist/hold-history.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html && cp ../src/mes_dashboard/static/dist/src/mid-section-defect/index.html ../src/mes_dashboard/static/dist/mid-section-defect.html",
"build": "vite build && cp ../src/mes_dashboard/static/dist/src/portal-shell/index.html ../src/mes_dashboard/static/dist/portal-shell.html && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-overview/index.html ../src/mes_dashboard/static/dist/hold-overview.html && cp ../src/mes_dashboard/static/dist/src/hold-history/index.html ../src/mes_dashboard/static/dist/hold-history.html && cp ../src/mes_dashboard/static/dist/src/reject-history/index.html ../src/mes_dashboard/static/dist/reject-history.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html && cp ../src/mes_dashboard/static/dist/src/mid-section-defect/index.html ../src/mes_dashboard/static/dist/mid-section-defect.html",
"test": "node --test tests/*.test.js"
},
"devDependencies": {

View File

@@ -192,17 +192,11 @@ async function handleResolveLots() {
return;
}
// Build tree only — don't auto-select or load detail data.
// User clicks a node to trigger detail/timeline loading on demand.
await lotLineage.primeResolvedLots(lotResolve.resolvedLots.value);
const treeRootIds = lotLineage.treeRoots.value;
if (treeRootIds.length === 0) {
await lotDetail.setSelectedContainerId('');
lotLineage.clearSelection();
return;
}
// Auto-select all tree roots and load detail for their full subtrees
await handleSelectNodes(treeRootIds);
lotLineage.clearSelection();
lotDetail.clearTabData();
}
async function handleSelectNodes(containerIds) {

View File

@@ -145,113 +145,137 @@ function buildNode(cid, visited) {
};
}
const echartsData = computed(() => {
// Build each root into its own independent tree data
const treesData = computed(() => {
if (props.treeRoots.length === 0) {
return [];
}
const visited = new Set();
const globalVisited = new Set();
return props.treeRoots
.map((rootId) => buildNode(rootId, visited))
.map((rootId) => buildNode(rootId, globalVisited))
.filter(Boolean);
});
const hasData = computed(() => echartsData.value.length > 0);
const hasData = computed(() => treesData.value.length > 0);
function countNodes(nodes) {
let count = 0;
function walk(list) {
list.forEach((node) => {
count += 1;
if (node.children?.length > 0) {
walk(node.children);
}
});
function countLeaves(node) {
if (!node.children || node.children.length === 0) {
return 1;
}
walk(nodes);
return count;
return node.children.reduce((sum, child) => sum + countLeaves(child), 0);
}
const chartHeight = computed(() => {
const total = countNodes(echartsData.value);
const base = Math.max(300, Math.min(800, total * 28));
const totalLeaves = treesData.value.reduce((sum, tree) => sum + countLeaves(tree), 0);
const base = Math.max(300, Math.min(1200, totalLeaves * 36));
return `${base}px`;
});
const TREE_SERIES_DEFAULTS = Object.freeze({
type: 'tree',
layout: 'orthogonal',
orient: 'LR',
expandAndCollapse: true,
initialTreeDepth: -1,
roam: 'move',
symbol: 'circle',
symbolSize: 10,
label: {
show: true,
position: 'right',
fontSize: 11,
color: '#334155',
overflow: 'truncate',
ellipsis: '…',
width: 160,
},
lineStyle: {
width: 1.5,
color: '#CBD5E1',
curveness: 0.5,
},
emphasis: {
focus: 'ancestor',
itemStyle: { borderWidth: 2 },
label: { fontWeight: 'bold' },
},
animationDuration: 350,
animationDurationUpdate: 300,
});
const chartOption = computed(() => {
if (!hasData.value) {
const trees = treesData.value;
if (trees.length === 0) {
return null;
}
return {
tooltip: {
trigger: 'item',
triggerOn: 'mousemove',
formatter(params) {
const data = params?.data;
if (!data) {
return '';
}
const val = data.value || {};
const lines = [`<b>${data.name}</b>`];
if (val.type === 'serial') {
lines.push('<span style="color:#64748B">成品序列號</span>');
} else if (val.type === 'root') {
lines.push('<span style="color:#3B82F6">根節點(晶批)</span>');
} else if (val.type === 'leaf') {
lines.push('<span style="color:#F59E0B">末端節點</span>');
} else if (val.type === 'branch') {
lines.push('<span style="color:#10B981">中間節點</span>');
}
if (val.cid && val.cid !== data.name) {
lines.push(`<span style="color:#94A3B8;font-size:11px">CID: ${val.cid}</span>`);
}
return lines.join('<br/>');
},
const tooltip = {
trigger: 'item',
triggerOn: 'mousemove',
formatter(params) {
const data = params?.data;
if (!data) {
return '';
}
const val = data.value || {};
const lines = [`<b>${data.name}</b>`];
if (val.type === 'serial') {
lines.push('<span style="color:#64748B">成品序列號</span>');
} else if (val.type === 'root') {
lines.push('<span style="color:#3B82F6">根節點(晶批)</span>');
} else if (val.type === 'leaf') {
lines.push('<span style="color:#F59E0B">末端節點</span>');
} else if (val.type === 'branch') {
lines.push('<span style="color:#10B981">中間節點</span>');
}
if (val.cid && val.cid !== data.name) {
lines.push(`<span style="color:#94A3B8;font-size:11px">CID: ${val.cid}</span>`);
}
return lines.join('<br/>');
},
series: [
{
type: 'tree',
layout: 'orthogonal',
orient: 'LR',
expandAndCollapse: true,
initialTreeDepth: -1,
roam: 'move',
symbol: 'circle',
symbolSize: 10,
label: {
show: true,
position: 'right',
fontSize: 11,
color: '#334155',
overflow: 'truncate',
ellipsis: '…',
width: 160,
},
lineStyle: {
width: 1.5,
color: '#CBD5E1',
curveness: 0.5,
},
emphasis: {
focus: 'ancestor',
itemStyle: {
borderWidth: 2,
},
label: {
fontWeight: 'bold',
},
},
animationDuration: 350,
animationDurationUpdate: 300,
};
// Single root → one series, full area
if (trees.length === 1) {
return {
tooltip,
series: [{
...TREE_SERIES_DEFAULTS,
left: 40,
right: 180,
top: 20,
bottom: 20,
data: echartsData.value,
},
],
};
data: [trees[0]],
}],
};
}
// Multiple roots → one series per tree, each in its own vertical band
const leafCounts = trees.map(countLeaves);
const totalLeaves = leafCounts.reduce((a, b) => a + b, 0);
const GAP_PX = 12;
const totalGapPercent = ((trees.length - 1) * GAP_PX / 800) * 100;
const usablePercent = 100 - totalGapPercent;
let cursor = 0;
const series = trees.map((tree, index) => {
const fraction = leafCounts[index] / totalLeaves;
const heightPercent = Math.max(10, usablePercent * fraction);
const topPercent = cursor;
cursor += heightPercent + (GAP_PX / 800) * 100;
return {
...TREE_SERIES_DEFAULTS,
left: 40,
right: 180,
top: `${topPercent}%`,
height: `${heightPercent}%`,
data: [tree],
};
});
return { tooltip, series };
});
function handleNodeClick(params) {

View File

@@ -24,16 +24,18 @@ function safeDate(value) {
return parsed ? parsed : null;
}
function fallbackTrackId() {
const first = props.historyRows[0];
return normalizeText(first?.WORKCENTERNAME) || 'UNKNOWN_TRACK';
}
// ── Tracks: group by (WORKCENTER_GROUP × LOT ID × Equipment) ──
const tracks = computed(() => {
const grouped = new Map();
props.historyRows.forEach((row, index) => {
const workcenterName = normalizeText(row?.WORKCENTERNAME) || `WORKCENTER-${index + 1}`;
const groupName = normalizeText(row?.WORKCENTER_GROUP)
|| normalizeText(row?.WORKCENTERNAME)
|| `WORKCENTER-${index + 1}`;
const lotId = normalizeText(row?.CONTAINERNAME || row?.CONTAINERID) || '';
const equipment = normalizeText(row?.EQUIPMENTNAME) || '';
const trackKey = `${groupName}||${lotId}||${equipment}`;
const start = safeDate(row?.TRACKINTIMESTAMP);
const end = safeDate(row?.TRACKOUTTIMESTAMP) || (start ? new Date(start.getTime() + (1000 * 60 * 30)) : null);
@@ -41,26 +43,30 @@ const tracks = computed(() => {
return;
}
if (!grouped.has(workcenterName)) {
grouped.set(workcenterName, []);
if (!grouped.has(trackKey)) {
grouped.set(trackKey, { groupName, lotId, equipment, bars: [] });
}
grouped.get(workcenterName).push({
id: `${workcenterName}-${index}`,
grouped.get(trackKey).bars.push({
id: `${trackKey}-${index}`,
start,
end,
type: workcenterName,
label: row?.SPECNAME || workcenterName,
detail: `${normalizeText(row?.CONTAINERNAME || row?.CONTAINERID)} | ${normalizeText(row?.EQUIPMENTNAME)}`,
type: groupName,
label: row?.SPECNAME || groupName,
});
});
return [...grouped.entries()].map(([trackId, bars]) => ({
id: trackId,
label: trackId,
return [...grouped.entries()].map(([trackKey, { groupName, lotId, equipment, bars }]) => ({
id: trackKey,
group: groupName,
label: groupName,
sublabels: [
lotId ? `LOT ID: ${lotId}` : '',
equipment ? `機台編號: ${equipment}` : '',
].filter(Boolean),
layers: [
{
id: `${trackId}-lots`,
id: `${trackKey}-lots`,
bars,
opacity: 0.85,
},
@@ -68,6 +74,22 @@ const tracks = computed(() => {
}));
});
// ── Events: resolve trackId to compound key via group matching ──
const groupToFirstTrackId = computed(() => {
const map = new Map();
tracks.value.forEach((track) => {
if (!map.has(track.group)) {
map.set(track.group, track.id);
}
});
return map;
});
function resolveEventTrackId(row) {
const group = normalizeText(row?.WORKCENTER_GROUP) || normalizeText(row?.WORKCENTERNAME) || '';
return groupToFirstTrackId.value.get(group) || group;
}
const events = computed(() => {
const markers = [];
@@ -79,7 +101,7 @@ const events = computed(() => {
markers.push({
id: `hold-${index}`,
trackId: normalizeText(row?.WORKCENTERNAME) || fallbackTrackId(),
trackId: resolveEventTrackId(row),
time,
type: 'HOLD',
shape: 'diamond',
@@ -96,7 +118,7 @@ const events = computed(() => {
markers.push({
id: `material-${index}`,
trackId: normalizeText(row?.WORKCENTERNAME) || fallbackTrackId(),
trackId: resolveEventTrackId(row),
time,
type: 'MATERIAL',
shape: 'triangle',
@@ -114,14 +136,22 @@ const colorMap = computed(() => {
MATERIAL: '#0ea5e9',
};
// Color by workcenter group (not compound key) so same group = same color
const seen = new Set();
tracks.value.forEach((track) => {
colors[track.id] = hashColor(track.id);
if (!seen.has(track.group)) {
seen.add(track.group);
colors[track.group] = hashColor(track.group);
}
});
return colors;
});
const timeRange = computed(() => {
// Derive range ONLY from history bars so it updates when LOT selection
// or workcenter group filter changes. Hold/material events are supplementary
// markers and should not stretch the visible range.
const timestamps = [];
tracks.value.forEach((track) => {
@@ -133,10 +163,6 @@ const timeRange = computed(() => {
});
});
events.value.forEach((eventItem) => {
timestamps.push(eventItem.time?.getTime?.() || 0);
});
const normalized = timestamps.filter((item) => Number.isFinite(item) && item > 0);
if (normalized.length === 0) {
return null;
@@ -163,15 +189,15 @@ const timeRange = computed(() => {
歷程資料不足無法產生 Timeline
</div>
<div v-else class="max-h-[420px] overflow-y-auto rounded-card border border-stroke-soft">
<div v-else class="max-h-[520px] overflow-y-auto">
<TimelineChart
:tracks="tracks"
:events="events"
:time-range="timeRange"
:color-map="colorMap"
:label-width="180"
:track-row-height="46"
:min-chart-width="1040"
:label-width="200"
:track-row-height="58"
:min-chart-width="600"
/>
</div>
</section>

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { formatDateTime, normalizeText, parseDateTime } from '../../query-tool/utils/values.js';
@@ -30,11 +30,13 @@ const props = defineProps({
},
minChartWidth: {
type: Number,
default: 960,
default: 600,
},
});
const AXIS_HEIGHT = 42;
const AXIS_HEIGHT = 32;
const RANGE_PAD_RATIO = 0.03;
const tooltipRef = ref({
visible: false,
x: 0,
@@ -43,6 +45,7 @@ const tooltipRef = ref({
lines: [],
});
const containerRef = ref(null);
const scrollRef = ref(null);
function toTimestamp(value) {
if (typeof value === 'number') {
@@ -80,34 +83,35 @@ const normalizedTimeRange = computed(() => {
const explicitStart = toTimestamp(props.timeRange?.start);
const explicitEnd = toTimestamp(props.timeRange?.end);
let startMs;
let endMs;
if (explicitStart !== null && explicitEnd !== null && explicitEnd > explicitStart) {
return {
startMs: explicitStart,
endMs: explicitEnd,
};
}
const timestamps = collectDomainTimestamps();
if (timestamps.length === 0) {
const now = Date.now();
return {
startMs: now - (1000 * 60 * 60),
endMs: now + (1000 * 60 * 60),
};
}
const startMs = Math.min(...timestamps);
const endMs = Math.max(...timestamps);
if (endMs === startMs) {
return {
startMs,
endMs: startMs + (1000 * 60 * 60),
};
startMs = explicitStart;
endMs = explicitEnd;
} else {
const timestamps = collectDomainTimestamps();
if (timestamps.length === 0) {
const now = Date.now();
return {
startMs: now - (1000 * 60 * 60),
endMs: now + (1000 * 60 * 60),
};
}
startMs = Math.min(...timestamps);
endMs = Math.max(...timestamps);
if (endMs === startMs) {
endMs = startMs + (1000 * 60 * 60);
}
}
// Add a small padding so bars don't sit at the very edge
const span = endMs - startMs;
const pad = span * RANGE_PAD_RATIO;
return {
startMs,
endMs,
startMs: startMs - pad,
endMs: endMs + pad,
};
});
@@ -119,8 +123,16 @@ const trackCount = computed(() => props.tracks.length);
const chartWidth = computed(() => {
const hours = totalDurationMs.value / (1000 * 60 * 60);
const estimated = Math.round(hours * 36);
return Math.max(props.minChartWidth, estimated);
// Adaptive scaling: longer durations get fewer px/hour to stay compact
let pxPerHour;
if (hours <= 6) pxPerHour = 120;
else if (hours <= 24) pxPerHour = 60;
else if (hours <= 72) pxPerHour = 30;
else if (hours <= 168) pxPerHour = 16;
else if (hours <= 720) pxPerHour = 6;
else pxPerHour = 3;
return Math.max(props.minChartWidth, Math.round(hours * pxPerHour));
});
const svgHeight = computed(() => AXIS_HEIGHT + trackCount.value * props.trackRowHeight + 2);
@@ -159,37 +171,45 @@ function normalizeEvent(event) {
};
}
const HOUR_MS = 1000 * 60 * 60;
const DAY_MS = HOUR_MS * 24;
const timelineTicks = computed(() => {
const ticks = [];
const rangeMs = totalDurationMs.value;
const rangeHours = rangeMs / (1000 * 60 * 60);
const rangeHours = rangeMs / HOUR_MS;
const stepMs = rangeHours <= 48
? (1000 * 60 * 60)
: (1000 * 60 * 60 * 24);
let stepMs;
if (rangeHours <= 12) stepMs = HOUR_MS;
else if (rangeHours <= 48) stepMs = HOUR_MS * 2;
else if (rangeHours <= 168) stepMs = HOUR_MS * 6;
else if (rangeHours <= 720) stepMs = DAY_MS;
else stepMs = DAY_MS * 3;
const start = normalizedTimeRange.value.startMs;
const end = normalizedTimeRange.value.endMs;
let cursor = start;
// Snap cursor to the nearest clean boundary
const snapMs = stepMs >= DAY_MS ? DAY_MS : HOUR_MS;
let cursor = Math.ceil(start / snapMs) * snapMs;
while (cursor <= end) {
const date = new Date(cursor);
const label = stepMs < (1000 * 60 * 60 * 24)
? `${String(date.getHours()).padStart(2, '0')}:00`
: `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
ticks.push({
timeMs: cursor,
label,
});
let label;
if (stepMs < DAY_MS) {
label = `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:00`;
} else {
label = `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
ticks.push({ timeMs: cursor, label });
cursor += stepMs;
}
if (ticks.length < 2) {
ticks.push({
timeMs: end,
label: stepMs < (1000 * 60 * 60 * 24) ? 'End' : '結束',
label: '結束',
});
}
@@ -254,17 +274,26 @@ const legendItems = computed(() => {
}));
});
// ── Tooltip (fixed-position, teleported to body) ──────────────
const TOOLTIP_MAX_W = 288; // max-w-72
const TOOLTIP_EST_H = 120;
function showTooltip(event, title, lines = []) {
const host = containerRef.value;
if (!host) {
return;
let x = event.clientX + 14;
let y = event.clientY + 14;
// Flip when near viewport edges
if (x + TOOLTIP_MAX_W > window.innerWidth - 8) {
x = event.clientX - TOOLTIP_MAX_W - 8;
}
if (y + TOOLTIP_EST_H > window.innerHeight - 8) {
y = event.clientY - TOOLTIP_EST_H - 8;
}
const bounds = host.getBoundingClientRect();
tooltipRef.value = {
visible: true,
x: event.clientX - bounds.left + 12,
y: event.clientY - bounds.top + 12,
x: Math.max(4, x),
y: Math.max(4, y),
title,
lines,
};
@@ -274,6 +303,21 @@ function hideTooltip() {
tooltipRef.value.visible = false;
}
// Hide tooltip when the chart area scrolls (fixed tooltip would become stale)
function handleScroll() {
if (tooltipRef.value.visible) {
hideTooltip();
}
}
onMounted(() => {
scrollRef.value?.addEventListener('scroll', handleScroll, { passive: true });
});
onBeforeUnmount(() => {
scrollRef.value?.removeEventListener('scroll', handleScroll);
hideTooltip();
});
function handleBarHover(mouseEvent, bar, trackLabel) {
const normalized = normalizeBar(bar);
if (!normalized) {
@@ -282,7 +326,7 @@ function handleBarHover(mouseEvent, bar, trackLabel) {
const start = formatDateTime(normalized.start);
const end = formatDateTime(normalized.end);
const durationHours = ((normalized.endMs - normalized.startMs) / (1000 * 60 * 60)).toFixed(2);
const durationHours = ((normalized.endMs - normalized.startMs) / HOUR_MS).toFixed(2);
const title = normalizeText(normalized.label) || normalizeText(normalized.type) || '區段';
const lines = [
@@ -325,7 +369,7 @@ function eventPath(type, x, y) {
<template>
<div class="rounded-card border border-stroke-soft bg-white p-3">
<div class="mb-3 flex flex-wrap items-center gap-3 text-xs text-slate-600">
<div class="mb-2 flex flex-wrap items-center gap-3 text-xs text-slate-600">
<span class="font-medium text-slate-700">Timeline</span>
<div v-for="item in legendItems" :key="item.key" class="flex items-center gap-1">
<span class="inline-block size-2 rounded-full" :style="{ backgroundColor: item.color }" />
@@ -335,26 +379,40 @@ function eventPath(type, x, y) {
<div
ref="containerRef"
class="relative overflow-hidden rounded-card border border-stroke-soft bg-surface-muted/30"
class="relative rounded-card border border-stroke-soft bg-surface-muted/30"
@mouseleave="hideTooltip"
>
<div class="grid" :style="{ gridTemplateColumns: `${labelWidth}px minmax(0, 1fr)` }">
<!-- Track labels (sticky left) -->
<div class="sticky left-0 z-20 border-r border-stroke-soft bg-white">
<div class="flex h-[42px] items-center border-b border-stroke-soft px-3 text-xs font-semibold tracking-wide text-slate-500">
<div
class="flex items-center border-b border-stroke-soft px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400"
:style="{ height: `${AXIS_HEIGHT}px` }"
>
Track
</div>
<div
v-for="track in tracks"
:key="track.id || track.label"
class="flex items-center border-b border-stroke-soft/70 px-3 text-xs text-slate-700"
class="flex flex-col justify-center border-b border-stroke-soft/70 px-3"
:style="{ height: `${trackRowHeight}px` }"
>
<span class="line-clamp-1">{{ track.label }}</span>
<span class="truncate text-xs font-medium text-slate-700">{{ track.label }}</span>
<!-- sublabels (array) takes priority over sublabel (string) -->
<template v-if="track.sublabels?.length">
<span
v-for="sub in track.sublabels"
:key="sub"
class="truncate text-[10px] leading-tight text-slate-400"
>{{ sub }}</span>
</template>
<span v-else-if="track.sublabel" class="truncate text-[10px] leading-tight text-slate-400">{{ track.sublabel }}</span>
</div>
</div>
<div class="overflow-x-auto">
<!-- Chart area (scrollable) -->
<div ref="scrollRef" class="overflow-x-auto">
<svg
:width="chartWidth"
:height="svgHeight"
@@ -363,8 +421,9 @@ function eventPath(type, x, y) {
>
<rect x="0" y="0" :width="chartWidth" :height="svgHeight" fill="#ffffff" />
<!-- Time axis -->
<g>
<line x1="0" :x2="chartWidth" y1="41" y2="41" stroke="#cbd5e1" stroke-width="1" />
<line x1="0" :x2="chartWidth" :y1="AXIS_HEIGHT - 1" :y2="AXIS_HEIGHT - 1" stroke="#cbd5e1" stroke-width="1" />
<g v-for="tick in timelineTicks" :key="tick.timeMs">
<line
:x1="xByTimestamp(tick.timeMs)"
@@ -376,16 +435,18 @@ function eventPath(type, x, y) {
stroke-dasharray="2 3"
/>
<text
:x="xByTimestamp(tick.timeMs) + 2"
y="14"
:x="xByTimestamp(tick.timeMs) + 3"
y="13"
fill="#475569"
font-size="11"
font-size="10"
font-family="ui-monospace, monospace"
>
{{ tick.label }}
</text>
</g>
</g>
<!-- Track rows -->
<g v-for="(track, trackIndex) in tracks" :key="track.id || track.label">
<rect
x="0"
@@ -396,6 +457,7 @@ function eventPath(type, x, y) {
opacity="0.45"
/>
<!-- Bars -->
<g v-for="(layer, layerIndex) in (track.layers || [])" :key="layer.id || layerIndex">
<template
v-for="(bar, barIndex) in (layer.bars || [])"
@@ -405,16 +467,18 @@ function eventPath(type, x, y) {
v-if="normalizeBar(bar)"
:x="xByTimestamp(normalizeBar(bar).startMs)"
:y="layerGeometry(trackIndex, layerIndex, (track.layers || []).length).y"
:width="Math.max(2, xByTimestamp(normalizeBar(bar).endMs) - xByTimestamp(normalizeBar(bar).startMs))"
:width="Math.max(4, xByTimestamp(normalizeBar(bar).endMs) - xByTimestamp(normalizeBar(bar).startMs))"
:height="layerGeometry(trackIndex, layerIndex, (track.layers || []).length).height"
:fill="bar.color || resolveColor(bar.type)"
:opacity="layer.opacity ?? (layerIndex === 0 ? 0.45 : 0.9)"
rx="3"
class="cursor-pointer"
@mousemove="handleBarHover($event, bar, track.label)"
/>
</template>
</g>
<!-- Event markers -->
<template v-for="(eventItem, eventIndex) in events" :key="eventItem.id || `${trackIndex}-event-${eventIndex}`">
<path
v-if="normalizeEvent(eventItem) && normalizeText(eventItem.trackId) === normalizeText(track.id)"
@@ -422,6 +486,7 @@ function eventPath(type, x, y) {
:fill="eventItem.color || resolveColor(eventItem.type)"
stroke="#0f172a"
stroke-width="0.5"
class="cursor-pointer"
@mousemove="handleEventHover($event, eventItem, track.label)"
/>
</template>
@@ -429,15 +494,27 @@ function eventPath(type, x, y) {
</svg>
</div>
</div>
<div
v-if="tooltipRef.visible"
class="pointer-events-none absolute z-30 max-w-72 rounded-card border border-stroke-soft bg-slate-900/95 px-2 py-1.5 text-[11px] text-slate-100 shadow-lg"
:style="{ left: `${tooltipRef.x}px`, top: `${tooltipRef.y}px` }"
>
<p class="font-semibold text-white">{{ tooltipRef.title }}</p>
<p v-for="line in tooltipRef.lines" :key="line" class="mt-0.5 text-slate-200">{{ line }}</p>
</div>
</div>
<!-- Tooltip: teleported to body so it's never clipped by overflow -->
<Teleport to="body">
<Transition name="tooltip-fade">
<div
v-if="tooltipRef.visible"
class="pointer-events-none fixed z-[9999] max-w-72 rounded-lg border border-slate-600/30 bg-slate-900/95 px-2.5 py-2 text-[11px] leading-relaxed text-slate-100 shadow-xl backdrop-blur-sm"
:style="{ left: `${tooltipRef.x}px`, top: `${tooltipRef.y}px` }"
>
<p class="font-semibold text-white">{{ tooltipRef.title }}</p>
<p v-for="line in tooltipRef.lines" :key="line" class="mt-0.5 text-slate-300">{{ line }}</p>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.tooltip-fade-enter-active { transition: opacity 0.12s ease-out; }
.tooltip-fade-leave-active { transition: opacity 0.08s ease-in; }
.tooltip-fade-enter-from,
.tooltip-fade-leave-to { opacity: 0; }
</style>