321 lines
10 KiB
Vue
321 lines
10 KiB
Vue
<template>
|
|
<Sidebar :visible="sidebarTraces" :closeIcon="'pi pi-chevron-left'" :modal="false" position="left" :dismissable="false" class="!w-11/12" @show="show()">
|
|
<template #header>
|
|
<p class="h1">Traces</p>
|
|
</template>
|
|
<div class="pt-4 h-full flex items-center justify-start">
|
|
<!-- Trace List -->
|
|
<section class="w-80 h-full pr-4 border-r border-neutral-300">
|
|
<p class="h2 px-2 mb-2">Trace List ({{ traceTotal }})</p>
|
|
<p class="text-primary h2 px-2 mb-2">
|
|
Click trace number to see more.
|
|
</p>
|
|
<div class="overflow-y-scroll overflow-x-hidden scrollbar mx-[-8px] max-h-[calc(100%_-_96px)]" >
|
|
<table class="border-separate border-spacing-x-2 text-sm">
|
|
<caption class="hidden">Trace List</caption>
|
|
<thead class="sticky top-0 z-10 bg-neutral-10">
|
|
<tr>
|
|
<th class="h2 px-2 border-b border-neutral-500">Trace</th>
|
|
<th class="h2 px-2 border-b border-neutral-500 text-start" colspan="3">Occurrences</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(trace, key) in traceList" :key="key" class=" cursor-pointer hover:text-primary" @click="switchCaseData(trace.id, trace.base_count)">
|
|
<td class="p-2">#{{ trace.id }}</td>
|
|
<td class="p-2 w-24">
|
|
<div class="h-4 w-full bg-neutral-300 rounded-sm overflow-hidden">
|
|
<div class="h-full bg-primary" :style="trace.value"></div>
|
|
</div>
|
|
</td>
|
|
<td class="py-2 text-right">{{ trace.count }}</td>
|
|
<td class="p-2 text-right">{{ trace.ratio }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<!-- Trace item Table -->
|
|
<section class="pl-4 h-full w-[calc(100%_-_320px)]">
|
|
<p class="h2 mb-2">Trace #{{ showTraceId }}</p>
|
|
<div class="h-36 w-full px-2 mb-2 border border-neutral-300 rounded">
|
|
<div class="h-full w-full">
|
|
<div id="cyTrace" ref="cyTraceRef" class="h-full min-w-full relative"></div>
|
|
</div>
|
|
</div>
|
|
<div class="overflow-y-auto overflow-x-auto scrollbar w-full h-[calc(100%_-_200px)] infiniteTable " @scroll="handleScroll">
|
|
<DataTable :value="caseData" showGridlines tableClass="text-sm" breakpoint="0">
|
|
<div v-for="(col, index) in columnData" :key="index">
|
|
<Column :field="col.field" :header="col.header">
|
|
<template #body="{ data }">
|
|
<div :class="data[col.field]?.length > 18 ? 'whitespace-normal' : 'whitespace-nowrap'">
|
|
{{ data[col.field] }}
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
</div>
|
|
</DataTable>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</Sidebar>
|
|
</template>
|
|
<script setup>
|
|
// The Lucia project.
|
|
// Copyright 2023-2026 DSP, inc. All rights reserved.
|
|
// Authors:
|
|
// chiayin.kuo@dsp.im (chiayin), 2023/1/31
|
|
/**
|
|
* @module components/Discover/Map/SidebarTraces
|
|
* Traces sidebar showing path insights with
|
|
* clickable trace lists for highlighting on the map.
|
|
*/
|
|
|
|
import { ref, computed, watch } from 'vue';
|
|
import { storeToRefs } from 'pinia';
|
|
import { useLoadingStore } from '@/stores/loading';
|
|
import { useAllMapDataStore } from '@/stores/allMapData';
|
|
import cytoscapeMapTrace from '@/module/cytoscapeMapTrace.js';
|
|
|
|
const props = defineProps(['sidebarTraces', 'cases']);
|
|
const emit = defineEmits(['switch-Trace-Id']);
|
|
|
|
const loadingStore = useLoadingStore();
|
|
const allMapDataStore = useAllMapDataStore();
|
|
const { isLoading } = storeToRefs(loadingStore);
|
|
const { infinit404, infiniteStart, traceId, traces, traceTaskSeq, infiniteFirstCases } = storeToRefs(allMapDataStore);
|
|
|
|
const processMap = ref({
|
|
nodes:[],
|
|
edges:[],
|
|
});
|
|
const showTraceId = ref(null);
|
|
const infinitMaxItems = ref(false);
|
|
const infiniteData = ref([]);
|
|
const infiniteFinish = ref(true); // 無限滾動是否載入完成
|
|
const cyTraceRef = ref(null);
|
|
|
|
const traceTotal = computed(() => {
|
|
return traces.value.length;
|
|
});
|
|
|
|
const traceList = computed(() => {
|
|
const sum = traces.value.map(trace => trace.count).reduce((acc, cur) => acc + cur, 0);
|
|
const result = traces.value.map(trace => {
|
|
return {
|
|
id: trace.id,
|
|
value: progressWidth(Number(((trace.count / sum) * 100).toFixed(1))),
|
|
count: trace.count.toLocaleString(),
|
|
base_count: trace.count,
|
|
ratio: getPercentLabel(trace.count / sum),
|
|
};
|
|
})
|
|
return result;
|
|
});
|
|
|
|
const caseData = computed(() => {
|
|
const data = JSON.parse(JSON.stringify(infiniteData.value)); // 深拷貝原始 cases 的內容
|
|
data.forEach(item => {
|
|
item.attributes.forEach((attribute, index) => {
|
|
item[`att_${index}`] = attribute.value; // 建立新的 key-value pair
|
|
});
|
|
delete item.attributes; // 刪除原本的 attributes 屬性
|
|
})
|
|
return data;
|
|
});
|
|
|
|
const columnData = computed(() => {
|
|
const data = JSON.parse(JSON.stringify(props.cases)); // 深拷貝原始 cases 的內容
|
|
let result = [
|
|
{ field: 'id', header: 'Case Id' },
|
|
{ field: 'started_at', header: 'Start time' },
|
|
{ field: 'completed_at', header: 'End time' },
|
|
];
|
|
if(data.length !== 0){
|
|
result = [
|
|
{ field: 'id', header: 'Case Id' },
|
|
{ field: 'started_at', header: 'Start time' },
|
|
{ field: 'completed_at', header: 'End time' },
|
|
...(data[0]?.attributes ?? []).map((att, index) => ({ field: `att_${index}`, header: att.key })),
|
|
];
|
|
}
|
|
return result
|
|
});
|
|
|
|
watch(infinit404, (newValue) => {
|
|
if(newValue === 404) infinitMaxItems.value = true;
|
|
});
|
|
|
|
watch(traceId, (newValue) => {
|
|
showTraceId.value = newValue;
|
|
}, { immediate: true });
|
|
|
|
watch(showTraceId, (newValue, oldValue) => {
|
|
const isScrollTop = document.querySelector('.infiniteTable');
|
|
if(isScrollTop && typeof isScrollTop.scrollTop !== 'undefined') if(newValue !== oldValue) isScrollTop.scrollTop = 0;
|
|
});
|
|
|
|
watch(infiniteFirstCases, (newValue) => {
|
|
if(infiniteFirstCases.value) infiniteData.value = JSON.parse(JSON.stringify(newValue));
|
|
});
|
|
|
|
/**
|
|
* Number to percentage
|
|
* @param {number} val - The raw ratio value.
|
|
* @returns {string} The formatted percentage string.
|
|
*/
|
|
function getPercentLabel(val){
|
|
if((val * 100).toFixed(1) >= 100) return `100%`;
|
|
else return `${(val * 100).toFixed(1)}%`;
|
|
}
|
|
|
|
/**
|
|
* set progress bar width
|
|
* @param {number} value - The percentage value.
|
|
* @returns {string} The CSS width style string.
|
|
*/
|
|
function progressWidth(value){
|
|
return `width:${value}%;`
|
|
}
|
|
|
|
/**
|
|
* switch case data
|
|
* @param {number} id case id
|
|
* @param {number} count - The total number of cases.
|
|
*/
|
|
async function switchCaseData(id, count) {
|
|
// 點同一筆 id 不要有動作
|
|
if(id == showTraceId.value) return;
|
|
isLoading.value = true; // 都要 loading 畫面
|
|
infinit404.value = null;
|
|
infinitMaxItems.value = false;
|
|
showTraceId.value = id;
|
|
infiniteStart.value = 0;
|
|
emit('switch-Trace-Id', {id: showTraceId.value, count: count}); // 傳遞到 Map index 再關掉 loading
|
|
}
|
|
|
|
/**
|
|
* Assembles the trace element nodes data for Cytoscape rendering.
|
|
*/
|
|
function setNodesData(){
|
|
// 避免每次渲染都重複累加
|
|
processMap.value.nodes = [];
|
|
// 將 api call 回來的資料帶進 node
|
|
traceTaskSeq.value.forEach((node, index) => {
|
|
processMap.value.nodes.push({
|
|
data: {
|
|
id: index,
|
|
label: node,
|
|
backgroundColor: '#CCE5FF',
|
|
bordercolor: '#003366',
|
|
shape: 'round-rectangle',
|
|
height: 80,
|
|
width: 100
|
|
}
|
|
});
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Assembles the trace edge line data for Cytoscape rendering.
|
|
*/
|
|
function setEdgesData(){
|
|
processMap.value.edges = [];
|
|
traceTaskSeq.value.forEach((edge, index) => {
|
|
processMap.value.edges.push({
|
|
data: {
|
|
source: `${index}`,
|
|
target: `${index + 1}`,
|
|
lineWidth: 1,
|
|
style: 'solid'
|
|
}
|
|
});
|
|
});
|
|
// 關係線數量筆節點少一個
|
|
processMap.value.edges.pop();
|
|
}
|
|
|
|
/**
|
|
* create trace cytoscape's map
|
|
*/
|
|
function createCy(){
|
|
const graphId = cyTraceRef.value;
|
|
|
|
setNodesData();
|
|
setEdgesData();
|
|
cytoscapeMapTrace(processMap.value.nodes, processMap.value.edges, graphId);
|
|
}
|
|
|
|
/**
|
|
* create map
|
|
*/
|
|
async function show() {
|
|
isLoading.value = await true; // createCy 執行完關閉
|
|
// 因 trace api 連動,所以關閉側邊欄時讓數值歸 traces 第一筆 id
|
|
showTraceId.value = await traces.value[0]?.id;
|
|
infiniteStart.value = await 0;
|
|
setNodesData();
|
|
setEdgesData();
|
|
createCy();
|
|
isLoading.value = false;
|
|
}
|
|
|
|
/**
|
|
* Infinite scroll: listens for scroll reaching the bottom.
|
|
* @param {Event} event - The scroll event.
|
|
*/
|
|
function handleScroll(event) {
|
|
if(infinitMaxItems.value || props.cases.length < 20 || infiniteFinish.value === false) return;
|
|
|
|
const container = event.target;
|
|
const overScrollHeight = container.scrollTop + container.clientHeight >= container.scrollHeight;
|
|
|
|
if(overScrollHeight) fetchData();
|
|
}
|
|
|
|
/**
|
|
* Infinite scroll: loads more data when the bottom is reached.
|
|
*/
|
|
async function fetchData() {
|
|
try {
|
|
isLoading.value = true;
|
|
infiniteFinish.value = false;
|
|
infiniteStart.value += 20;
|
|
await allMapDataStore.getTraceDetail();
|
|
infiniteData.value = await [...infiniteData.value, ...props.cases];
|
|
infiniteFinish.value = await true;
|
|
isLoading.value = await false;
|
|
} catch(error) {
|
|
console.error('Failed to load data:', error);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
@reference "../../../assets/tailwind.css";
|
|
/* 進度條顏色 */
|
|
:deep(.p-progressbar .p-progressbar-value) {
|
|
@apply bg-primary
|
|
}
|
|
/* Table set */
|
|
:deep(.p-datatable-thead) {
|
|
@apply sticky top-0 left-0 z-10 bg-neutral-10
|
|
}
|
|
:deep(.p-datatable .p-datatable-thead > tr > th) {
|
|
@apply !border-y-0 border-neutral-500 bg-neutral-100 after:absolute after:left-0 after:w-full after:h-full after:block after:top-0 after:border-b after:border-t after:border-neutral-500;
|
|
white-space: nowrap;
|
|
}
|
|
:deep(.p-datatable .p-datatable-tbody > tr > td) {
|
|
@apply border-neutral-500 !border-t-0 text-center
|
|
}
|
|
:deep(.p-datatable.p-datatable-gridlines .p-datatable-tbody > tr > td) {
|
|
min-width: 72px;
|
|
max-width: 184px;
|
|
overflow-wrap: break-word;
|
|
word-wrap: break-word;
|
|
}
|
|
/* datatable head 置中 */
|
|
:deep(.p-column-header-content) {
|
|
@apply justify-center
|
|
}
|
|
</style>
|