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

@@ -6,6 +6,7 @@
"/hold-overview": {"content_cutover_enabled": true}, "/hold-overview": {"content_cutover_enabled": true},
"/hold-detail": {"content_cutover_enabled": true}, "/hold-detail": {"content_cutover_enabled": true},
"/hold-history": {"content_cutover_enabled": true}, "/hold-history": {"content_cutover_enabled": true},
"/reject-history": {"content_cutover_enabled": false},
"/resource": {"content_cutover_enabled": true}, "/resource": {"content_cutover_enabled": true},
"/resource-history": {"content_cutover_enabled": true}, "/resource-history": {"content_cutover_enabled": true},
"/qc-gate": {"content_cutover_enabled": true}, "/qc-gate": {"content_cutover_enabled": true},

View File

@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "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" "test": "node --test tests/*.test.js"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -192,17 +192,11 @@ async function handleResolveLots() {
return; 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); await lotLineage.primeResolvedLots(lotResolve.resolvedLots.value);
lotLineage.clearSelection();
const treeRootIds = lotLineage.treeRoots.value; lotDetail.clearTabData();
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);
} }
async function handleSelectNodes(containerIds) { 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) { if (props.treeRoots.length === 0) {
return []; return [];
} }
const visited = new Set(); const globalVisited = new Set();
return props.treeRoots return props.treeRoots
.map((rootId) => buildNode(rootId, visited)) .map((rootId) => buildNode(rootId, globalVisited))
.filter(Boolean); .filter(Boolean);
}); });
const hasData = computed(() => echartsData.value.length > 0); const hasData = computed(() => treesData.value.length > 0);
function countNodes(nodes) { function countLeaves(node) {
let count = 0; if (!node.children || node.children.length === 0) {
function walk(list) { return 1;
list.forEach((node) => {
count += 1;
if (node.children?.length > 0) {
walk(node.children);
}
});
} }
walk(nodes); return node.children.reduce((sum, child) => sum + countLeaves(child), 0);
return count;
} }
const chartHeight = computed(() => { const chartHeight = computed(() => {
const total = countNodes(echartsData.value); const totalLeaves = treesData.value.reduce((sum, tree) => sum + countLeaves(tree), 0);
const base = Math.max(300, Math.min(800, total * 28)); const base = Math.max(300, Math.min(1200, totalLeaves * 36));
return `${base}px`; 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(() => { const chartOption = computed(() => {
if (!hasData.value) { const trees = treesData.value;
if (trees.length === 0) {
return null; return null;
} }
return { const tooltip = {
tooltip: { trigger: 'item',
trigger: 'item', triggerOn: 'mousemove',
triggerOn: 'mousemove', formatter(params) {
formatter(params) { const data = params?.data;
const data = params?.data; if (!data) {
if (!data) { return '';
return ''; }
} const val = data.value || {};
const val = data.value || {}; const lines = [`<b>${data.name}</b>`];
const lines = [`<b>${data.name}</b>`]; if (val.type === 'serial') {
if (val.type === 'serial') { lines.push('<span style="color:#64748B">成品序列號</span>');
lines.push('<span style="color:#64748B">成品序列號</span>'); } else if (val.type === 'root') {
} else if (val.type === 'root') { lines.push('<span style="color:#3B82F6">根節點(晶批)</span>');
lines.push('<span style="color:#3B82F6">根節點(晶批)</span>'); } else if (val.type === 'leaf') {
} else if (val.type === 'leaf') { lines.push('<span style="color:#F59E0B">末端節點</span>');
lines.push('<span style="color:#F59E0B">末端節點</span>'); } else if (val.type === 'branch') {
} else if (val.type === 'branch') { lines.push('<span style="color:#10B981">中間節點</span>');
lines.push('<span style="color:#10B981">中間節點</span>'); }
} if (val.cid && val.cid !== data.name) {
if (val.cid && val.cid !== data.name) { lines.push(`<span style="color:#94A3B8;font-size:11px">CID: ${val.cid}</span>`);
lines.push(`<span style="color:#94A3B8;font-size:11px">CID: ${val.cid}</span>`); }
} return lines.join('<br/>');
return lines.join('<br/>');
},
}, },
series: [ };
{
type: 'tree', // Single root → one series, full area
layout: 'orthogonal', if (trees.length === 1) {
orient: 'LR', return {
expandAndCollapse: true, tooltip,
initialTreeDepth: -1, series: [{
roam: 'move', ...TREE_SERIES_DEFAULTS,
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,
left: 40, left: 40,
right: 180, right: 180,
top: 20, top: 20,
bottom: 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) { function handleNodeClick(params) {

View File

@@ -24,16 +24,18 @@ function safeDate(value) {
return parsed ? parsed : null; return parsed ? parsed : null;
} }
function fallbackTrackId() { // ── Tracks: group by (WORKCENTER_GROUP × LOT ID × Equipment) ──
const first = props.historyRows[0];
return normalizeText(first?.WORKCENTERNAME) || 'UNKNOWN_TRACK';
}
const tracks = computed(() => { const tracks = computed(() => {
const grouped = new Map(); const grouped = new Map();
props.historyRows.forEach((row, index) => { 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 start = safeDate(row?.TRACKINTIMESTAMP);
const end = safeDate(row?.TRACKOUTTIMESTAMP) || (start ? new Date(start.getTime() + (1000 * 60 * 30)) : null); const end = safeDate(row?.TRACKOUTTIMESTAMP) || (start ? new Date(start.getTime() + (1000 * 60 * 30)) : null);
@@ -41,26 +43,30 @@ const tracks = computed(() => {
return; return;
} }
if (!grouped.has(workcenterName)) { if (!grouped.has(trackKey)) {
grouped.set(workcenterName, []); grouped.set(trackKey, { groupName, lotId, equipment, bars: [] });
} }
grouped.get(workcenterName).push({ grouped.get(trackKey).bars.push({
id: `${workcenterName}-${index}`, id: `${trackKey}-${index}`,
start, start,
end, end,
type: workcenterName, type: groupName,
label: row?.SPECNAME || workcenterName, label: row?.SPECNAME || groupName,
detail: `${normalizeText(row?.CONTAINERNAME || row?.CONTAINERID)} | ${normalizeText(row?.EQUIPMENTNAME)}`,
}); });
}); });
return [...grouped.entries()].map(([trackId, bars]) => ({ return [...grouped.entries()].map(([trackKey, { groupName, lotId, equipment, bars }]) => ({
id: trackId, id: trackKey,
label: trackId, group: groupName,
label: groupName,
sublabels: [
lotId ? `LOT ID: ${lotId}` : '',
equipment ? `機台編號: ${equipment}` : '',
].filter(Boolean),
layers: [ layers: [
{ {
id: `${trackId}-lots`, id: `${trackKey}-lots`,
bars, bars,
opacity: 0.85, 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 events = computed(() => {
const markers = []; const markers = [];
@@ -79,7 +101,7 @@ const events = computed(() => {
markers.push({ markers.push({
id: `hold-${index}`, id: `hold-${index}`,
trackId: normalizeText(row?.WORKCENTERNAME) || fallbackTrackId(), trackId: resolveEventTrackId(row),
time, time,
type: 'HOLD', type: 'HOLD',
shape: 'diamond', shape: 'diamond',
@@ -96,7 +118,7 @@ const events = computed(() => {
markers.push({ markers.push({
id: `material-${index}`, id: `material-${index}`,
trackId: normalizeText(row?.WORKCENTERNAME) || fallbackTrackId(), trackId: resolveEventTrackId(row),
time, time,
type: 'MATERIAL', type: 'MATERIAL',
shape: 'triangle', shape: 'triangle',
@@ -114,14 +136,22 @@ const colorMap = computed(() => {
MATERIAL: '#0ea5e9', MATERIAL: '#0ea5e9',
}; };
// Color by workcenter group (not compound key) so same group = same color
const seen = new Set();
tracks.value.forEach((track) => { 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; return colors;
}); });
const timeRange = computed(() => { 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 = []; const timestamps = [];
tracks.value.forEach((track) => { 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); const normalized = timestamps.filter((item) => Number.isFinite(item) && item > 0);
if (normalized.length === 0) { if (normalized.length === 0) {
return null; return null;
@@ -163,15 +189,15 @@ const timeRange = computed(() => {
歷程資料不足無法產生 Timeline 歷程資料不足無法產生 Timeline
</div> </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 <TimelineChart
:tracks="tracks" :tracks="tracks"
:events="events" :events="events"
:time-range="timeRange" :time-range="timeRange"
:color-map="colorMap" :color-map="colorMap"
:label-width="180" :label-width="200"
:track-row-height="46" :track-row-height="58"
:min-chart-width="1040" :min-chart-width="600"
/> />
</div> </div>
</section> </section>

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { formatDateTime, normalizeText, parseDateTime } from '../../query-tool/utils/values.js'; import { formatDateTime, normalizeText, parseDateTime } from '../../query-tool/utils/values.js';
@@ -30,11 +30,13 @@ const props = defineProps({
}, },
minChartWidth: { minChartWidth: {
type: Number, type: Number,
default: 960, default: 600,
}, },
}); });
const AXIS_HEIGHT = 42; const AXIS_HEIGHT = 32;
const RANGE_PAD_RATIO = 0.03;
const tooltipRef = ref({ const tooltipRef = ref({
visible: false, visible: false,
x: 0, x: 0,
@@ -43,6 +45,7 @@ const tooltipRef = ref({
lines: [], lines: [],
}); });
const containerRef = ref(null); const containerRef = ref(null);
const scrollRef = ref(null);
function toTimestamp(value) { function toTimestamp(value) {
if (typeof value === 'number') { if (typeof value === 'number') {
@@ -80,34 +83,35 @@ const normalizedTimeRange = computed(() => {
const explicitStart = toTimestamp(props.timeRange?.start); const explicitStart = toTimestamp(props.timeRange?.start);
const explicitEnd = toTimestamp(props.timeRange?.end); const explicitEnd = toTimestamp(props.timeRange?.end);
let startMs;
let endMs;
if (explicitStart !== null && explicitEnd !== null && explicitEnd > explicitStart) { if (explicitStart !== null && explicitEnd !== null && explicitEnd > explicitStart) {
return { startMs = explicitStart;
startMs: explicitStart, endMs = explicitEnd;
endMs: explicitEnd, } else {
}; const timestamps = collectDomainTimestamps();
} if (timestamps.length === 0) {
const now = Date.now();
const timestamps = collectDomainTimestamps(); return {
if (timestamps.length === 0) { startMs: now - (1000 * 60 * 60),
const now = Date.now(); endMs: now + (1000 * 60 * 60),
return { };
startMs: now - (1000 * 60 * 60), }
endMs: now + (1000 * 60 * 60),
}; startMs = Math.min(...timestamps);
} endMs = Math.max(...timestamps);
if (endMs === startMs) {
const startMs = Math.min(...timestamps); endMs = startMs + (1000 * 60 * 60);
const endMs = Math.max(...timestamps); }
if (endMs === startMs) {
return {
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 { return {
startMs, startMs: startMs - pad,
endMs, endMs: endMs + pad,
}; };
}); });
@@ -119,8 +123,16 @@ const trackCount = computed(() => props.tracks.length);
const chartWidth = computed(() => { const chartWidth = computed(() => {
const hours = totalDurationMs.value / (1000 * 60 * 60); const hours = totalDurationMs.value / (1000 * 60 * 60);
const estimated = Math.round(hours * 36); // Adaptive scaling: longer durations get fewer px/hour to stay compact
return Math.max(props.minChartWidth, estimated); 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); 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 timelineTicks = computed(() => {
const ticks = []; const ticks = [];
const rangeMs = totalDurationMs.value; const rangeMs = totalDurationMs.value;
const rangeHours = rangeMs / (1000 * 60 * 60); const rangeHours = rangeMs / HOUR_MS;
const stepMs = rangeHours <= 48 let stepMs;
? (1000 * 60 * 60) if (rangeHours <= 12) stepMs = HOUR_MS;
: (1000 * 60 * 60 * 24); 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 start = normalizedTimeRange.value.startMs;
const end = normalizedTimeRange.value.endMs; 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) { while (cursor <= end) {
const date = new Date(cursor); const date = new Date(cursor);
const label = stepMs < (1000 * 60 * 60 * 24) let label;
? `${String(date.getHours()).padStart(2, '0')}:00` if (stepMs < DAY_MS) {
: `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; label = `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:00`;
} else {
ticks.push({ label = `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
timeMs: cursor, }
label,
});
ticks.push({ timeMs: cursor, label });
cursor += stepMs; cursor += stepMs;
} }
if (ticks.length < 2) { if (ticks.length < 2) {
ticks.push({ ticks.push({
timeMs: end, 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 = []) { function showTooltip(event, title, lines = []) {
const host = containerRef.value; let x = event.clientX + 14;
if (!host) { let y = event.clientY + 14;
return;
// 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 = { tooltipRef.value = {
visible: true, visible: true,
x: event.clientX - bounds.left + 12, x: Math.max(4, x),
y: event.clientY - bounds.top + 12, y: Math.max(4, y),
title, title,
lines, lines,
}; };
@@ -274,6 +303,21 @@ function hideTooltip() {
tooltipRef.value.visible = false; 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) { function handleBarHover(mouseEvent, bar, trackLabel) {
const normalized = normalizeBar(bar); const normalized = normalizeBar(bar);
if (!normalized) { if (!normalized) {
@@ -282,7 +326,7 @@ function handleBarHover(mouseEvent, bar, trackLabel) {
const start = formatDateTime(normalized.start); const start = formatDateTime(normalized.start);
const end = formatDateTime(normalized.end); 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 title = normalizeText(normalized.label) || normalizeText(normalized.type) || '區段';
const lines = [ const lines = [
@@ -325,7 +369,7 @@ function eventPath(type, x, y) {
<template> <template>
<div class="rounded-card border border-stroke-soft bg-white p-3"> <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> <span class="font-medium text-slate-700">Timeline</span>
<div v-for="item in legendItems" :key="item.key" class="flex items-center gap-1"> <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 }" /> <span class="inline-block size-2 rounded-full" :style="{ backgroundColor: item.color }" />
@@ -335,26 +379,40 @@ function eventPath(type, x, y) {
<div <div
ref="containerRef" 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" @mouseleave="hideTooltip"
> >
<div class="grid" :style="{ gridTemplateColumns: `${labelWidth}px minmax(0, 1fr)` }"> <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="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 Track
</div> </div>
<div <div
v-for="track in tracks" v-for="track in tracks"
:key="track.id || track.label" :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` }" :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> </div>
<div class="overflow-x-auto"> <!-- Chart area (scrollable) -->
<div ref="scrollRef" class="overflow-x-auto">
<svg <svg
:width="chartWidth" :width="chartWidth"
:height="svgHeight" :height="svgHeight"
@@ -363,8 +421,9 @@ function eventPath(type, x, y) {
> >
<rect x="0" y="0" :width="chartWidth" :height="svgHeight" fill="#ffffff" /> <rect x="0" y="0" :width="chartWidth" :height="svgHeight" fill="#ffffff" />
<!-- Time axis -->
<g> <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"> <g v-for="tick in timelineTicks" :key="tick.timeMs">
<line <line
:x1="xByTimestamp(tick.timeMs)" :x1="xByTimestamp(tick.timeMs)"
@@ -376,16 +435,18 @@ function eventPath(type, x, y) {
stroke-dasharray="2 3" stroke-dasharray="2 3"
/> />
<text <text
:x="xByTimestamp(tick.timeMs) + 2" :x="xByTimestamp(tick.timeMs) + 3"
y="14" y="13"
fill="#475569" fill="#475569"
font-size="11" font-size="10"
font-family="ui-monospace, monospace"
> >
{{ tick.label }} {{ tick.label }}
</text> </text>
</g> </g>
</g> </g>
<!-- Track rows -->
<g v-for="(track, trackIndex) in tracks" :key="track.id || track.label"> <g v-for="(track, trackIndex) in tracks" :key="track.id || track.label">
<rect <rect
x="0" x="0"
@@ -396,6 +457,7 @@ function eventPath(type, x, y) {
opacity="0.45" opacity="0.45"
/> />
<!-- Bars -->
<g v-for="(layer, layerIndex) in (track.layers || [])" :key="layer.id || layerIndex"> <g v-for="(layer, layerIndex) in (track.layers || [])" :key="layer.id || layerIndex">
<template <template
v-for="(bar, barIndex) in (layer.bars || [])" v-for="(bar, barIndex) in (layer.bars || [])"
@@ -405,16 +467,18 @@ function eventPath(type, x, y) {
v-if="normalizeBar(bar)" v-if="normalizeBar(bar)"
:x="xByTimestamp(normalizeBar(bar).startMs)" :x="xByTimestamp(normalizeBar(bar).startMs)"
:y="layerGeometry(trackIndex, layerIndex, (track.layers || []).length).y" :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" :height="layerGeometry(trackIndex, layerIndex, (track.layers || []).length).height"
:fill="bar.color || resolveColor(bar.type)" :fill="bar.color || resolveColor(bar.type)"
:opacity="layer.opacity ?? (layerIndex === 0 ? 0.45 : 0.9)" :opacity="layer.opacity ?? (layerIndex === 0 ? 0.45 : 0.9)"
rx="3" rx="3"
class="cursor-pointer"
@mousemove="handleBarHover($event, bar, track.label)" @mousemove="handleBarHover($event, bar, track.label)"
/> />
</template> </template>
</g> </g>
<!-- Event markers -->
<template v-for="(eventItem, eventIndex) in events" :key="eventItem.id || `${trackIndex}-event-${eventIndex}`"> <template v-for="(eventItem, eventIndex) in events" :key="eventItem.id || `${trackIndex}-event-${eventIndex}`">
<path <path
v-if="normalizeEvent(eventItem) && normalizeText(eventItem.trackId) === normalizeText(track.id)" 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)" :fill="eventItem.color || resolveColor(eventItem.type)"
stroke="#0f172a" stroke="#0f172a"
stroke-width="0.5" stroke-width="0.5"
class="cursor-pointer"
@mousemove="handleEventHover($event, eventItem, track.label)" @mousemove="handleEventHover($event, eventItem, track.label)"
/> />
</template> </template>
@@ -429,15 +494,27 @@ function eventPath(type, x, y) {
</svg> </svg>
</div> </div>
</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> </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> </div>
</template> </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>

View File

@@ -381,6 +381,22 @@ def _get_workcenters_for_groups(groups: List[str]) -> List[str]:
return get_workcenters_for_groups(groups) return get_workcenters_for_groups(groups)
def _enrich_workcenter_group(rows: list) -> list:
"""Add WORKCENTER_GROUP field to each history row based on WORKCENTERNAME.
Uses filter_cache workcenter mapping to resolve the group name.
"""
from mes_dashboard.services.filter_cache import get_workcenter_mapping
mapping = get_workcenter_mapping() or {}
for row in rows:
wc_name = row.get('WORKCENTERNAME')
if wc_name and wc_name in mapping:
row['WORKCENTER_GROUP'] = mapping[wc_name].get('group', wc_name)
else:
row['WORKCENTER_GROUP'] = wc_name or ''
return rows
def get_lot_history( def get_lot_history(
container_id: str, container_id: str,
workcenter_groups: Optional[List[str]] = None workcenter_groups: Optional[List[str]] = None
@@ -415,6 +431,7 @@ def get_lot_history(
f"({len(workcenters)} workcenters)" f"({len(workcenters)} workcenters)"
) )
_enrich_workcenter_group(rows)
data = _df_to_records(pd.DataFrame(rows)) data = _df_to_records(pd.DataFrame(rows))
logger.debug(f"LOT history: {len(data)} records for {container_id}") logger.debug(f"LOT history: {len(data)} records for {container_id}")
@@ -520,6 +537,7 @@ def get_lot_history_batch(
if row.get('WORKCENTERNAME') in workcenter_set if row.get('WORKCENTERNAME') in workcenter_set
] ]
_enrich_workcenter_group(rows)
data = _df_to_records(pd.DataFrame(rows)) data = _df_to_records(pd.DataFrame(rows))
logger.debug( logger.debug(