TimePointStrip.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. <script setup lang="ts">
  2. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  3. import type { DevicePoint, Fault, MeasurementType, TimePoint } from '../types'
  4. import { SITE_POINT_COLORS } from '../types'
  5. import { RUNNING_COLOR, STOPPED_COLOR, statusGradientColor } from '../statusColor'
  6. const props = defineProps<{
  7. points: TimePoint[]
  8. devicePoints: DevicePoint[]
  9. pointToType: Record<DevicePoint, MeasurementType>
  10. startIndex: number
  11. windowSize: number
  12. loading?: boolean
  13. minTime?: string
  14. maxTime?: string
  15. annotatedFileIds?: number[]
  16. faults?: Fault[]
  17. ruler?: { min: number; max: number } | null
  18. sitePoints?: string[]
  19. siteDescriptions?: Record<string, string>
  20. }>()
  21. const emit = defineEmits<{
  22. 'update:startIndex': [value: number]
  23. }>()
  24. const canvas = ref<HTMLCanvasElement | null>(null)
  25. const canvasHost = ref<HTMLElement | null>(null)
  26. let resizeObserver: ResizeObserver | undefined
  27. let drawLeft = 70
  28. function rowLabel(name: string) {
  29. return props.siteDescriptions?.[name] || name
  30. }
  31. const maxStart = computed(() => Math.max(0, props.points.length - props.windowSize))
  32. const endIndex = computed(() => Math.min(props.points.length, props.startIndex + props.windowSize))
  33. function displayDate(value: string | undefined) {
  34. if (!value) return '--'
  35. return value.replace('T', ' ').slice(0, 10)
  36. }
  37. function pointColor(devicePoint: DevicePoint, fileInfo: { rpm: number; status?: number }): string {
  38. const running = fileInfo.rpm > 0
  39. if (!running) return STOPPED_COLOR
  40. if (props.pointToType[devicePoint] === '压力') return statusGradientColor(fileInfo.status, props.ruler ?? null)
  41. return RUNNING_COLOR
  42. }
  43. function draw() {
  44. const element = canvas.value
  45. const host = canvasHost.value
  46. if (!element || !host) return
  47. const width = Math.max(host.clientWidth, 320)
  48. const topY = 32
  49. const rowGap = 39
  50. const rows = [...props.devicePoints, ...(props.sitePoints ?? [])]
  51. const annotationY = topY + rows.length * rowGap
  52. const tickY = annotationY + 109
  53. const height = tickY + 30
  54. const ratio = window.devicePixelRatio || 1
  55. element.width = width * ratio
  56. element.height = height * ratio
  57. element.style.width = `${width}px`
  58. element.style.height = `${height}px`
  59. const context = element.getContext('2d')
  60. if (!context) return
  61. context.setTransform(ratio, 0, 0, ratio, 0, 0)
  62. context.clearRect(0, 0, width, height)
  63. context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
  64. let maxLabelWidth = 70
  65. for (const name of rows) {
  66. maxLabelWidth = Math.max(maxLabelWidth, Math.ceil(context.measureText(rowLabel(name)).width) + 14)
  67. }
  68. drawLeft = maxLabelWidth
  69. const left = drawLeft
  70. const right = 20
  71. const available = Math.max(width - left - right, 1)
  72. const rowY = (index: number) => topY + index * rowGap
  73. const xAt = (index: number) => left + (props.points.length <= 1 ? 0 : index / (props.points.length - 1)) * available
  74. const selectedLeft = xAt(props.startIndex)
  75. const selectedRight = xAt(Math.min(props.points.length - 1, Math.max(props.startIndex, endIndex.value - 1)))
  76. const selectedWidth = Math.max(selectedRight - selectedLeft, 8)
  77. context.fillStyle = '#f4f6f7'
  78. context.fillRect(selectedLeft, 10, selectedWidth, 172)
  79. context.strokeStyle = '#dbe2e6'
  80. context.lineWidth = 1
  81. context.strokeRect(selectedLeft + 0.5, 10.5, selectedWidth - 1, 171)
  82. rows.forEach((rowName, rowIndex) => {
  83. const y = rowY(rowIndex)
  84. context.strokeStyle = '#d7e0e4'
  85. context.setLineDash([2, 5])
  86. context.beginPath()
  87. context.moveTo(left, y)
  88. context.lineTo(width - right, y)
  89. context.stroke()
  90. context.setLineDash([])
  91. context.fillStyle = '#52616b'
  92. context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
  93. context.fillText(rowLabel(rowName), 10, y + 4)
  94. const isSiteRow = rowIndex >= props.devicePoints.length
  95. const siteIndex = rowIndex - props.devicePoints.length
  96. let lastX = -Infinity
  97. props.points.forEach((point, pointIndex) => {
  98. if (isSiteRow) {
  99. if (point.siteValues?.[rowName] == null) return
  100. } else if (!point.files[rowName as DevicePoint]) {
  101. return
  102. }
  103. const x = xAt(pointIndex)
  104. if (x - lastX < 6 && pointIndex !== props.startIndex && pointIndex !== endIndex.value - 1) return
  105. lastX = x
  106. let siteRunning = true
  107. if (isSiteRow) {
  108. siteRunning = point.machineRunning !== undefined
  109. ? point.machineRunning
  110. : props.devicePoints.some((devicePoint) => (point.files[devicePoint]?.rpm ?? 0) > 0)
  111. }
  112. context.fillStyle = isSiteRow
  113. ? (siteRunning ? SITE_POINT_COLORS[siteIndex % SITE_POINT_COLORS.length] : STOPPED_COLOR)
  114. : pointColor(rowName as DevicePoint, point.files[rowName as DevicePoint]!)
  115. context.beginPath()
  116. context.arc(x, y, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3.5 : 2.5, 0, Math.PI * 2)
  117. context.fill()
  118. })
  119. })
  120. context.strokeStyle = '#d7e0e4'
  121. context.setLineDash([2, 5])
  122. context.beginPath()
  123. context.moveTo(left, annotationY)
  124. context.lineTo(width - right, annotationY)
  125. context.stroke()
  126. context.setLineDash([])
  127. context.fillStyle = '#52616b'
  128. context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
  129. context.fillText('标注', 10, annotationY + 4)
  130. const annotatedSet = new Set(props.annotatedFileIds ?? [])
  131. const isAnnotated = (point: TimePoint) => props.devicePoints.some((devicePoint) => {
  132. const id = point.files[devicePoint]?.id
  133. return id != null && annotatedSet.has(id)
  134. })
  135. let lastGrayX = -Infinity
  136. props.points.forEach((point, pointIndex) => {
  137. if (isAnnotated(point)) return
  138. const hasFile = props.devicePoints.some((devicePoint) => point.files[devicePoint])
  139. if (!hasFile) return
  140. const x = xAt(pointIndex)
  141. if (x - lastGrayX < 6 && pointIndex !== props.startIndex && pointIndex !== endIndex.value - 1) return
  142. lastGrayX = x
  143. context.fillStyle = '#c0c4cc'
  144. context.beginPath()
  145. context.arc(x, annotationY, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3 : 2.5, 0, Math.PI * 2)
  146. context.fill()
  147. })
  148. props.points.forEach((point, pointIndex) => {
  149. if (!isAnnotated(point)) return
  150. const x = xAt(pointIndex)
  151. context.fillStyle = '#e05252'
  152. context.beginPath()
  153. context.arc(x, annotationY, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3.5 : 2.5, 0, Math.PI * 2)
  154. context.fill()
  155. })
  156. // 故障竖线:按 fault_date 在时间轴上的位置显示,黄色,标签为 fault_category。
  157. if (props.faults?.length && props.points.length) {
  158. const faultColor = '#f5c400'
  159. const pointTimes = props.points.map((item) => new Date(item.sampleTime.replace(' ', 'T')).getTime())
  160. const firstTime = pointTimes[0]
  161. const lastTime = pointTimes[pointTimes.length - 1]
  162. const faultX = (iso: string): number | null => {
  163. const target = new Date(`${iso}T00:00:00`).getTime()
  164. if (!Number.isFinite(target)) return null
  165. if (target <= firstTime) return xAt(0)
  166. if (target >= lastTime) return xAt(props.points.length - 1)
  167. let lo = 0
  168. let hi = pointTimes.length - 1
  169. while (lo < hi) {
  170. const mid = (lo + hi) >> 1
  171. if (pointTimes[mid] < target) lo = mid + 1
  172. else hi = mid
  173. }
  174. const t0 = pointTimes[lo - 1]
  175. const t1 = pointTimes[lo]
  176. const ratio = t1 === t0 ? 0 : (target - t0) / (t1 - t0)
  177. return xAt(lo - 1) + ratio * (xAt(lo) - xAt(lo - 1))
  178. }
  179. context.save()
  180. context.strokeStyle = faultColor
  181. context.fillStyle = faultColor
  182. context.lineWidth = 1.5
  183. context.setLineDash([])
  184. for (const fault of props.faults) {
  185. const x = faultX(fault.faultDate)
  186. if (x == null) continue
  187. context.beginPath()
  188. context.moveTo(x, 14)
  189. context.lineTo(x, tickY)
  190. context.stroke()
  191. }
  192. context.textAlign = 'center'
  193. context.textBaseline = 'top'
  194. context.font = '600 11px -apple-system, BlinkMacSystemFont, sans-serif'
  195. for (const fault of props.faults) {
  196. const x = faultX(fault.faultDate)
  197. if (x == null) continue
  198. context.fillText(fault.faultCategory || '故障', x, 3)
  199. }
  200. context.restore()
  201. }
  202. context.fillStyle = '#788892'
  203. context.font = '12px -apple-system, BlinkMacSystemFont, sans-serif'
  204. const tickCount = Math.min(6, Math.max(2, Math.floor(width / 180)))
  205. for (let tick = 0; tick <= tickCount; tick += 1) {
  206. const index = Math.min(props.points.length - 1, Math.round((props.points.length - 1) * tick / tickCount))
  207. const x = xAt(index)
  208. context.strokeStyle = '#d6dfe3'
  209. context.beginPath()
  210. context.moveTo(x, tickY)
  211. context.lineTo(x, tickY + 6)
  212. context.stroke()
  213. const label = displayDate(props.points[index]?.sampleTime)
  214. context.textAlign = tick === 0 ? 'left' : tick === tickCount ? 'right' : 'center'
  215. context.fillText(label, x, tickY + 22)
  216. }
  217. context.textAlign = 'left'
  218. if (!props.points.length) {
  219. context.fillStyle = '#8a989f'
  220. context.fillText('暂无时间点', left, 70)
  221. }
  222. }
  223. function selectFromPointer(event: PointerEvent) {
  224. if (!canvas.value || !props.points.length) return
  225. const rect = canvas.value.getBoundingClientRect()
  226. const left = drawLeft
  227. const right = 20
  228. const usable = Math.max(rect.width - left - right, 1)
  229. const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left - left) / usable))
  230. const pointIndex = Math.round(ratio * (props.points.length - 1))
  231. const value = Math.max(0, Math.min(maxStart.value, pointIndex - Math.floor(props.windowSize / 2)))
  232. emit('update:startIndex', value)
  233. }
  234. function updateSlider(value: number | number[]) {
  235. emit('update:startIndex', Number(Array.isArray(value) ? value[0] : value))
  236. }
  237. watch(
  238. () => [props.points, props.devicePoints, props.startIndex, props.windowSize, props.annotatedFileIds, props.faults, props.ruler, props.sitePoints],
  239. () => nextTick(draw),
  240. { deep: true },
  241. )
  242. onMounted(() => {
  243. resizeObserver = new ResizeObserver(draw)
  244. if (canvasHost.value) resizeObserver.observe(canvasHost.value)
  245. draw()
  246. })
  247. onBeforeUnmount(() => resizeObserver?.disconnect())
  248. </script>
  249. <template>
  250. <section class="time-strip" aria-label="时间点选择区">
  251. <div class="time-strip-head">
  252. <div>
  253. <h2>时间点选择</h2>
  254. </div>
  255. <div class="time-summary">
  256. <span class="time-legend"><i class="legend-running"></i>运转</span>
  257. <span class="time-legend"><i class="legend-stopped"></i>停机</span>
  258. <span class="time-legend"><i class="legend-fault"></i>故障</span>
  259. <span class="summary-divider">/</span>
  260. <span class="mono">{{ points.length.toLocaleString() }}</span> 个时间点
  261. <span class="summary-divider">/</span>
  262. 当前 {{ points.length ? `${startIndex + 1} — ${endIndex}` : '—' }}
  263. </div>
  264. </div>
  265. <div ref="canvasHost" class="timeline-canvas-host" :class="{ 'is-loading': loading }">
  266. <canvas ref="canvas" @pointerdown="selectFromPointer" />
  267. <div v-if="loading" class="canvas-loading">正在读取时间点…</div>
  268. </div>
  269. <div class="time-strip-foot">
  270. <div class="time-foot-item">
  271. <span class="foot-label">起点</span>
  272. <strong>{{ displayDate(minTime) }}</strong>
  273. </div>
  274. <div class="window-track">
  275. <el-slider
  276. :model-value="startIndex"
  277. :min="0"
  278. :max="maxStart"
  279. :disabled="!points.length || maxStart === 0"
  280. :show-tooltip="false"
  281. aria-label="时间窗口位置"
  282. @update:model-value="updateSlider"
  283. />
  284. </div>
  285. <div class="time-foot-item align-right">
  286. <span class="foot-label">终点</span>
  287. <strong>{{ displayDate(maxTime) }}</strong>
  288. </div>
  289. </div>
  290. </section>
  291. </template>