|
|
|
@ -7,13 +7,13 @@
|
|
|
|
range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" style="width: 380px" />
|
|
|
|
range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" style="width: 380px" />
|
|
|
|
</el-form-item>
|
|
|
|
</el-form-item>
|
|
|
|
<el-form-item>
|
|
|
|
<el-form-item>
|
|
|
|
<el-button type="primary" icon="Search" @click="handleQuery">查询</el-button>
|
|
|
|
<el-button type="primary" icon="Search" :loading="loadingMap.sankey" @click="handleQuery">查询</el-button>
|
|
|
|
</el-form-item>
|
|
|
|
</el-form-item>
|
|
|
|
</el-form>
|
|
|
|
</el-form>
|
|
|
|
</el-card>
|
|
|
|
</el-card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 桑基图 -->
|
|
|
|
<!-- 桑基图 -->
|
|
|
|
<el-card shadow="never" class="mt-4">
|
|
|
|
<el-card v-loading="loadingMap.sankey" shadow="never" class="mt-4">
|
|
|
|
<template #header>
|
|
|
|
<template #header>
|
|
|
|
<div class="card-header">
|
|
|
|
<div class="card-header">
|
|
|
|
<span class="font-bold">温区流转桑基图</span>
|
|
|
|
<span class="font-bold">温区流转桑基图</span>
|
|
|
|
@ -24,7 +24,7 @@
|
|
|
|
</el-card>
|
|
|
|
</el-card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 主题河流图 -->
|
|
|
|
<!-- 主题河流图 -->
|
|
|
|
<el-card shadow="never" class="mt-4">
|
|
|
|
<el-card v-loading="loadingMap.river" shadow="never" class="mt-4">
|
|
|
|
<template #header>
|
|
|
|
<template #header>
|
|
|
|
<div class="card-header">
|
|
|
|
<div class="card-header">
|
|
|
|
<span class="font-bold">温度主题河流图</span>
|
|
|
|
<span class="font-bold">温度主题河流图</span>
|
|
|
|
@ -37,7 +37,7 @@
|
|
|
|
<!-- 矩形树图 + 旭日图 -->
|
|
|
|
<!-- 矩形树图 + 旭日图 -->
|
|
|
|
<el-row :gutter="16" class="mt-4">
|
|
|
|
<el-row :gutter="16" class="mt-4">
|
|
|
|
<el-col :xs="24" :sm="12">
|
|
|
|
<el-col :xs="24" :sm="12">
|
|
|
|
<el-card shadow="never">
|
|
|
|
<el-card v-loading="loadingMap.treemap" shadow="never">
|
|
|
|
<template #header>
|
|
|
|
<template #header>
|
|
|
|
<div class="card-header">
|
|
|
|
<div class="card-header">
|
|
|
|
<span class="font-bold">温度矩形树图(按测点平均温度)</span>
|
|
|
|
<span class="font-bold">温度矩形树图(按测点平均温度)</span>
|
|
|
|
@ -48,7 +48,7 @@
|
|
|
|
</el-card>
|
|
|
|
</el-card>
|
|
|
|
</el-col>
|
|
|
|
</el-col>
|
|
|
|
<el-col :xs="24" :sm="12">
|
|
|
|
<el-col :xs="24" :sm="12">
|
|
|
|
<el-card shadow="never">
|
|
|
|
<el-card v-loading="loadingMap.sunburst" shadow="never">
|
|
|
|
<template #header>
|
|
|
|
<template #header>
|
|
|
|
<div class="card-header">
|
|
|
|
<div class="card-header">
|
|
|
|
<span class="font-bold">温度旭日图(温区→测点)</span>
|
|
|
|
<span class="font-bold">温度旭日图(温区→测点)</span>
|
|
|
|
@ -61,7 +61,7 @@
|
|
|
|
</el-row>
|
|
|
|
</el-row>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 平行坐标图 -->
|
|
|
|
<!-- 平行坐标图 -->
|
|
|
|
<el-card shadow="never" class="mt-4">
|
|
|
|
<el-card v-loading="loadingMap.parallel" shadow="never" class="mt-4">
|
|
|
|
<template #header>
|
|
|
|
<template #header>
|
|
|
|
<div class="card-header">
|
|
|
|
<div class="card-header">
|
|
|
|
<span class="font-bold">测点多维温度画像(平行坐标图)</span>
|
|
|
|
<span class="font-bold">测点多维温度画像(平行坐标图)</span>
|
|
|
|
@ -74,7 +74,7 @@
|
|
|
|
</template>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
<script setup lang="ts">
|
|
|
|
import { ref, reactive, onMounted, nextTick } from 'vue'
|
|
|
|
import { ref, shallowRef, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
|
|
import * as echarts from 'echarts'
|
|
|
|
import * as echarts from 'echarts'
|
|
|
|
import { getSankeyData, getThemeRiverData, getTreemapData, getSunburstData, getParallelData } from '@/api/ems/report/tempBoard'
|
|
|
|
import { getSankeyData, getThemeRiverData, getTreemapData, getSunburstData, getParallelData } from '@/api/ems/report/tempBoard'
|
|
|
|
import type { TempBoardQuery, TempBoardAdvancedVO } from '@/api/ems/report/tempBoard'
|
|
|
|
import type { TempBoardQuery, TempBoardAdvancedVO } from '@/api/ems/report/tempBoard'
|
|
|
|
@ -86,14 +86,46 @@ defineOptions({ name: 'TempBoardAdvanced' })
|
|
|
|
|
|
|
|
|
|
|
|
const timeRange = ref<[string, string]>(['', ''])
|
|
|
|
const timeRange = ref<[string, string]>(['', ''])
|
|
|
|
const queryForm = reactive<TempBoardQuery>({})
|
|
|
|
const queryForm = reactive<TempBoardQuery>({})
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
// 每图独立 loading,替代单一 loading
|
|
|
|
|
|
|
|
const loadingMap = reactive({
|
|
|
|
|
|
|
|
sankey: false,
|
|
|
|
|
|
|
|
river: false,
|
|
|
|
|
|
|
|
treemap: false,
|
|
|
|
|
|
|
|
sunburst: false,
|
|
|
|
|
|
|
|
parallel: false
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 图表数据使用 shallowRef 避免深层响应式代理
|
|
|
|
|
|
|
|
const sankeyData = shallowRef<TempBoardAdvancedVO[]>([])
|
|
|
|
|
|
|
|
const riverData = shallowRef<TempBoardAdvancedVO[]>([])
|
|
|
|
|
|
|
|
const treemapData = shallowRef<TempBoardAdvancedVO[]>([])
|
|
|
|
|
|
|
|
const sunburstData = shallowRef<TempBoardAdvancedVO[]>([])
|
|
|
|
|
|
|
|
const parallelData = shallowRef<TempBoardAdvancedVO[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 图表容器 DOM 引用
|
|
|
|
const sankeyChartRef = ref<HTMLElement>()
|
|
|
|
const sankeyChartRef = ref<HTMLElement>()
|
|
|
|
const themeRiverChartRef = ref<HTMLElement>()
|
|
|
|
const themeRiverChartRef = ref<HTMLElement>()
|
|
|
|
const treemapChartRef = ref<HTMLElement>()
|
|
|
|
const treemapChartRef = ref<HTMLElement>()
|
|
|
|
const sunburstChartRef = ref<HTMLElement>()
|
|
|
|
const sunburstChartRef = ref<HTMLElement>()
|
|
|
|
const parallelChartRef = ref<HTMLElement>()
|
|
|
|
const parallelChartRef = ref<HTMLElement>()
|
|
|
|
|
|
|
|
|
|
|
|
const { getChart } = useChartResize(sankeyChartRef, themeRiverChartRef, treemapChartRef, sunburstChartRef, parallelChartRef)
|
|
|
|
const { getChart, disposeAll } = useChartResize(sankeyChartRef, themeRiverChartRef, treemapChartRef, sunburstChartRef, parallelChartRef)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 请求取消控制 ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 每次查询生成唯一 ID,旧请求结果自动丢弃 */
|
|
|
|
|
|
|
|
let currentQueryId = 0
|
|
|
|
|
|
|
|
let abortController: AbortController | null = null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function cancelPending() {
|
|
|
|
|
|
|
|
if (abortController) {
|
|
|
|
|
|
|
|
abortController.abort()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
abortController = new AbortController()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 时间初始化 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/** 初始化默认时间范围:昨天8:30 ~ 今天8:30 */
|
|
|
|
/** 初始化默认时间范围:昨天8:30 ~ 今天8:30 */
|
|
|
|
function initTimeRange() {
|
|
|
|
function initTimeRange() {
|
|
|
|
@ -105,23 +137,98 @@ function initTimeRange() {
|
|
|
|
timeRange.value = [fmt(start), fmt(end)]
|
|
|
|
timeRange.value = [fmt(start), fmt(end)]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleQuery() {
|
|
|
|
// ==================== 懒加载控制 ====================
|
|
|
|
if (timeRange.value?.[0]) { queryForm.startTime = timeRange.value[0]; queryForm.endTime = timeRange.value[1] }
|
|
|
|
|
|
|
|
loading.value = true
|
|
|
|
/** 记录已触发的图表,IntersectionObserver 只触发一次 */
|
|
|
|
try {
|
|
|
|
const loadedCards = reactive(new Set<string>())
|
|
|
|
const [skRes, trRes, tmRes, sbRes, plRes] = await Promise.all([
|
|
|
|
|
|
|
|
getSankeyData(queryForm), getThemeRiverData(queryForm),
|
|
|
|
/** 通用懒加载:进入视口后发起请求并渲染 */
|
|
|
|
getTreemapData(queryForm), getSunburstData(queryForm), getParallelData(queryForm)
|
|
|
|
function lazyLoadChart(
|
|
|
|
])
|
|
|
|
cardKey: string,
|
|
|
|
await nextTick()
|
|
|
|
elRef: { value: HTMLElement | undefined },
|
|
|
|
renderSankey(skRes.data)
|
|
|
|
fetchFn: (params: TempBoardQuery) => Promise<any>,
|
|
|
|
renderThemeRiver(trRes.data)
|
|
|
|
renderFn: (data: TempBoardAdvancedVO[]) => void,
|
|
|
|
renderTreemap(tmRes.data)
|
|
|
|
loadingKey: keyof typeof loadingMap
|
|
|
|
renderSunburst(sbRes.data)
|
|
|
|
) {
|
|
|
|
renderParallel(plRes.data)
|
|
|
|
if (loadedCards.has(cardKey) || !elRef.value) return
|
|
|
|
} finally { loading.value = false }
|
|
|
|
// 标记已触发,避免重复
|
|
|
|
|
|
|
|
loadedCards.add(cardKey)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const queryId = currentQueryId
|
|
|
|
|
|
|
|
loadingMap[loadingKey] = true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fetchFn(queryForm).then(res => {
|
|
|
|
|
|
|
|
// 只处理最新请求的结果
|
|
|
|
|
|
|
|
if (queryId === currentQueryId) {
|
|
|
|
|
|
|
|
nextTick(() => renderFn(res.data))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}).catch(err => {
|
|
|
|
|
|
|
|
if (err?.name !== 'CanceledError' && err?.name !== 'AbortError') {
|
|
|
|
|
|
|
|
console.error(`[${cardKey}] 加载失败:`, err)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}).finally(() => {
|
|
|
|
|
|
|
|
if (queryId === currentQueryId) {
|
|
|
|
|
|
|
|
loadingMap[loadingKey] = false
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** 为每个图表容器注册 IntersectionObserver */
|
|
|
|
|
|
|
|
const observerMap = new Map<HTMLElement, IntersectionObserver>()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function registerObserver(
|
|
|
|
|
|
|
|
cardKey: string,
|
|
|
|
|
|
|
|
elRef: { value: HTMLElement | undefined },
|
|
|
|
|
|
|
|
fetchFn: (params: TempBoardQuery) => Promise<any>,
|
|
|
|
|
|
|
|
renderFn: (data: TempBoardAdvancedVO[]) => void,
|
|
|
|
|
|
|
|
loadingKey: keyof typeof loadingMap
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
// 等待 DOM 渲染后注册
|
|
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
|
|
const el = elRef.value
|
|
|
|
|
|
|
|
if (!el || observerMap.has(el)) return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const observer = new IntersectionObserver(
|
|
|
|
|
|
|
|
([entry]) => {
|
|
|
|
|
|
|
|
if (entry.isIntersecting) {
|
|
|
|
|
|
|
|
lazyLoadChart(cardKey, elRef, fetchFn, renderFn, loadingKey)
|
|
|
|
|
|
|
|
observer.disconnect()
|
|
|
|
|
|
|
|
observerMap.delete(el)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{ threshold: 0.05 }
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
observer.observe(el)
|
|
|
|
|
|
|
|
observerMap.set(el, observer)
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 查询入口 ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function handleQuery() {
|
|
|
|
|
|
|
|
if (timeRange.value?.[0]) {
|
|
|
|
|
|
|
|
queryForm.startTime = timeRange.value[0]
|
|
|
|
|
|
|
|
queryForm.endTime = timeRange.value[1]
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// 取消上一次未完成的请求
|
|
|
|
|
|
|
|
cancelPending()
|
|
|
|
|
|
|
|
currentQueryId++
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 清空旧数据和观察器
|
|
|
|
|
|
|
|
loadedCards.clear()
|
|
|
|
|
|
|
|
observerMap.forEach(obs => obs.disconnect())
|
|
|
|
|
|
|
|
observerMap.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 注册懒加载观察器
|
|
|
|
|
|
|
|
registerObserver('sankey', sankeyChartRef, getSankeyData, renderSankey, 'sankey')
|
|
|
|
|
|
|
|
registerObserver('river', themeRiverChartRef, getThemeRiverData, renderThemeRiver, 'river')
|
|
|
|
|
|
|
|
registerObserver('treemap', treemapChartRef, getTreemapData, renderTreemap, 'treemap')
|
|
|
|
|
|
|
|
registerObserver('sunburst', sunburstChartRef, getSunburstData, renderSunburst, 'sunburst')
|
|
|
|
|
|
|
|
registerObserver('parallel', parallelChartRef, getParallelData, renderParallel, 'parallel')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 图表渲染函数 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/** 桑基图 */
|
|
|
|
/** 桑基图 */
|
|
|
|
function renderSankey(data: TempBoardAdvancedVO[]) {
|
|
|
|
function renderSankey(data: TempBoardAdvancedVO[]) {
|
|
|
|
const chart = getChart(sankeyChartRef)
|
|
|
|
const chart = getChart(sankeyChartRef)
|
|
|
|
@ -129,10 +236,11 @@ function renderSankey(data: TempBoardAdvancedVO[]) {
|
|
|
|
const nodes = new Set<string>()
|
|
|
|
const nodes = new Set<string>()
|
|
|
|
data.forEach(d => { nodes.add(d.fromNode!); nodes.add(d.toNode!) })
|
|
|
|
data.forEach(d => { nodes.add(d.fromNode!); nodes.add(d.toNode!) })
|
|
|
|
chart.setOption({
|
|
|
|
chart.setOption({
|
|
|
|
|
|
|
|
animation: false,
|
|
|
|
tooltip: {
|
|
|
|
tooltip: {
|
|
|
|
trigger: 'item',
|
|
|
|
trigger: 'item',
|
|
|
|
formatter: (p: any) => p.dataType === 'edge'
|
|
|
|
formatter: (p: any) => p.dataType === 'edge'
|
|
|
|
? `${p.data.source} → ${p.data.target}<br/>流量: <b>${p.data.value}</b>`
|
|
|
|
? `${p.data.source} → ${p.data.target}<br/>设备数: <b>${p.data.value}</b>`
|
|
|
|
: `${p.name}`
|
|
|
|
: `${p.name}`
|
|
|
|
},
|
|
|
|
},
|
|
|
|
series: [{
|
|
|
|
series: [{
|
|
|
|
@ -145,14 +253,15 @@ function renderSankey(data: TempBoardAdvancedVO[]) {
|
|
|
|
label: { color: '#333', fontSize: 12 },
|
|
|
|
label: { color: '#333', fontSize: 12 },
|
|
|
|
itemStyle: { borderWidth: 0 }
|
|
|
|
itemStyle: { borderWidth: 0 }
|
|
|
|
}]
|
|
|
|
}]
|
|
|
|
})
|
|
|
|
}, { notMerge: true, lazyUpdate: true })
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 主题河流图 */
|
|
|
|
/** 主题河流图 - 开启降采样 */
|
|
|
|
function renderThemeRiver(data: TempBoardAdvancedVO[]) {
|
|
|
|
function renderThemeRiver(data: TempBoardAdvancedVO[]) {
|
|
|
|
const chart = getChart(themeRiverChartRef)
|
|
|
|
const chart = getChart(themeRiverChartRef)
|
|
|
|
if (!chart || !data.length) return
|
|
|
|
if (!chart || !data.length) return
|
|
|
|
chart.setOption({
|
|
|
|
chart.setOption({
|
|
|
|
|
|
|
|
animation: false,
|
|
|
|
tooltip: {
|
|
|
|
tooltip: {
|
|
|
|
trigger: 'axis',
|
|
|
|
trigger: 'axis',
|
|
|
|
formatter: (params: any) => {
|
|
|
|
formatter: (params: any) => {
|
|
|
|
@ -164,21 +273,27 @@ function renderThemeRiver(data: TempBoardAdvancedVO[]) {
|
|
|
|
singleAxis: { type: 'time', axisLabel: { color: '#666' } },
|
|
|
|
singleAxis: { type: 'time', axisLabel: { color: '#666' } },
|
|
|
|
series: [{
|
|
|
|
series: [{
|
|
|
|
type: 'themeRiver',
|
|
|
|
type: 'themeRiver',
|
|
|
|
|
|
|
|
sampling: 'lttb',
|
|
|
|
data: data.map(d => [d.statTime, d.avgTemp, d.monitorName || d.monitorId]),
|
|
|
|
data: data.map(d => [d.statTime, d.avgTemp, d.monitorName || d.monitorId]),
|
|
|
|
emphasis: { itemStyle: { shadowBlur: 20, shadowColor: 'rgba(0,0,0,0.8)' } },
|
|
|
|
emphasis: { itemStyle: { shadowBlur: 20, shadowColor: 'rgba(0,0,0,0.8)' } },
|
|
|
|
label: { show: false }
|
|
|
|
label: { show: false }
|
|
|
|
}]
|
|
|
|
}]
|
|
|
|
})
|
|
|
|
}, { notMerge: true, lazyUpdate: true })
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 矩形树图 */
|
|
|
|
/** 矩形树图 - tooltip 使用 Map 替代 O(n) find */
|
|
|
|
function renderTreemap(data: TempBoardAdvancedVO[]) {
|
|
|
|
function renderTreemap(data: TempBoardAdvancedVO[]) {
|
|
|
|
const chart = getChart(treemapChartRef)
|
|
|
|
const chart = getChart(treemapChartRef)
|
|
|
|
if (!chart || !data.length) return
|
|
|
|
if (!chart || !data.length) return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 预建 Map,避免 tooltip 每次 hover 做 O(n) 线性扫描
|
|
|
|
|
|
|
|
const itemMap = new Map(data.map(d => [d.monitorName || d.monitorId, d]))
|
|
|
|
|
|
|
|
|
|
|
|
chart.setOption({
|
|
|
|
chart.setOption({
|
|
|
|
|
|
|
|
animation: false,
|
|
|
|
tooltip: {
|
|
|
|
tooltip: {
|
|
|
|
formatter: (p: any) => {
|
|
|
|
formatter: (p: any) => {
|
|
|
|
const item = data.find(d => d.monitorName === p.name || d.monitorId === p.name)
|
|
|
|
const item = itemMap.get(p.name)
|
|
|
|
return `${p.name}<br/>平均温度: <b>${p.value?.toFixed(2)}℃</b>${item ? `<br/>样本数: ${item.sampleCount}` : ''}`
|
|
|
|
return `${p.name}<br/>平均温度: <b>${p.value?.toFixed(2)}℃</b>${item ? `<br/>样本数: ${item.sampleCount}` : ''}`
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
},
|
|
|
|
@ -201,32 +316,44 @@ function renderTreemap(data: TempBoardAdvancedVO[]) {
|
|
|
|
{ itemStyle: { borderColor: '#fff', borderWidth: 1, gapWidth: 1 } }
|
|
|
|
{ itemStyle: { borderColor: '#fff', borderWidth: 1, gapWidth: 1 } }
|
|
|
|
]
|
|
|
|
]
|
|
|
|
}]
|
|
|
|
}]
|
|
|
|
})
|
|
|
|
}, { notMerge: true, lazyUpdate: true })
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 旭日图 */
|
|
|
|
/** 旭日图 - 同一温区内同名测点合并 sampleCount */
|
|
|
|
function renderSunburst(data: TempBoardAdvancedVO[]) {
|
|
|
|
function renderSunburst(data: TempBoardAdvancedVO[]) {
|
|
|
|
const chart = getChart(sunburstChartRef)
|
|
|
|
const chart = getChart(sunburstChartRef)
|
|
|
|
if (!chart || !data.length) return
|
|
|
|
if (!chart || !data.length) return
|
|
|
|
// 构建层级数据:温区 → 测点
|
|
|
|
|
|
|
|
const bucketMap = new Map<string, { name: string; value: number; children: any[] }>()
|
|
|
|
|
|
|
|
const colorMap: Record<string, string> = {
|
|
|
|
const colorMap: Record<string, string> = {
|
|
|
|
'低温': '#409eff', '偏低': '#67c23a', '正常': '#95de64',
|
|
|
|
'低温': '#409eff', '偏低': '#67c23a', '正常': '#95de64',
|
|
|
|
'偏高': '#e6a23c', '高温': '#f56c6c'
|
|
|
|
'偏高': '#e6a23c', '高温': '#f56c6c'
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// key = tempBucket, value = { name, children: Map<monitorName, aggregated> }
|
|
|
|
|
|
|
|
const bucketMap = new Map<string, { name: string; value: number; children: any[] }>()
|
|
|
|
|
|
|
|
|
|
|
|
data.forEach(d => {
|
|
|
|
data.forEach(d => {
|
|
|
|
if (!bucketMap.has(d.tempBucket!)) {
|
|
|
|
if (!bucketMap.has(d.tempBucket!)) {
|
|
|
|
bucketMap.set(d.tempBucket!, { name: d.tempBucket!, value: 0, children: [] })
|
|
|
|
bucketMap.set(d.tempBucket!, { name: d.tempBucket!, value: 0, children: [] })
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const bucket = bucketMap.get(d.tempBucket!)!
|
|
|
|
const bucket = bucketMap.get(d.tempBucket!)!
|
|
|
|
|
|
|
|
const mName = d.monitorName || d.monitorId
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 在 children 中查找是否已有同名测点,有则累加 sampleCount
|
|
|
|
|
|
|
|
const existing = bucket.children.find(c => c.name === mName)
|
|
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
|
|
existing.value += d.sampleCount ?? 0
|
|
|
|
|
|
|
|
} else {
|
|
|
|
bucket.children.push({
|
|
|
|
bucket.children.push({
|
|
|
|
name: d.monitorName || d.monitorId,
|
|
|
|
name: mName,
|
|
|
|
value: d.sampleCount,
|
|
|
|
value: d.sampleCount ?? 0,
|
|
|
|
itemStyle: { color: colorMap[d.tempBucket!] || undefined }
|
|
|
|
itemStyle: { color: colorMap[d.tempBucket!] || undefined }
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
}
|
|
|
|
bucket.value += d.sampleCount ?? 0
|
|
|
|
bucket.value += d.sampleCount ?? 0
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
chart.setOption({
|
|
|
|
chart.setOption({
|
|
|
|
|
|
|
|
animation: false,
|
|
|
|
tooltip: { formatter: '{b}: {c}样本' },
|
|
|
|
tooltip: { formatter: '{b}: {c}样本' },
|
|
|
|
series: [{
|
|
|
|
series: [{
|
|
|
|
type: 'sunburst',
|
|
|
|
type: 'sunburst',
|
|
|
|
@ -239,23 +366,26 @@ function renderSunburst(data: TempBoardAdvancedVO[]) {
|
|
|
|
itemStyle: { borderWidth: 2, borderColor: '#fff' },
|
|
|
|
itemStyle: { borderWidth: 2, borderColor: '#fff' },
|
|
|
|
emphasis: { focus: 'ancestor' }
|
|
|
|
emphasis: { focus: 'ancestor' }
|
|
|
|
}]
|
|
|
|
}]
|
|
|
|
})
|
|
|
|
}, { notMerge: true, lazyUpdate: true })
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 平行坐标图 */
|
|
|
|
/** 平行坐标图 - 恢复 per-monitor series + scroll legend,保留 IQR 轴范围 + 关闭动画 */
|
|
|
|
function renderParallel(data: TempBoardAdvancedVO[]) {
|
|
|
|
function renderParallel(data: TempBoardAdvancedVO[]) {
|
|
|
|
const chart = getChart(parallelChartRef)
|
|
|
|
const chart = getChart(parallelChartRef)
|
|
|
|
if (!chart || !data.length) return
|
|
|
|
if (!chart || !data.length) return
|
|
|
|
|
|
|
|
|
|
|
|
const dimNames = ['测点', '平均温度', '最高温度', '最低温度', '标准差', '平均延迟(s)']
|
|
|
|
const dimNames = ['测点', '平均温度', '最高温度', '最低温度', '标准差', '平均延迟(s)']
|
|
|
|
const fields = ['avgTemp', 'maxTemp', 'minTemp', 'tempStddev', 'avgDelay'] as const
|
|
|
|
const fields = ['avgTemp', 'maxTemp', 'minTemp', 'tempStddev', 'avgDelay'] as const
|
|
|
|
const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#9b59b6', '#1abc9c', '#e74c3c', '#2ecc71']
|
|
|
|
const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#9b59b6', '#1abc9c', '#e74c3c', '#2ecc71']
|
|
|
|
|
|
|
|
|
|
|
|
// 基于IQR(四分位距)动态计算每根轴的范围,避免异常值导致线条超出可视区域
|
|
|
|
// 限制最多展示 Top 100 测点,避免浏览器崩溃
|
|
|
|
|
|
|
|
const limited = data.slice(0, 100)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 基于 IQR 计算轴范围
|
|
|
|
function calcAxisRange(field: string): { min: number; max: number } {
|
|
|
|
function calcAxisRange(field: string): { min: number; max: number } {
|
|
|
|
const values = data.map(d => (d as any)[field] as number).filter(v => v != null).sort((a, b) => a - b)
|
|
|
|
const values = limited.map(d => (d as any)[field] as number).filter(v => v != null).sort((a, b) => a - b)
|
|
|
|
if (values.length === 0) return { min: 0, max: 100 }
|
|
|
|
if (values.length === 0) return { min: 0, max: 100 }
|
|
|
|
if (values.length <= 2) {
|
|
|
|
if (values.length <= 2) {
|
|
|
|
// 数据太少时直接用实际范围,加一点余量
|
|
|
|
|
|
|
|
const range = values[values.length - 1] - values[0]
|
|
|
|
const range = values[values.length - 1] - values[0]
|
|
|
|
const pad = Math.max(range * 0.1, 1)
|
|
|
|
const pad = Math.max(range * 0.1, 1)
|
|
|
|
return { min: Math.floor(values[0] - pad), max: Math.ceil(values[values.length - 1] + pad) }
|
|
|
|
return { min: Math.floor(values[0] - pad), max: Math.ceil(values[values.length - 1] + pad) }
|
|
|
|
@ -263,33 +393,38 @@ function renderParallel(data: TempBoardAdvancedVO[]) {
|
|
|
|
const q1 = values[Math.floor(values.length * 0.25)]
|
|
|
|
const q1 = values[Math.floor(values.length * 0.25)]
|
|
|
|
const q3 = values[Math.floor(values.length * 0.75)]
|
|
|
|
const q3 = values[Math.floor(values.length * 0.75)]
|
|
|
|
const iqr = q3 - q1
|
|
|
|
const iqr = q3 - q1
|
|
|
|
// 上下限:Q1 - 1.5*IQR ~ Q3 + 1.5*IQR,并兜底覆盖全部数据
|
|
|
|
|
|
|
|
const lower = Math.min(q1 - 1.5 * iqr, values[0])
|
|
|
|
const lower = Math.min(q1 - 1.5 * iqr, values[0])
|
|
|
|
const upper = Math.max(q3 + 1.5 * iqr, values[values.length - 1])
|
|
|
|
const upper = Math.max(q3 + 1.5 * iqr, values[values.length - 1])
|
|
|
|
const pad = Math.max((upper - lower) * 0.05, 0.5)
|
|
|
|
const pad = Math.max((upper - lower) * 0.05, 0.5)
|
|
|
|
return { min: Math.floor(lower - pad), max: Math.ceil(upper + pad) }
|
|
|
|
return { min: Math.floor(lower - pad), max: Math.ceil(upper + pad) }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 为数值维度预计算轴范围
|
|
|
|
|
|
|
|
const axisRanges = fields.map(f => calcAxisRange(f))
|
|
|
|
const axisRanges = fields.map(f => calcAxisRange(f))
|
|
|
|
|
|
|
|
|
|
|
|
chart.setOption({
|
|
|
|
chart.setOption({
|
|
|
|
|
|
|
|
animation: false,
|
|
|
|
tooltip: { trigger: 'item' },
|
|
|
|
tooltip: { trigger: 'item' },
|
|
|
|
legend: {
|
|
|
|
legend: {
|
|
|
|
data: data.map(d => d.monitorName || d.monitorId),
|
|
|
|
data: limited.map(d => d.monitorName || d.monitorId),
|
|
|
|
top: 0,
|
|
|
|
top: 0,
|
|
|
|
type: 'scroll',
|
|
|
|
type: 'scroll',
|
|
|
|
textStyle: { color: '#666', fontSize: 11 }
|
|
|
|
textStyle: { color: '#666', fontSize: 11 }
|
|
|
|
},
|
|
|
|
},
|
|
|
|
parallelAxis: [
|
|
|
|
parallelAxis: [
|
|
|
|
{ type: 'category', data: data.map(d => d.monitorName || d.monitorId), dim: 0, name: dimNames[0], axisLabel: { rotate: 30, color: '#666' } },
|
|
|
|
{
|
|
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
|
|
data: limited.map(d => d.monitorName || d.monitorId),
|
|
|
|
|
|
|
|
dim: 0,
|
|
|
|
|
|
|
|
name: dimNames[0],
|
|
|
|
|
|
|
|
axisLabel: { rotate: 30, color: '#666', fontSize: 10 }
|
|
|
|
|
|
|
|
},
|
|
|
|
...fields.map((f, i) => ({
|
|
|
|
...fields.map((f, i) => ({
|
|
|
|
type: 'value',
|
|
|
|
type: 'value' as const,
|
|
|
|
dim: i + 1,
|
|
|
|
dim: i + 1,
|
|
|
|
name: dimNames[i + 1],
|
|
|
|
name: dimNames[i + 1],
|
|
|
|
min: axisRanges[i].min,
|
|
|
|
min: axisRanges[i].min,
|
|
|
|
max: axisRanges[i].max,
|
|
|
|
max: axisRanges[i].max,
|
|
|
|
nameLocation: 'end',
|
|
|
|
nameLocation: 'end' as const,
|
|
|
|
nameTextStyle: { color: '#333', fontSize: 12 },
|
|
|
|
nameTextStyle: { color: '#333', fontSize: 12 },
|
|
|
|
axisLabel: { color: '#666', fontSize: 11 },
|
|
|
|
axisLabel: { color: '#666', fontSize: 11 },
|
|
|
|
splitLine: { show: true, lineStyle: { color: '#eee' } },
|
|
|
|
splitLine: { show: true, lineStyle: { color: '#eee' } },
|
|
|
|
@ -297,17 +432,30 @@ function renderParallel(data: TempBoardAdvancedVO[]) {
|
|
|
|
}))
|
|
|
|
}))
|
|
|
|
],
|
|
|
|
],
|
|
|
|
parallel: { left: 100, right: 50, top: 50, bottom: 30 },
|
|
|
|
parallel: { left: 100, right: 50, top: 50, bottom: 30 },
|
|
|
|
series: data.map((d, idx) => ({
|
|
|
|
series: limited.map((d, idx) => ({
|
|
|
|
name: d.monitorName || d.monitorId,
|
|
|
|
name: d.monitorName || d.monitorId,
|
|
|
|
type: 'parallel',
|
|
|
|
type: 'parallel',
|
|
|
|
data: [[d.monitorName || d.monitorId, d.avgTemp, d.maxTemp, d.minTemp, d.tempStddev, d.avgDelay]],
|
|
|
|
data: [[d.monitorName || d.monitorId, d.avgTemp, d.maxTemp, d.minTemp, d.tempStddev, d.avgDelay]],
|
|
|
|
lineStyle: { width: 2, opacity: 0.7, color: colors[idx % colors.length] },
|
|
|
|
lineStyle: { width: 2, opacity: 0.7, color: colors[idx % colors.length] },
|
|
|
|
smooth: true
|
|
|
|
smooth: true
|
|
|
|
}))
|
|
|
|
}))
|
|
|
|
})
|
|
|
|
}, { notMerge: true, lazyUpdate: true })
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => { initTimeRange(); handleQuery() })
|
|
|
|
// ==================== 生命周期 ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
|
|
initTimeRange()
|
|
|
|
|
|
|
|
handleQuery()
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
|
|
// 清理所有观察器和图表
|
|
|
|
|
|
|
|
observerMap.forEach(obs => obs.disconnect())
|
|
|
|
|
|
|
|
observerMap.clear()
|
|
|
|
|
|
|
|
cancelPending()
|
|
|
|
|
|
|
|
disposeAll()
|
|
|
|
|
|
|
|
})
|
|
|
|
</script>
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
<style scoped>
|
|
|
|
|