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:
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user