Files
DashBoard/frontend/src/query-tool/components/LineageTreeChart.vue

495 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { computed, ref } from 'vue';
import VChart from 'vue-echarts';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { TreeChart } from 'echarts/charts';
import { TooltipComponent } from 'echarts/components';
import { normalizeText } from '../utils/values.js';
use([CanvasRenderer, TreeChart, TooltipComponent]);
const NODE_COLORS = {
wafer: '#2563EB',
gc: '#06B6D4',
ga: '#10B981',
gd: '#EF4444',
root: '#3B82F6',
branch: '#10B981',
leaf: '#F59E0B',
serial: '#94A3B8',
};
const EDGE_STYLES = Object.freeze({
split_from: { color: '#CBD5E1', type: 'solid', width: 1.5 },
merge_source: { color: '#F59E0B', type: 'dashed', width: 1.8 },
wafer_origin: { color: '#2563EB', type: 'dotted', width: 1.8 },
gd_rework_source: { color: '#EF4444', type: 'dashed', width: 1.8 },
default: { color: '#CBD5E1', type: 'solid', width: 1.5 },
});
const LABEL_BASE_STYLE = Object.freeze({
backgroundColor: 'rgba(255,255,255,0.92)',
borderRadius: 3,
padding: [1, 4],
});
const props = defineProps({
treeRoots: {
type: Array,
default: () => [],
},
lineageMap: {
type: Object,
required: true,
},
nameMap: {
type: Object,
default: () => new Map(),
},
nodeMetaMap: {
type: Object,
default: () => new Map(),
},
edgeTypeMap: {
type: Object,
default: () => new Map(),
},
leafSerials: {
type: Object,
default: () => new Map(),
},
notFound: {
type: Array,
default: () => [],
},
selectedContainerIds: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '批次血緣樹',
},
description: {
type: String,
default: '生產流程追溯:晶批 → 切割 → 封裝 → 成品(點擊節點可多選)',
},
emptyMessage: {
type: String,
default: '目前尚無 LOT 根節點,請先在上方解析。',
},
showSerialLegend: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['select-nodes']);
const selectedSet = computed(() => new Set(props.selectedContainerIds.map(normalizeText).filter(Boolean)));
const rootsSet = computed(() => new Set(props.treeRoots.map(normalizeText).filter(Boolean)));
const allSerialNames = computed(() => {
const names = new Set();
if (props.leafSerials) {
for (const serials of props.leafSerials.values()) {
if (Array.isArray(serials)) {
serials.forEach((sn) => names.add(sn));
}
}
}
return names;
});
function detectNodeType(cid, entry, serials) {
const explicitType = normalizeText(props.nodeMetaMap?.get?.(cid)?.node_type).toUpperCase();
if (explicitType === 'WAFER') {
return 'wafer';
}
if (explicitType === 'GC') {
return 'gc';
}
if (explicitType === 'GA') {
return 'ga';
}
if (explicitType === 'GD') {
return 'gd';
}
if (rootsSet.value.has(cid)) {
return 'root';
}
const children = entry?.children || [];
if (children.length === 0 && serials.length > 0) {
return 'leaf';
}
if (children.length === 0 && serials.length === 0) {
return 'leaf';
}
return 'branch';
}
function lookupEdgeType(parentCid, childCid) {
const parent = normalizeText(parentCid);
const child = normalizeText(childCid);
if (!parent || !child) {
return '';
}
const direct = normalizeText(props.edgeTypeMap?.get?.(`${parent}->${child}`));
if (direct) {
return direct;
}
return normalizeText(props.edgeTypeMap?.get?.(`${child}->${parent}`));
}
function buildNode(cid, visited, parentCid = '') {
const id = normalizeText(cid);
if (!id || visited.has(id)) {
return null;
}
visited.add(id);
const entry = props.lineageMap.get(id);
const name = props.nameMap?.get?.(id) || id;
const serials = props.leafSerials?.get?.(id) || [];
const childIds = entry?.children || [];
const nodeType = detectNodeType(id, entry, serials);
const isSelected = selectedSet.value.has(id);
const children = childIds
.map((childId) => buildNode(childId, visited, id))
.filter(Boolean);
if (children.length === 0 && serials.length > 0) {
serials.forEach((sn) => {
children.push({
name: sn,
value: { type: 'serial', cid: id },
itemStyle: {
color: NODE_COLORS.serial,
borderColor: NODE_COLORS.serial,
},
label: {
fontSize: 10,
color: '#64748B',
},
symbol: 'diamond',
symbolSize: 6,
});
});
}
// Leaf node whose display name matches a known serial → render as serial style
const isSerialLike = nodeType === 'leaf'
&& serials.length === 0
&& children.length === 0
&& allSerialNames.value.has(name);
const effectiveType = isSerialLike ? 'serial' : nodeType;
const color = NODE_COLORS[effectiveType] || NODE_COLORS.branch;
const incomingEdgeType = lookupEdgeType(parentCid, id);
const incomingEdgeStyle = EDGE_STYLES[incomingEdgeType] || EDGE_STYLES.default;
return {
name,
value: { cid: id, type: effectiveType, edgeType: incomingEdgeType || '' },
children,
itemStyle: {
color,
borderColor: isSelected ? '#1D4ED8' : color,
borderWidth: isSelected ? 3 : 1,
},
label: {
...LABEL_BASE_STYLE,
position: children.length > 0 ? 'top' : 'right',
distance: children.length > 0 ? 8 : 6,
fontWeight: isSelected ? 'bold' : 'normal',
fontSize: isSerialLike ? 10 : 11,
color: isSelected ? '#1E3A8A' : (isSerialLike ? '#64748B' : '#334155'),
},
symbol: isSerialLike ? 'diamond' : (nodeType === 'root' ? 'roundRect' : 'circle'),
symbolSize: isSerialLike ? 6 : (nodeType === 'root' ? 14 : 10),
lineStyle: incomingEdgeStyle,
};
}
// Build each root into its own independent tree data
const treesData = computed(() => {
if (props.treeRoots.length === 0) {
return [];
}
const globalVisited = new Set();
return props.treeRoots
.map((rootId) => buildNode(rootId, globalVisited))
.filter(Boolean);
});
const hasData = computed(() => treesData.value.length > 0);
function countLeaves(node) {
if (!node.children || node.children.length === 0) {
return 1;
}
return node.children.reduce((sum, child) => sum + countLeaves(child), 0);
}
const chartHeight = computed(() => {
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',
distance: 6,
fontSize: 11,
color: '#334155',
overflow: 'truncate',
ellipsis: '…',
width: 160,
...LABEL_BASE_STYLE,
},
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 trees = treesData.value;
if (trees.length === 0) {
return null;
}
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 === 'wafer') {
lines.push('<span style="color:#2563EB">Wafer LOT</span>');
} else if (val.type === 'gc') {
lines.push('<span style="color:#06B6D4">GC LOT</span>');
} else if (val.type === 'ga') {
lines.push('<span style="color:#10B981">GA LOT</span>');
} else if (val.type === 'gd') {
lines.push('<span style="color:#EF4444">GD LOT重工</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.edgeType) {
lines.push(`<span style="color:#94A3B8;font-size:11px">關係: ${val.edgeType}</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/>');
},
};
// 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: [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) {
const data = params?.data;
if (!data?.value?.cid) {
return;
}
if (data.value.type === 'serial') {
return;
}
const cid = data.value.cid;
const current = new Set(selectedSet.value);
if (current.has(cid)) {
current.delete(cid);
} else {
current.add(cid);
}
emit('select-nodes', [...current]);
}
</script>
<template>
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 class="text-sm font-semibold text-slate-800">{{ title }}</h3>
<p class="text-xs text-slate-500">{{ description }}</p>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 text-[10px] text-slate-500">
<span class="inline-flex items-center gap-1">
<span class="inline-block size-2.5 rounded-sm" :style="{ background: NODE_COLORS.wafer }" />
Wafer
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.gc }" />
GC
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.ga }" />
GA
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.gd }" />
GD
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.leaf }" />
其他 LOT
</span>
<span v-if="showSerialLegend" class="inline-flex items-center gap-1">
<span class="inline-block size-2.5 rotate-45" :style="{ background: NODE_COLORS.serial, width: '8px', height: '8px' }" />
序列號
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-0.5 w-3 bg-slate-300" />
split
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-0.5 w-3 border-t-2 border-dashed border-amber-500" />
merge
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-0.5 w-3 border-t-2 border-dotted border-blue-600" />
wafer
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-0.5 w-3 border-t-2 border-dashed border-red-500" />
gd-rework
</span>
</div>
</div>
</div>
<!-- Loading overlay -->
<div v-if="loading" class="flex items-center justify-center rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 py-16">
<div class="flex flex-col items-center gap-2">
<span class="inline-block size-5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<span class="text-xs text-slate-500">正在載入血緣資料</span>
</div>
</div>
<!-- Empty state -->
<div v-else-if="!hasData" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-10 text-center text-xs text-slate-500">
{{ emptyMessage }}
</div>
<!-- ECharts Tree -->
<div v-else class="relative">
<VChart
class="lineage-tree-chart"
:style="{ height: chartHeight }"
:option="chartOption"
autoresize
@click="handleNodeClick"
/>
</div>
<!-- Not found warning -->
<div v-if="notFound.length > 0" class="mt-3 rounded-card border border-state-warning/40 bg-amber-50 px-3 py-2 text-xs text-amber-700">
未命中{{ notFound.join(', ') }}
</div>
<!-- Selection summary -->
<div v-if="selectedContainerIds.length > 0" class="mt-3 rounded-card border border-brand-200 bg-brand-50/60 px-3 py-2">
<div class="flex flex-wrap items-center gap-1.5">
<span class="mr-1 text-xs font-medium text-brand-700">已選 {{ selectedContainerIds.length }} 個節點</span>
<span
v-for="cid in selectedContainerIds.slice(0, 8)"
:key="cid"
class="inline-flex items-center rounded-full border border-brand-300 bg-white px-2 py-0.5 font-mono text-xs text-brand-800 shadow-sm"
>
{{ nameMap?.get?.(cid) || cid }}
</span>
<span v-if="selectedContainerIds.length > 8" class="text-xs text-brand-600">+{{ selectedContainerIds.length - 8 }} 更多</span>
</div>
</div>
</section>
</template>
<style scoped>
.lineage-tree-chart {
width: 100%;
min-height: 300px;
}
</style>