TimePointStrip.vue 8.0 KB

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