TimePointStrip.vue 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. <script setup lang="ts">
  2. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  3. import type { DevicePoint, MeasurementType, TimePoint } from '../types'
  4. import { RUNNING_COLOR, STOPPED_COLOR, statusGradientColor } from '../statusColor'
  5. const props = defineProps<{
  6. points: TimePoint[]
  7. devicePoints: DevicePoint[]
  8. pointToType: Record<DevicePoint, MeasurementType>
  9. startIndex: number
  10. windowSize: number
  11. loading?: boolean
  12. minTime?: string
  13. maxTime?: string
  14. annotatedFileIds?: number[]
  15. ruler?: { min: number; max: number } | null
  16. }>()
  17. const emit = defineEmits<{
  18. 'update:startIndex': [value: number]
  19. }>()
  20. const canvas = ref<HTMLCanvasElement | null>(null)
  21. const canvasHost = ref<HTMLElement | null>(null)
  22. let resizeObserver: ResizeObserver | undefined
  23. const maxStart = computed(() => Math.max(0, props.points.length - props.windowSize))
  24. const endIndex = computed(() => Math.min(props.points.length, props.startIndex + props.windowSize))
  25. function displayDate(value: string | undefined) {
  26. if (!value) return '--'
  27. return value.replace('T', ' ').slice(0, 10)
  28. }
  29. function pointColor(devicePoint: DevicePoint, fileInfo: { rpm: number; status?: number }): string {
  30. const running = fileInfo.rpm > 0
  31. if (!running) return STOPPED_COLOR
  32. if (props.pointToType[devicePoint] === '压力') return statusGradientColor(fileInfo.status, props.ruler ?? null)
  33. return RUNNING_COLOR
  34. }
  35. function draw() {
  36. const element = canvas.value
  37. const host = canvasHost.value
  38. if (!element || !host) return
  39. const width = Math.max(host.clientWidth, 320)
  40. const height = 210
  41. const ratio = window.devicePixelRatio || 1
  42. element.width = width * ratio
  43. element.height = height * ratio
  44. element.style.width = `${width}px`
  45. element.style.height = `${height}px`
  46. const context = element.getContext('2d')
  47. if (!context) return
  48. context.setTransform(ratio, 0, 0, ratio, 0, 0)
  49. context.clearRect(0, 0, width, height)
  50. const left = 70
  51. const right = 20
  52. const available = Math.max(width - left - right, 1)
  53. const rowGap = 39
  54. const rows = props.devicePoints
  55. const rowY = (index: number) => 32 + index * rowGap
  56. const annotationY = 32 + rows.length * rowGap
  57. const xAt = (index: number) => left + (props.points.length <= 1 ? 0 : index / (props.points.length - 1)) * available
  58. const selectedLeft = xAt(props.startIndex)
  59. const selectedRight = xAt(Math.min(props.points.length - 1, Math.max(props.startIndex, endIndex.value - 1)))
  60. const selectedWidth = Math.max(selectedRight - selectedLeft, 8)
  61. context.fillStyle = '#f4f6f7'
  62. context.fillRect(selectedLeft, 10, selectedWidth, 172)
  63. context.strokeStyle = '#dbe2e6'
  64. context.lineWidth = 1
  65. context.strokeRect(selectedLeft + 0.5, 10.5, selectedWidth - 1, 171)
  66. rows.forEach((devicePoint, rowIndex) => {
  67. const y = rowY(rowIndex)
  68. context.strokeStyle = '#d7e0e4'
  69. context.setLineDash([2, 5])
  70. context.beginPath()
  71. context.moveTo(left, y)
  72. context.lineTo(width - right, y)
  73. context.stroke()
  74. context.setLineDash([])
  75. context.fillStyle = '#52616b'
  76. context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
  77. context.fillText(devicePoint, 10, y + 4)
  78. let lastX = -Infinity
  79. props.points.forEach((point, pointIndex) => {
  80. const fileInfo = point.files[devicePoint]
  81. if (!fileInfo) return
  82. const x = xAt(pointIndex)
  83. if (x - lastX < 6 && pointIndex !== props.startIndex && pointIndex !== endIndex.value - 1) return
  84. lastX = x
  85. context.fillStyle = pointColor(devicePoint, fileInfo)
  86. context.beginPath()
  87. context.arc(x, y, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3.5 : 2.5, 0, Math.PI * 2)
  88. context.fill()
  89. })
  90. })
  91. context.strokeStyle = '#d7e0e4'
  92. context.setLineDash([2, 5])
  93. context.beginPath()
  94. context.moveTo(left, annotationY)
  95. context.lineTo(width - right, annotationY)
  96. context.stroke()
  97. context.setLineDash([])
  98. context.fillStyle = '#52616b'
  99. context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
  100. context.fillText('标注', 10, annotationY + 4)
  101. const annotatedSet = new Set(props.annotatedFileIds ?? [])
  102. const isAnnotated = (point: TimePoint) => props.devicePoints.some((devicePoint) => {
  103. const id = point.files[devicePoint]?.id
  104. return id != null && annotatedSet.has(id)
  105. })
  106. let lastGrayX = -Infinity
  107. props.points.forEach((point, pointIndex) => {
  108. if (isAnnotated(point)) return
  109. const hasFile = props.devicePoints.some((devicePoint) => point.files[devicePoint])
  110. if (!hasFile) return
  111. const x = xAt(pointIndex)
  112. if (x - lastGrayX < 6 && pointIndex !== props.startIndex && pointIndex !== endIndex.value - 1) return
  113. lastGrayX = x
  114. context.fillStyle = '#c0c4cc'
  115. context.beginPath()
  116. context.arc(x, annotationY, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3 : 2.5, 0, Math.PI * 2)
  117. context.fill()
  118. })
  119. props.points.forEach((point, pointIndex) => {
  120. if (!isAnnotated(point)) return
  121. const x = xAt(pointIndex)
  122. context.fillStyle = '#e05252'
  123. context.beginPath()
  124. context.arc(x, annotationY, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3.5 : 2.5, 0, Math.PI * 2)
  125. context.fill()
  126. })
  127. context.fillStyle = '#788892'
  128. context.font = '12px -apple-system, BlinkMacSystemFont, sans-serif'
  129. const tickCount = Math.min(6, Math.max(2, Math.floor(width / 180)))
  130. for (let tick = 0; tick <= tickCount; tick += 1) {
  131. const index = Math.min(props.points.length - 1, Math.round((props.points.length - 1) * tick / tickCount))
  132. const x = xAt(index)
  133. context.strokeStyle = '#d6dfe3'
  134. context.beginPath()
  135. context.moveTo(x, 180)
  136. context.lineTo(x, 186)
  137. context.stroke()
  138. const label = displayDate(props.points[index]?.sampleTime)
  139. context.textAlign = tick === 0 ? 'left' : tick === tickCount ? 'right' : 'center'
  140. context.fillText(label, x, 202)
  141. }
  142. context.textAlign = 'left'
  143. if (!props.points.length) {
  144. context.fillStyle = '#8a989f'
  145. context.fillText('暂无时间点', left, 70)
  146. }
  147. }
  148. function selectFromPointer(event: PointerEvent) {
  149. if (!canvas.value || !props.points.length) return
  150. const rect = canvas.value.getBoundingClientRect()
  151. const left = 70
  152. const right = 20
  153. const usable = Math.max(rect.width - left - right, 1)
  154. const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left - left) / usable))
  155. const pointIndex = Math.round(ratio * (props.points.length - 1))
  156. const value = Math.max(0, Math.min(maxStart.value, pointIndex - Math.floor(props.windowSize / 2)))
  157. emit('update:startIndex', value)
  158. }
  159. function updateSlider(value: number | number[]) {
  160. emit('update:startIndex', Number(Array.isArray(value) ? value[0] : value))
  161. }
  162. watch(
  163. () => [props.points, props.devicePoints, props.startIndex, props.windowSize, props.annotatedFileIds, props.ruler],
  164. () => nextTick(draw),
  165. { deep: true },
  166. )
  167. onMounted(() => {
  168. resizeObserver = new ResizeObserver(draw)
  169. if (canvasHost.value) resizeObserver.observe(canvasHost.value)
  170. draw()
  171. })
  172. onBeforeUnmount(() => resizeObserver?.disconnect())
  173. </script>
  174. <template>
  175. <section class="time-strip" aria-label="时间点选择区">
  176. <div class="time-strip-head">
  177. <div>
  178. <h2>时间点选择</h2>
  179. </div>
  180. <div class="time-summary">
  181. <span class="time-legend"><i class="legend-running"></i>运转</span>
  182. <span class="time-legend"><i class="legend-stopped"></i>停机</span>
  183. <span class="summary-divider">/</span>
  184. <span class="mono">{{ points.length.toLocaleString() }}</span> 个时间点
  185. <span class="summary-divider">/</span>
  186. 当前 {{ points.length ? `${startIndex + 1} — ${endIndex}` : '—' }}
  187. </div>
  188. </div>
  189. <div ref="canvasHost" class="timeline-canvas-host" :class="{ 'is-loading': loading }">
  190. <canvas ref="canvas" @pointerdown="selectFromPointer" />
  191. <div v-if="loading" class="canvas-loading">正在读取时间点…</div>
  192. </div>
  193. <div class="time-strip-foot">
  194. <div class="time-foot-item">
  195. <span class="foot-label">起点</span>
  196. <strong>{{ displayDate(minTime) }}</strong>
  197. </div>
  198. <div class="window-track">
  199. <el-slider
  200. :model-value="startIndex"
  201. :min="0"
  202. :max="maxStart"
  203. :disabled="!points.length || maxStart === 0"
  204. :show-tooltip="false"
  205. aria-label="时间窗口位置"
  206. @update:model-value="updateSlider"
  207. />
  208. </div>
  209. <div class="time-foot-item align-right">
  210. <span class="foot-label">终点</span>
  211. <strong>{{ displayDate(maxTime) }}</strong>
  212. </div>
  213. </div>
  214. </section>
  215. </template>