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