|
|
@@ -45,10 +45,10 @@ const props = defineProps({
|
|
|
type: String,
|
|
|
required: true
|
|
|
},
|
|
|
- // isRealTime: {
|
|
|
- // type: Boolean,
|
|
|
- // default: true
|
|
|
- // },
|
|
|
+ isRealTime: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ },
|
|
|
date: {
|
|
|
type: Array as PropType<Array<string>>,
|
|
|
required: true
|
|
|
@@ -64,8 +64,14 @@ const selectedDimension = ref<Record<string, boolean>>({})
|
|
|
|
|
|
const { connect, destroy, isConnected, subscribe } = useMqtt()
|
|
|
|
|
|
+const REALTIME_WINDOW_MS = 5 * 60 * 1000
|
|
|
+let isProgrammaticDataZoomUpdate = false
|
|
|
+
|
|
|
const handleMessageUpdate = (_topic: string, data: any) => {
|
|
|
const valueMap = new Map<string, number>()
|
|
|
+ const nextUpdatedNames = new Set<string>()
|
|
|
+ const wasFollowingFullRange = isFollowingFullRange.value
|
|
|
+ const previousVisibleRange = { ...visibleTimeRange.value }
|
|
|
|
|
|
for (const item of data) {
|
|
|
const { id: identity, value: logValue, remark } = item
|
|
|
@@ -79,18 +85,46 @@ const handleMessageUpdate = (_topic: string, data: any) => {
|
|
|
const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
|
|
|
|
|
|
if (modelName && chartData.value[modelName]) {
|
|
|
- chartData.value[modelName].push({
|
|
|
- ts: dayjs.unix(remark).valueOf(),
|
|
|
+ const ts = dayjs.unix(remark).valueOf()
|
|
|
+
|
|
|
+ appendChartPoint(modelName, {
|
|
|
+ ts,
|
|
|
value
|
|
|
})
|
|
|
|
|
|
- if (isFollowingFullRange.value) {
|
|
|
- resetVisibleTimeRange()
|
|
|
- }
|
|
|
-
|
|
|
- updateSeriesByNames([modelName])
|
|
|
+ nextUpdatedNames.add(modelName)
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ if (!nextUpdatedNames.size) return
|
|
|
+
|
|
|
+ const { maxTs } = getGlobalTimeExtent()
|
|
|
+ if (maxTs === null) return
|
|
|
+
|
|
|
+ const fullRange = getRealtimeTimeRange(maxTs)
|
|
|
+ const trimmedNames = trimChartDataToRealtimeWindow(maxTs)
|
|
|
+ const selectedRange = wasFollowingFullRange
|
|
|
+ ? fullRange
|
|
|
+ : clampTimeRangeToFullRange(visibleTimeRange.value, fullRange)
|
|
|
+
|
|
|
+ visibleTimeRange.value = selectedRange
|
|
|
+ isFollowingFullRange.value = isSameTimeRange(selectedRange, fullRange)
|
|
|
+
|
|
|
+ trimmedNames.forEach((name) => nextUpdatedNames.add(name))
|
|
|
+
|
|
|
+ const didVisibleRangeChange =
|
|
|
+ previousVisibleRange.startTs === null ||
|
|
|
+ previousVisibleRange.endTs === null ||
|
|
|
+ Math.abs(previousVisibleRange.startTs - selectedRange.startTs) > 1 ||
|
|
|
+ Math.abs(previousVisibleRange.endTs - selectedRange.endTs) > 1
|
|
|
+
|
|
|
+ applyChartRangeAndSeries(
|
|
|
+ fullRange,
|
|
|
+ selectedRange,
|
|
|
+ wasFollowingFullRange || didVisibleRangeChange
|
|
|
+ ? getSelectedSeriesNames()
|
|
|
+ : Array.from(nextUpdatedNames)
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
watch(isConnected, (newVal) => {
|
|
|
@@ -140,8 +174,13 @@ async function loadDimensions() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+interface ChartPoint {
|
|
|
+ ts: number
|
|
|
+ value: number
|
|
|
+}
|
|
|
+
|
|
|
interface ChartData {
|
|
|
- [key: Dimensions['name']]: { ts: number; value: number }[]
|
|
|
+ [key: Dimensions['name']]: ChartPoint[]
|
|
|
}
|
|
|
|
|
|
const chartData = ref<ChartData>({})
|
|
|
@@ -158,6 +197,7 @@ const visibleTimeRange = ref<{
|
|
|
})
|
|
|
|
|
|
const isFollowingFullRange = ref(true)
|
|
|
+const isRealtimeMode = ref(true)
|
|
|
|
|
|
const TREND_AXIS_MIN = 0
|
|
|
const TREND_AXIS_MAX = 100
|
|
|
@@ -202,30 +242,187 @@ const getGlobalTimeExtent = () => {
|
|
|
}
|
|
|
|
|
|
function resetVisibleTimeRange() {
|
|
|
- const { minTs, maxTs } = getGlobalTimeExtent()
|
|
|
+ const { startTs, endTs } = getFullTimeRange()
|
|
|
|
|
|
visibleTimeRange.value = {
|
|
|
- startTs: minTs,
|
|
|
- endTs: maxTs
|
|
|
+ startTs,
|
|
|
+ endTs
|
|
|
}
|
|
|
|
|
|
isFollowingFullRange.value = true
|
|
|
}
|
|
|
|
|
|
-function syncVisibleTimeRangeFromChart() {
|
|
|
+function getFullTimeRange() {
|
|
|
const { minTs, maxTs } = getGlobalTimeExtent()
|
|
|
|
|
|
- if (!chart || minTs === null || maxTs === null) {
|
|
|
+ if (isRealtimeMode.value && maxTs !== null) {
|
|
|
+ return getRealtimeTimeRange(maxTs)
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ startTs: minTs,
|
|
|
+ endTs: maxTs
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function getRealtimeTimeRange(endTs: number) {
|
|
|
+ return {
|
|
|
+ startTs: endTs - REALTIME_WINDOW_MS,
|
|
|
+ endTs
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function isSameTimeRange(
|
|
|
+ range: { startTs: number | null; endTs: number | null },
|
|
|
+ fullRange: { startTs: number; endTs: number }
|
|
|
+) {
|
|
|
+ if (range.startTs === null || range.endTs === null) return false
|
|
|
+
|
|
|
+ const tolerance = Math.max((fullRange.endTs - fullRange.startTs) * 0.001, 1000)
|
|
|
+
|
|
|
+ return (
|
|
|
+ Math.abs(range.startTs - fullRange.startTs) <= tolerance &&
|
|
|
+ Math.abs(range.endTs - fullRange.endTs) <= tolerance
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function clampTimeRangeToFullRange(
|
|
|
+ range: { startTs: number | null; endTs: number | null },
|
|
|
+ fullRange: { startTs: number; endTs: number }
|
|
|
+) {
|
|
|
+ if (range.startTs === null || range.endTs === null) return fullRange
|
|
|
+
|
|
|
+ const startTs = Math.max(Math.min(range.startTs, range.endTs), fullRange.startTs)
|
|
|
+ const endTs = Math.min(Math.max(range.startTs, range.endTs), fullRange.endTs)
|
|
|
+
|
|
|
+ if (startTs > endTs) return fullRange
|
|
|
+
|
|
|
+ return {
|
|
|
+ startTs,
|
|
|
+ endTs
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function appendChartPoint(name: string, point: ChartPoint) {
|
|
|
+ const dataset = chartData.value[name]
|
|
|
+ const lastPoint = dataset.at(-1)
|
|
|
+
|
|
|
+ if (!lastPoint || point.ts >= lastPoint.ts) {
|
|
|
+ dataset.push(point)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const insertIndex = dataset.findIndex(({ ts }) => ts > point.ts)
|
|
|
+ dataset.splice(insertIndex === -1 ? dataset.length : insertIndex, 0, point)
|
|
|
+}
|
|
|
+
|
|
|
+function trimDatasetToRealtimeWindow(dataset: ChartPoint[], startTs: number, endTs: number) {
|
|
|
+ const boundedDataset = dataset.filter(({ ts }) => Number.isFinite(ts) && ts <= endTs)
|
|
|
+
|
|
|
+ if (!boundedDataset.length) return []
|
|
|
+
|
|
|
+ const firstVisibleIndex = boundedDataset.findIndex(({ ts }) => ts >= startTs)
|
|
|
+
|
|
|
+ if (firstVisibleIndex === -1) {
|
|
|
+ return boundedDataset.slice(-1)
|
|
|
+ }
|
|
|
+
|
|
|
+ return boundedDataset.slice(Math.max(firstVisibleIndex - 1, 0))
|
|
|
+}
|
|
|
+
|
|
|
+function trimChartDataToRealtimeWindow(endTs: number | null) {
|
|
|
+ if (endTs === null || !Number.isFinite(endTs)) return []
|
|
|
+
|
|
|
+ const { startTs } = getRealtimeTimeRange(endTs)
|
|
|
+ const trimmedNames: string[] = []
|
|
|
+
|
|
|
+ Object.entries(chartData.value).forEach(([name, dataset]) => {
|
|
|
+ const nextDataset = trimDatasetToRealtimeWindow(dataset, startTs, endTs)
|
|
|
+
|
|
|
+ if (nextDataset.length !== dataset.length || nextDataset[0] !== dataset[0]) {
|
|
|
+ chartData.value[name] = nextDataset
|
|
|
+ trimmedNames.push(name)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ return trimmedNames
|
|
|
+}
|
|
|
+
|
|
|
+function getDataZoomRangeOptions(range: { startTs: number; endTs: number }) {
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ id: 'monitoring-board-inside-zoom',
|
|
|
+ startValue: range.startTs,
|
|
|
+ endValue: range.endTs
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'monitoring-board-slider-zoom',
|
|
|
+ startValue: range.startTs,
|
|
|
+ endValue: range.endTs
|
|
|
+ }
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
+function applyChartRangeAndSeries(
|
|
|
+ fullRange: { startTs: number; endTs: number },
|
|
|
+ selectedRange: { startTs: number; endTs: number },
|
|
|
+ names: string[]
|
|
|
+) {
|
|
|
+ if (!chart) render()
|
|
|
+ if (!chart) return
|
|
|
+
|
|
|
+ isProgrammaticDataZoomUpdate = true
|
|
|
+
|
|
|
+ chart.setOption(
|
|
|
+ {
|
|
|
+ xAxis: {
|
|
|
+ min: fullRange.startTs,
|
|
|
+ max: fullRange.endTs
|
|
|
+ },
|
|
|
+ dataZoom: getDataZoomRangeOptions(selectedRange),
|
|
|
+ series: names.map((name) => ({
|
|
|
+ name,
|
|
|
+ data: buildSeriesData(name)
|
|
|
+ }))
|
|
|
+ },
|
|
|
+ {
|
|
|
+ lazyUpdate: true,
|
|
|
+ silent: true
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ window.setTimeout(() => {
|
|
|
+ isProgrammaticDataZoomUpdate = false
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function getDataZoomPayload(params?: any) {
|
|
|
+ const payload = Array.isArray(params?.batch) ? params.batch[0] : params
|
|
|
+
|
|
|
+ if (
|
|
|
+ payload &&
|
|
|
+ ['startValue', 'endValue', 'start', 'end'].some((key) => payload[key] !== undefined)
|
|
|
+ ) {
|
|
|
+ return payload
|
|
|
+ }
|
|
|
+
|
|
|
+ const dataZoomOptions = chart?.getOption().dataZoom
|
|
|
+ return Array.isArray(dataZoomOptions) ? (dataZoomOptions[0] as any) : undefined
|
|
|
+}
|
|
|
+
|
|
|
+function syncVisibleTimeRangeFromChart(params?: any) {
|
|
|
+ const { startTs: fullStartTs, endTs: fullEndTs } = getFullTimeRange()
|
|
|
+
|
|
|
+ if (!chart || fullStartTs === null || fullEndTs === null) {
|
|
|
visibleTimeRange.value = {
|
|
|
- startTs: minTs,
|
|
|
- endTs: maxTs
|
|
|
+ startTs: fullStartTs,
|
|
|
+ endTs: fullEndTs
|
|
|
}
|
|
|
isFollowingFullRange.value = true
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- const dataZoomOptions = chart.getOption().dataZoom
|
|
|
- const primaryDataZoom = Array.isArray(dataZoomOptions) ? (dataZoomOptions[0] as any) : undefined
|
|
|
+ const primaryDataZoom = getDataZoomPayload(params)
|
|
|
|
|
|
if (!primaryDataZoom) {
|
|
|
resetVisibleTimeRange()
|
|
|
@@ -235,8 +432,8 @@ function syncVisibleTimeRangeFromChart() {
|
|
|
const startValue = Number(primaryDataZoom.startValue)
|
|
|
const endValue = Number(primaryDataZoom.endValue)
|
|
|
|
|
|
- let nextStartTs = minTs
|
|
|
- let nextEndTs = maxTs
|
|
|
+ let nextStartTs = fullStartTs
|
|
|
+ let nextEndTs = fullEndTs
|
|
|
|
|
|
if (Number.isFinite(startValue) && Number.isFinite(endValue)) {
|
|
|
nextStartTs = Math.min(startValue, endValue)
|
|
|
@@ -244,23 +441,25 @@ function syncVisibleTimeRangeFromChart() {
|
|
|
} else {
|
|
|
const startPercent = Number(primaryDataZoom.start ?? 0)
|
|
|
const endPercent = Number(primaryDataZoom.end ?? 100)
|
|
|
- const range = maxTs - minTs
|
|
|
+ const range = fullEndTs - fullStartTs
|
|
|
|
|
|
- nextStartTs = minTs + (range * Math.min(startPercent, endPercent)) / 100
|
|
|
- nextEndTs = minTs + (range * Math.max(startPercent, endPercent)) / 100
|
|
|
+ nextStartTs = fullStartTs + (range * Math.min(startPercent, endPercent)) / 100
|
|
|
+ nextEndTs = fullStartTs + (range * Math.max(startPercent, endPercent)) / 100
|
|
|
}
|
|
|
|
|
|
- visibleTimeRange.value = {
|
|
|
+ const nextRange = {
|
|
|
startTs: nextStartTs,
|
|
|
endTs: nextEndTs
|
|
|
}
|
|
|
|
|
|
- const tolerance = Math.max((maxTs - minTs) * 0.001, 1)
|
|
|
- isFollowingFullRange.value =
|
|
|
- Math.abs(nextStartTs - minTs) <= tolerance && Math.abs(nextEndTs - maxTs) <= tolerance
|
|
|
+ visibleTimeRange.value = nextRange
|
|
|
+ isFollowingFullRange.value = isSameTimeRange(nextRange, {
|
|
|
+ startTs: fullStartTs,
|
|
|
+ endTs: fullEndTs
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
-const getVisibleDataset = (dataset: { ts: number; value: number }[]) => {
|
|
|
+const getVisibleDataset = (dataset: ChartPoint[]) => {
|
|
|
const { startTs, endTs } = visibleTimeRange.value
|
|
|
|
|
|
if (startTs === null || endTs === null) {
|
|
|
@@ -270,7 +469,7 @@ const getVisibleDataset = (dataset: { ts: number; value: number }[]) => {
|
|
|
return dataset.filter(({ ts }) => ts >= startTs && ts <= endTs)
|
|
|
}
|
|
|
|
|
|
-const getTrendStats = (dataset: { ts: number; value: number }[]): TrendStats => {
|
|
|
+const getTrendStats = (dataset: ChartPoint[]): TrendStats => {
|
|
|
const values = getVisibleDataset(dataset)
|
|
|
.map(({ value }) => Number(value))
|
|
|
.filter((value) => Number.isFinite(value))
|
|
|
@@ -375,16 +574,20 @@ function updateSeriesByNames(names: string[]) {
|
|
|
if (!chart) render()
|
|
|
if (!chart || !names.length) return
|
|
|
|
|
|
- chart.setOption({
|
|
|
- series: names.map((name) => ({
|
|
|
- name,
|
|
|
- data: buildSeriesData(name)
|
|
|
- }))
|
|
|
- })
|
|
|
+ chart.setOption(
|
|
|
+ {
|
|
|
+ series: names.map((name) => ({
|
|
|
+ name,
|
|
|
+ data: buildSeriesData(name)
|
|
|
+ }))
|
|
|
+ },
|
|
|
+ {
|
|
|
+ lazyUpdate: true
|
|
|
+ }
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
const refreshVisibleSeries = useDebounceFn(() => {
|
|
|
- syncVisibleTimeRangeFromChart()
|
|
|
updateSeriesByNames(getSelectedSeriesNames())
|
|
|
}, 50)
|
|
|
|
|
|
@@ -403,7 +606,10 @@ function chartInit() {
|
|
|
})
|
|
|
|
|
|
chart.off('datazoom')
|
|
|
- chart.on('datazoom', () => {
|
|
|
+ chart.on('datazoom', (params: any) => {
|
|
|
+ if (isProgrammaticDataZoomUpdate) return
|
|
|
+
|
|
|
+ syncVisibleTimeRangeFromChart(params)
|
|
|
refreshVisibleSeries()
|
|
|
})
|
|
|
|
|
|
@@ -420,10 +626,10 @@ function render() {
|
|
|
|
|
|
chart.setOption({
|
|
|
color: neonColors,
|
|
|
- animation: true,
|
|
|
- animationDuration: 200,
|
|
|
+ animation: false,
|
|
|
+ animationDuration: 0,
|
|
|
animationEasing: 'linear',
|
|
|
- animationDurationUpdate: 200,
|
|
|
+ animationDurationUpdate: 0,
|
|
|
animationEasingUpdate: 'linear',
|
|
|
grid: {
|
|
|
left: '3%',
|
|
|
@@ -491,8 +697,15 @@ function render() {
|
|
|
}
|
|
|
},
|
|
|
dataZoom: [
|
|
|
- { type: 'inside', xAxisIndex: 0, filterMode: 'none', throttle: 50 },
|
|
|
{
|
|
|
+ id: 'monitoring-board-inside-zoom',
|
|
|
+ type: 'inside',
|
|
|
+ xAxisIndex: 0,
|
|
|
+ filterMode: 'none',
|
|
|
+ throttle: 50
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'monitoring-board-slider-zoom',
|
|
|
type: 'slider',
|
|
|
xAxisIndex: 0,
|
|
|
filterMode: 'none',
|
|
|
@@ -585,11 +798,11 @@ function render() {
|
|
|
|
|
|
emphasis: {
|
|
|
focus: 'series',
|
|
|
- lineStyle: { width: 4 }
|
|
|
+ lineStyle: { width: 3 }
|
|
|
},
|
|
|
|
|
|
lineStyle: {
|
|
|
- width: 3,
|
|
|
+ width: 2,
|
|
|
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
|
|
shadowBlur: 10,
|
|
|
shadowOffsetY: 5
|
|
|
@@ -608,6 +821,7 @@ const chartLoading = ref(false)
|
|
|
async function initLoadChartData(real_time: boolean = true) {
|
|
|
if (!dimensions.value.length) return
|
|
|
|
|
|
+ isRealtimeMode.value = real_time
|
|
|
chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
|
|
|
isFollowingFullRange.value = true
|
|
|
resetVisibleTimeRange()
|
|
|
@@ -650,6 +864,17 @@ async function initLoadChartData(real_time: boolean = true) {
|
|
|
}
|
|
|
|
|
|
if (real_time) {
|
|
|
+ const { maxTs } = getGlobalTimeExtent()
|
|
|
+
|
|
|
+ if (maxTs !== null) {
|
|
|
+ trimChartDataToRealtimeWindow(maxTs)
|
|
|
+ const fullRange = getRealtimeTimeRange(maxTs)
|
|
|
+
|
|
|
+ visibleTimeRange.value = fullRange
|
|
|
+ isFollowingFullRange.value = true
|
|
|
+ applyChartRangeAndSeries(fullRange, fullRange, getSelectedSeriesNames())
|
|
|
+ }
|
|
|
+
|
|
|
connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
|
|
|
}
|
|
|
}
|
|
|
@@ -665,20 +890,23 @@ onMounted(() => {
|
|
|
})
|
|
|
|
|
|
watch(
|
|
|
- () => props.date,
|
|
|
- async (newDate, oldDate) => {
|
|
|
+ () => [props.date, props.isRealTime] as const,
|
|
|
+ async ([newDate, isRealTime], [oldDate, oldIsRealTime]) => {
|
|
|
if (!newDate || newDate.length !== 2) return
|
|
|
|
|
|
- if (oldDate && newDate[0] === oldDate[0] && newDate[1] === oldDate[1]) return
|
|
|
+ if (
|
|
|
+ oldDate &&
|
|
|
+ newDate[0] === oldDate[0] &&
|
|
|
+ newDate[1] === oldDate[1] &&
|
|
|
+ isRealTime === oldIsRealTime
|
|
|
+ ) {
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
await cancelAllRequests()
|
|
|
|
|
|
destroy()
|
|
|
|
|
|
- const endTime = dayjs(newDate[1])
|
|
|
- const now = dayjs()
|
|
|
- const isRealTime = endTime.isAfter(now.subtract(1, 'minute'))
|
|
|
-
|
|
|
if (chart) chart.clear()
|
|
|
|
|
|
if (isRealTime) initfn(false)
|
|
|
@@ -705,7 +933,8 @@ function handleDetailClick() {
|
|
|
name: props.deviceName,
|
|
|
code: props.deviceCode,
|
|
|
dept: props.deptName,
|
|
|
- vehicle: props.vehicleName
|
|
|
+ vehicle: props.vehicleName,
|
|
|
+ mqttUrl: props.mqttUrl
|
|
|
}
|
|
|
})
|
|
|
}
|