chart.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. <script lang="ts" setup>
  2. import { IotDeviceApi } from '@/api/pms/device'
  3. import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
  4. import { useMqtt } from '@/utils/useMqtt'
  5. import { Dimensions, formatIotValue } from '@/utils/useSocketBus'
  6. import dayjs from 'dayjs'
  7. import * as echarts from 'echarts'
  8. import { neonColors } from '@/utils/td-color'
  9. const props = defineProps({
  10. id: {
  11. type: Number,
  12. required: true
  13. },
  14. deviceCode: {
  15. type: String,
  16. required: true
  17. },
  18. deviceName: {
  19. type: String,
  20. required: true
  21. },
  22. mqttUrl: {
  23. type: String,
  24. required: true
  25. },
  26. ifInline: {
  27. type: String,
  28. required: true
  29. },
  30. lastInlineTime: {
  31. type: String,
  32. required: true
  33. },
  34. deptName: {
  35. type: String,
  36. required: true
  37. },
  38. vehicleName: {
  39. type: String,
  40. required: true
  41. },
  42. carOnline: {
  43. type: String,
  44. required: true
  45. },
  46. // isRealTime: {
  47. // type: Boolean,
  48. // default: true
  49. // },
  50. date: {
  51. type: Array as PropType<Array<string>>,
  52. required: true
  53. },
  54. token: {
  55. type: String,
  56. required: true
  57. }
  58. })
  59. const dimensions = ref<Omit<Dimensions, 'color' | 'bgHover' | 'bgActive'>[]>([])
  60. const selectedDimension = ref<Record<string, boolean>>({})
  61. const { connect, destroy, isConnected, subscribe } = useMqtt()
  62. const handleMessageUpdate = (_topic: string, data: any) => {
  63. const valueMap = new Map<string, number>()
  64. for (const item of data) {
  65. const { id: identity, value: logValue, remark } = item
  66. const value = logValue ? Number(logValue) : 0
  67. if (identity) {
  68. valueMap.set(identity, value)
  69. }
  70. const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
  71. if (modelName && chartData.value[modelName]) {
  72. chartData.value[modelName].push({
  73. ts: dayjs.unix(remark).valueOf(),
  74. value
  75. })
  76. updateSingleSeries(modelName)
  77. }
  78. }
  79. }
  80. watch(isConnected, (newVal) => {
  81. if (newVal) {
  82. // subscribe(`/636/${props.deviceCode}/property/post`)
  83. // switch (props.deviceCode) {
  84. // case 'YF1539':
  85. // subscribe(`/656/${props.deviceCode}/property/post`)
  86. // case 'YF325':
  87. // case 'YF288':
  88. // case 'YF671':
  89. // case 'YF459':
  90. // subscribe(`/635/${props.deviceCode}/property/post`)
  91. // case 'YF649':
  92. // subscribe(`/636/${props.deviceCode}/property/post`)
  93. // default:
  94. // subscribe(`/636/${props.deviceCode}/property/post`)
  95. // }
  96. subscribe(props.mqttUrl)
  97. // subscribe('/636/YF649/property/post')
  98. }
  99. })
  100. async function loadDimensions() {
  101. if (!props.id) return
  102. try {
  103. dimensions.value = (((await IotDeviceApi.getIotDeviceTds(Number(props.id))) as any[]) ?? [])
  104. .sort((a, b) => b.modelOrder - a.modelOrder)
  105. .map((item) => {
  106. const { value, suffix, isText } = formatIotValue(item.value)
  107. return {
  108. identifier: item.identifier,
  109. name: item.modelName,
  110. value: value,
  111. suffix: suffix,
  112. isText: isText,
  113. response: false
  114. }
  115. })
  116. .filter((item) => item.isText === false)
  117. selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, true]))
  118. } catch (error) {
  119. console.error(error)
  120. }
  121. }
  122. interface ChartData {
  123. [key: Dimensions['name']]: { ts: number; value: number }[]
  124. }
  125. const chartData = ref<ChartData>({})
  126. let intervalArr = ref<number[]>([])
  127. let maxInterval = ref(0)
  128. let minInterval = ref(0)
  129. const chartRef = ref<HTMLDivElement | null>(null)
  130. let chart: echarts.ECharts | null = null
  131. function genderIntervalArr(init: boolean = false) {
  132. // 1. 使用正负无穷大初始化,避免先把所有数存入数组
  133. let maxVal = -Infinity
  134. let minVal = Infinity
  135. let hasData = false
  136. // 2. 直接遍历数据查找最值 (不使用 spread ...)
  137. for (const [key, value] of Object.entries(selectedDimension.value)) {
  138. if (value) {
  139. const dataset = chartData.value[key]
  140. if (dataset && dataset.length > 0) {
  141. hasData = true
  142. // 使用循环代替 ...spread
  143. for (const item of dataset) {
  144. const val = item.value
  145. if (val > maxVal) maxVal = val
  146. if (val < minVal) minVal = val
  147. }
  148. }
  149. }
  150. }
  151. // 3. 处理无数据的默认情况
  152. if (!hasData) {
  153. maxVal = 10000
  154. minVal = 0
  155. } else {
  156. // 保持你原有的逻辑:如果最小值大于0,则归零
  157. minVal = minVal > 0 ? 0 : minVal
  158. }
  159. // 4. 计算位数逻辑 (保持不变)
  160. const maxDigits = (Math.floor(maxVal) + '').length
  161. const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
  162. const interval = Math.max(maxDigits, minDigits)
  163. maxInterval.value = interval
  164. minInterval.value = minDigits
  165. intervalArr.value = [0]
  166. for (let i = 1; i <= interval; i++) {
  167. intervalArr.value.push(Math.pow(10, i))
  168. }
  169. if (!init) {
  170. chart?.setOption({
  171. yAxis: {
  172. min: -minInterval.value,
  173. max: maxInterval.value
  174. }
  175. })
  176. }
  177. }
  178. function chartInit() {
  179. if (!chart) return
  180. chart.on('legendselectchanged', (params: any) => {
  181. // 1. 同步选中状态
  182. selectedDimension.value = params.selected
  183. const clickedModelName = params.name
  184. const isSelected = params.selected[clickedModelName]
  185. const oldMax = maxInterval.value
  186. const oldMin = minInterval.value
  187. genderIntervalArr()
  188. const isScaleChanged = oldMax !== maxInterval.value || oldMin !== minInterval.value
  189. if (isScaleChanged) {
  190. Object.keys(selectedDimension.value).forEach((name) => {
  191. if (selectedDimension.value[name]) {
  192. updateSingleSeries(name)
  193. }
  194. })
  195. } else {
  196. if (isSelected) {
  197. updateSingleSeries(clickedModelName)
  198. }
  199. }
  200. })
  201. window.addEventListener('resize', () => {
  202. if (chart) chart.resize()
  203. })
  204. }
  205. function render() {
  206. if (!chartRef.value) return
  207. if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
  208. chartInit()
  209. genderIntervalArr(true)
  210. chart.setOption({
  211. color: neonColors,
  212. animation: true,
  213. animationDuration: 200,
  214. animationEasing: 'linear',
  215. animationDurationUpdate: 200,
  216. animationEasingUpdate: 'linear',
  217. grid: {
  218. left: '3%',
  219. top: '60px',
  220. right: '6%',
  221. bottom: '10%',
  222. containLabel: true,
  223. show: false
  224. },
  225. tooltip: {
  226. trigger: 'axis',
  227. confine: true,
  228. enterable: true,
  229. className: 'echarts-tooltip-scroll',
  230. extraCssText:
  231. 'max-height: 300px; overflow-y: auto; pointer-events: auto; border-radius: 4px;',
  232. backgroundColor: 'rgba(11, 17, 33, 0.95)',
  233. borderColor: '#22d3ee',
  234. borderWidth: 1,
  235. textStyle: {
  236. color: '#e2e8f0'
  237. },
  238. axisPointer: {
  239. type: 'cross',
  240. label: { backgroundColor: '#22d3ee', color: '#000' },
  241. lineStyle: { color: 'rgba(255,255,255,0.3)', type: 'dashed' }
  242. },
  243. formatter: (params: any) => {
  244. let d = `<div style="font-weight:bold; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:5px; margin-bottom:5px;">${params[0].axisValueLabel}</div>`
  245. const exist: string[] = []
  246. params = params.filter((el: any) => {
  247. if (exist.includes(el.seriesName)) return false
  248. exist.push(el.seriesName)
  249. return true
  250. })
  251. // 优化列表显示,圆点使用原本的颜色
  252. let item = params.map(
  253. (
  254. el: any
  255. ) => `<div class="flex items-center justify-between mt-1" style="font-size:12px; min-width: 180px;">
  256. <span style="display:flex; align-items:center;">
  257. <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background-color:${el.color};margin-right:6px;"></span>
  258. <span style="color:#cbd5e1">${el.seriesName}</span>
  259. </span>
  260. <span style="color:#fff; font-weight:bold; margin-left:10px;">${el.value[2]?.toFixed(2)}</span>
  261. </div>`
  262. )
  263. return d + item.join('')
  264. }
  265. },
  266. xAxis: {
  267. type: 'time',
  268. boundaryGap: ['0%', '25%'],
  269. axisLabel: {
  270. formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
  271. rotate: 0,
  272. align: 'center',
  273. color: '#94a3b8',
  274. fontSize: 11
  275. },
  276. splitLine: {
  277. show: false,
  278. lineStyle: { color: 'rgba(255,255,255,0.5)', type: 'dashed' }
  279. }
  280. },
  281. dataZoom: [
  282. { type: 'inside', xAxisIndex: 0 },
  283. {
  284. type: 'slider',
  285. xAxisIndex: 0,
  286. height: 20,
  287. bottom: 10,
  288. borderColor: 'transparent',
  289. backgroundColor: 'rgba(255,255,255,0.05)',
  290. fillerColor: 'rgba(34,211,238,0.2)',
  291. handleStyle: {
  292. color: '#22d3ee',
  293. borderColor: '#22d3ee'
  294. },
  295. labelFormatter: (value: any) => {
  296. return dayjs(value).format('YYYY-MM-DD\nHH:mm:ss')
  297. },
  298. textStyle: {
  299. color: '#94a3b8',
  300. fontSize: 10,
  301. lineHeight: 12
  302. }
  303. }
  304. ],
  305. yAxis: {
  306. type: 'value',
  307. min: -minInterval.value,
  308. max: maxInterval.value,
  309. interval: 1,
  310. axisLabel: {
  311. color: '#94a3b8',
  312. formatter: (v) => {
  313. const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
  314. if (Math.abs(num) >= 10000) return (num / 10000).toFixed(0) + 'w'
  315. if (Math.abs(num) >= 1000) return (num / 1000).toFixed(0) + 'k'
  316. return num.toLocaleString()
  317. }
  318. },
  319. show: true,
  320. splitLine: {
  321. show: true,
  322. lineStyle: {
  323. color: 'rgba(255,255,255,0.05)',
  324. type: 'dashed'
  325. }
  326. },
  327. axisPointer: {
  328. show: true,
  329. snap: false, // 必须设为 false,才能平滑显示小数部分的真实值
  330. label: {
  331. show: true,
  332. backgroundColor: '#22d3ee', // 青色背景
  333. color: '#000', // 黑色文字
  334. fontWeight: 'bold',
  335. precision: 2, // 保证精度
  336. // --- 具体的实现逻辑 ---
  337. formatter: (params: any) => {
  338. const val = params.value // 这里拿到的是索引值,比如 4.21
  339. if (val === 0) return '0.00'
  340. // A. 处理正负号
  341. const sign = val >= 0 ? 1 : -1
  342. const absVal = Math.abs(val)
  343. // B. 分离 整数部分(区间下标) 和 小数部分(区间内百分比)
  344. const idx = Math.floor(absVal)
  345. const percent = absVal - idx
  346. // C. 安全检查:如果 intervalArr 还没生成,直接返回指数值
  347. if (!intervalArr.value || intervalArr.value.length === 0) {
  348. return (sign * Math.pow(10, absVal)).toFixed(2)
  349. }
  350. // D. 获取该区间的真实数值范围
  351. // 例如 idx=2, 对应 intervalArr[2]=100, intervalArr[3]=1000
  352. const min = intervalArr.value[idx]
  353. // 如果到了最后一个区间,或者越界,就默认下一级是当前的10倍(防止报错)
  354. const max =
  355. intervalArr.value[idx + 1] !== undefined ? intervalArr.value[idx + 1] : min * 10
  356. // E. 反向线性插值公式
  357. // 真实值 = 下界 + (上下界之差 * 百分比)
  358. const realVal = min + (max - min) * percent
  359. // F. 加上符号并格式化
  360. return (realVal * sign).toFixed(2)
  361. }
  362. }
  363. }
  364. },
  365. legend: {
  366. type: 'scroll', // 开启滚动,防止遮挡
  367. top: 10,
  368. left: 'center',
  369. width: '90%',
  370. textStyle: {
  371. color: '#e2e8f0', // 亮白色
  372. fontSize: 12
  373. },
  374. pageIconColor: '#22d3ee',
  375. pageIconInactiveColor: '#475569',
  376. pageTextStyle: { color: '#fff' },
  377. data: dimensions.value.map((item) => item.name),
  378. selected: selectedDimension.value,
  379. show: true
  380. },
  381. // legend: {
  382. // data: dimensions.value.map((item) => item.name),
  383. // selected: selectedDimension.value,
  384. // show: true
  385. // },
  386. series: dimensions.value.map((item) => ({
  387. name: item.name,
  388. type: 'line',
  389. smooth: 0.3,
  390. showSymbol: false,
  391. endLabel: {
  392. show: true,
  393. formatter: (params) => params.value[2]?.toFixed(2),
  394. offset: [4, 0],
  395. color: '#fff',
  396. backgroundColor: 'auto',
  397. padding: [2, 6],
  398. borderRadius: 4,
  399. fontSize: 11,
  400. fontWeight: 'bold'
  401. },
  402. emphasis: {
  403. focus: 'series',
  404. lineStyle: { width: 4 }
  405. },
  406. lineStyle: {
  407. width: 3,
  408. shadowColor: 'rgba(0, 0, 0, 0.5)',
  409. shadowBlur: 10,
  410. shadowOffsetY: 5
  411. },
  412. data: [] // 占位数组
  413. }))
  414. })
  415. }
  416. function mapData({ value, ts }) {
  417. if (value === null || value === undefined || value === 0) return [ts, 0, 0]
  418. const isPositive = value > 0
  419. const absItem = Math.abs(value)
  420. if (!intervalArr.value.length) return [ts, 0, value]
  421. const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
  422. const min_index = intervalArr.value.findIndex((v) => v === min_value)
  423. let denominator = 1
  424. if (min_index < intervalArr.value.length - 1) {
  425. denominator = intervalArr.value[min_index + 1] - intervalArr.value[min_index]
  426. } else {
  427. denominator = intervalArr.value[min_index] || 1
  428. }
  429. const new_value = (absItem - min_value) / denominator + min_index
  430. return [ts, isPositive ? new_value : -new_value, value]
  431. }
  432. function updateSingleSeries(name: string) {
  433. if (!chart) render()
  434. if (!chart) return
  435. const idx = dimensions.value.findIndex((item) => item.name === name)
  436. if (idx === -1) return
  437. const data = chartData.value[name].map((v) => mapData(v))
  438. chart.setOption({
  439. series: [{ name, data }]
  440. })
  441. }
  442. const lastTsMap = ref<Record<Dimensions['name'], number>>({})
  443. const chartLoading = ref(false)
  444. async function initLoadChartData(real_time: boolean = true) {
  445. if (!dimensions.value.length) return
  446. chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
  447. chartLoading.value = true
  448. dimensions.value = dimensions.value.map((item) => {
  449. item.response = true
  450. return item
  451. })
  452. for (const item of dimensions.value) {
  453. const { identifier, name } = item
  454. try {
  455. const res = await IotStatApi.getDeviceInfoChart(
  456. props.deviceCode,
  457. identifier,
  458. props.date[0],
  459. props.date[1]
  460. )
  461. const sorted = res
  462. .sort((a, b) => a.ts - b.ts)
  463. .map((item) => ({ ts: item.ts, value: item.value }))
  464. chartData.value[name] = sorted
  465. lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
  466. genderIntervalArr()
  467. updateSingleSeries(name)
  468. chartLoading.value = false
  469. } finally {
  470. item.response = false
  471. }
  472. }
  473. if (real_time) {
  474. connect(`wss://aims.deepoil.cc/mqtt`, { password: props.token }, handleMessageUpdate)
  475. }
  476. }
  477. async function initfn(load: boolean = true, real_time: boolean = true) {
  478. if (load) await loadDimensions()
  479. render()
  480. initLoadChartData(real_time)
  481. }
  482. onMounted(() => {
  483. initfn()
  484. })
  485. watch(
  486. () => props.date,
  487. async (newDate, oldDate) => {
  488. if (!newDate || newDate.length !== 2) return
  489. if (oldDate && newDate[0] === oldDate[0] && newDate[1] === oldDate[1]) return
  490. await cancelAllRequests()
  491. destroy()
  492. const endTime = dayjs(newDate[1])
  493. const now = dayjs()
  494. const isRealTime = endTime.isAfter(now.subtract(1, 'minute'))
  495. if (chart) chart.clear()
  496. if (isRealTime) initfn(false)
  497. else initfn(false, false)
  498. }
  499. )
  500. onUnmounted(() => {
  501. destroy()
  502. window.removeEventListener('resize', () => {
  503. if (chart) chart.resize()
  504. })
  505. })
  506. const router = useRouter()
  507. function handleDetailClick() {
  508. router.push({
  509. name: 'MonitoringDetail',
  510. query: {
  511. id: props.id,
  512. ifInline: props.ifInline,
  513. carOnline: props.carOnline,
  514. time: props.lastInlineTime,
  515. name: props.deviceName,
  516. code: props.deviceCode,
  517. dept: props.deptName,
  518. vehicle: props.vehicleName,
  519. mqttUrl: props.mqttUrl
  520. }
  521. })
  522. }
  523. </script>
  524. <template>
  525. <div class="h-100 rounded-lg chart-container flex flex-col">
  526. <header class="chart-header justify-between">
  527. <div class="flex items-center">
  528. <div class="title-icon"></div>
  529. <div>{{ `${props.deviceCode}-${props.deviceName}` }}</div>
  530. </div>
  531. <el-button link type="primary" class="group" @click="handleDetailClick">
  532. 详情
  533. <div
  534. class="i-material-symbols:arrow-right-alt-rounded size-4 transition-transform group-hover:translate-x-1"
  535. ></div>
  536. </el-button>
  537. </header>
  538. <main
  539. class="flex-1 chart-main"
  540. ref="chartRef"
  541. v-loading="chartLoading"
  542. element-loading-background="transparent"
  543. ></main>
  544. </div>
  545. </template>
  546. <style scoped>
  547. .chart-container {
  548. position: relative;
  549. overflow: hidden;
  550. background-color: rgb(11 17 33 / 90%);
  551. border: 2px solid rgb(34 211 238 / 30%);
  552. box-shadow:
  553. 0 0 20px rgb(0 0 0 / 80%),
  554. inset 0 0 15px rgb(34 211 238 / 10%);
  555. transition:
  556. border-color 0.3s ease,
  557. transform 0.3s ease;
  558. }
  559. .chart-container::before {
  560. position: absolute;
  561. pointer-events: none;
  562. background: radial-gradient(
  563. 400px circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
  564. rgb(34 211 238 / 5%),
  565. transparent 40%
  566. );
  567. content: '';
  568. inset: 0;
  569. }
  570. .chart-container:hover {
  571. border-color: rgb(34 211 238 / 60%);
  572. transform: scale(1.005);
  573. }
  574. .chart-header {
  575. display: flex;
  576. padding: 12px 16px;
  577. font-size: 18px;
  578. font-weight: 600;
  579. letter-spacing: 1px;
  580. color: #e2e8f0;
  581. background: rgb(255 255 255 / 3%);
  582. border-bottom: 1px solid transparent;
  583. border-image: linear-gradient(to right, rgb(34 211 238 / 50%), transparent) 1;
  584. align-items: center;
  585. }
  586. .title-icon {
  587. width: 4px;
  588. height: 16px;
  589. margin-right: 10px;
  590. background: #22d3ee;
  591. box-shadow: 0 0 8px #22d3ee;
  592. }
  593. .chart-main {
  594. padding-top: 12px;
  595. background-image: radial-gradient(circle at 50% 50%, rgb(34 211 238 / 10%) 0%, transparent 80%),
  596. linear-gradient(to right, rgb(34 211 238 / 15%) 1px, transparent 1px),
  597. linear-gradient(to bottom, rgb(34 211 238 / 15%) 1px, transparent 1px),
  598. linear-gradient(135deg, rgb(11 17 33 / 90%) 0%, rgb(6 9 18 / 95%) 100%);
  599. background-size:
  600. 100% 100%,
  601. 40px 40px,
  602. 40px 40px,
  603. 100% 100%;
  604. }
  605. /* 针对 ECharts tooltip 的滚动条美化 */
  606. .echarts-tooltip-scroll::-webkit-scrollbar {
  607. width: 6px;
  608. }
  609. .echarts-tooltip-scroll::-webkit-scrollbar-thumb {
  610. background: #22d3ee; /* 青色滑块 */
  611. border-radius: 3px;
  612. }
  613. .echarts-tooltip-scroll::-webkit-scrollbar-track {
  614. background: rgb(255 255 255 / 10%); /* 深色轨道 */
  615. }
  616. </style>