| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- <script setup lang="ts">
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
- import type { DevicePoint, MeasurementType, TimePoint } from '../types'
- import { RUNNING_COLOR, STOPPED_COLOR, statusGradientColor } from '../statusColor'
- const props = defineProps<{
- points: TimePoint[]
- devicePoints: DevicePoint[]
- pointToType: Record<DevicePoint, MeasurementType>
- startIndex: number
- windowSize: number
- loading?: boolean
- minTime?: string
- maxTime?: string
- annotatedFileIds?: number[]
- ruler?: { min: number; max: number } | null
- }>()
- const emit = defineEmits<{
- 'update:startIndex': [value: number]
- }>()
- const canvas = ref<HTMLCanvasElement | null>(null)
- const canvasHost = ref<HTMLElement | null>(null)
- let resizeObserver: ResizeObserver | undefined
- const maxStart = computed(() => Math.max(0, props.points.length - props.windowSize))
- const endIndex = computed(() => Math.min(props.points.length, props.startIndex + props.windowSize))
- function displayDate(value: string | undefined) {
- if (!value) return '--'
- return value.replace('T', ' ').slice(0, 10)
- }
- function pointColor(devicePoint: DevicePoint, fileInfo: { rpm: number; status?: number }): string {
- const running = fileInfo.rpm > 0
- if (!running) return STOPPED_COLOR
- if (props.pointToType[devicePoint] === '压力') return statusGradientColor(fileInfo.status, props.ruler ?? null)
- return RUNNING_COLOR
- }
- function draw() {
- const element = canvas.value
- const host = canvasHost.value
- if (!element || !host) return
- const width = Math.max(host.clientWidth, 320)
- const height = 210
- const ratio = window.devicePixelRatio || 1
- element.width = width * ratio
- element.height = height * ratio
- element.style.width = `${width}px`
- element.style.height = `${height}px`
- const context = element.getContext('2d')
- if (!context) return
- context.setTransform(ratio, 0, 0, ratio, 0, 0)
- context.clearRect(0, 0, width, height)
- const left = 70
- const right = 20
- const available = Math.max(width - left - right, 1)
- const rowGap = 39
- const rows = props.devicePoints
- const rowY = (index: number) => 32 + index * rowGap
- const annotationY = 32 + rows.length * rowGap
- const xAt = (index: number) => left + (props.points.length <= 1 ? 0 : index / (props.points.length - 1)) * available
- const selectedLeft = xAt(props.startIndex)
- const selectedRight = xAt(Math.min(props.points.length - 1, Math.max(props.startIndex, endIndex.value - 1)))
- const selectedWidth = Math.max(selectedRight - selectedLeft, 8)
- context.fillStyle = '#f4f6f7'
- context.fillRect(selectedLeft, 10, selectedWidth, 172)
- context.strokeStyle = '#dbe2e6'
- context.lineWidth = 1
- context.strokeRect(selectedLeft + 0.5, 10.5, selectedWidth - 1, 171)
- rows.forEach((devicePoint, rowIndex) => {
- const y = rowY(rowIndex)
- context.strokeStyle = '#d7e0e4'
- context.setLineDash([2, 5])
- context.beginPath()
- context.moveTo(left, y)
- context.lineTo(width - right, y)
- context.stroke()
- context.setLineDash([])
- context.fillStyle = '#52616b'
- context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
- context.fillText(devicePoint, 10, y + 4)
- let lastX = -Infinity
- props.points.forEach((point, pointIndex) => {
- const fileInfo = point.files[devicePoint]
- if (!fileInfo) return
- const x = xAt(pointIndex)
- if (x - lastX < 6 && pointIndex !== props.startIndex && pointIndex !== endIndex.value - 1) return
- lastX = x
- context.fillStyle = pointColor(devicePoint, fileInfo)
- context.beginPath()
- context.arc(x, y, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3.5 : 2.5, 0, Math.PI * 2)
- context.fill()
- })
- })
- context.strokeStyle = '#d7e0e4'
- context.setLineDash([2, 5])
- context.beginPath()
- context.moveTo(left, annotationY)
- context.lineTo(width - right, annotationY)
- context.stroke()
- context.setLineDash([])
- context.fillStyle = '#52616b'
- context.font = '600 13px -apple-system, BlinkMacSystemFont, sans-serif'
- context.fillText('标注', 10, annotationY + 4)
- const annotatedSet = new Set(props.annotatedFileIds ?? [])
- const isAnnotated = (point: TimePoint) => props.devicePoints.some((devicePoint) => {
- const id = point.files[devicePoint]?.id
- return id != null && annotatedSet.has(id)
- })
- let lastGrayX = -Infinity
- props.points.forEach((point, pointIndex) => {
- if (isAnnotated(point)) return
- const hasFile = props.devicePoints.some((devicePoint) => point.files[devicePoint])
- if (!hasFile) return
- const x = xAt(pointIndex)
- if (x - lastGrayX < 6 && pointIndex !== props.startIndex && pointIndex !== endIndex.value - 1) return
- lastGrayX = x
- context.fillStyle = '#c0c4cc'
- context.beginPath()
- context.arc(x, annotationY, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3 : 2.5, 0, Math.PI * 2)
- context.fill()
- })
- props.points.forEach((point, pointIndex) => {
- if (!isAnnotated(point)) return
- const x = xAt(pointIndex)
- context.fillStyle = '#e05252'
- context.beginPath()
- context.arc(x, annotationY, pointIndex >= props.startIndex && pointIndex < endIndex.value ? 3.5 : 2.5, 0, Math.PI * 2)
- context.fill()
- })
- context.fillStyle = '#788892'
- context.font = '12px -apple-system, BlinkMacSystemFont, sans-serif'
- const tickCount = Math.min(6, Math.max(2, Math.floor(width / 180)))
- for (let tick = 0; tick <= tickCount; tick += 1) {
- const index = Math.min(props.points.length - 1, Math.round((props.points.length - 1) * tick / tickCount))
- const x = xAt(index)
- context.strokeStyle = '#d6dfe3'
- context.beginPath()
- context.moveTo(x, 180)
- context.lineTo(x, 186)
- context.stroke()
- const label = displayDate(props.points[index]?.sampleTime)
- context.textAlign = tick === 0 ? 'left' : tick === tickCount ? 'right' : 'center'
- context.fillText(label, x, 202)
- }
- context.textAlign = 'left'
- if (!props.points.length) {
- context.fillStyle = '#8a989f'
- context.fillText('暂无时间点', left, 70)
- }
- }
- function selectFromPointer(event: PointerEvent) {
- if (!canvas.value || !props.points.length) return
- const rect = canvas.value.getBoundingClientRect()
- const left = 70
- const right = 20
- const usable = Math.max(rect.width - left - right, 1)
- const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left - left) / usable))
- const pointIndex = Math.round(ratio * (props.points.length - 1))
- const value = Math.max(0, Math.min(maxStart.value, pointIndex - Math.floor(props.windowSize / 2)))
- emit('update:startIndex', value)
- }
- function updateSlider(value: number | number[]) {
- emit('update:startIndex', Number(Array.isArray(value) ? value[0] : value))
- }
- watch(
- () => [props.points, props.devicePoints, props.startIndex, props.windowSize, props.annotatedFileIds, props.ruler],
- () => nextTick(draw),
- { deep: true },
- )
- onMounted(() => {
- resizeObserver = new ResizeObserver(draw)
- if (canvasHost.value) resizeObserver.observe(canvasHost.value)
- draw()
- })
- onBeforeUnmount(() => resizeObserver?.disconnect())
- </script>
- <template>
- <section class="time-strip" aria-label="时间点选择区">
- <div class="time-strip-head">
- <div>
- <h2>时间点选择</h2>
- </div>
- <div class="time-summary">
- <span class="time-legend"><i class="legend-running"></i>运转</span>
- <span class="time-legend"><i class="legend-stopped"></i>停机</span>
- <span class="summary-divider">/</span>
- <span class="mono">{{ points.length.toLocaleString() }}</span> 个时间点
- <span class="summary-divider">/</span>
- 当前 {{ points.length ? `${startIndex + 1} — ${endIndex}` : '—' }}
- </div>
- </div>
- <div ref="canvasHost" class="timeline-canvas-host" :class="{ 'is-loading': loading }">
- <canvas ref="canvas" @pointerdown="selectFromPointer" />
- <div v-if="loading" class="canvas-loading">正在读取时间点…</div>
- </div>
- <div class="time-strip-foot">
- <div class="time-foot-item">
- <span class="foot-label">起点</span>
- <strong>{{ displayDate(minTime) }}</strong>
- </div>
- <div class="window-track">
- <el-slider
- :model-value="startIndex"
- :min="0"
- :max="maxStart"
- :disabled="!points.length || maxStart === 0"
- :show-tooltip="false"
- aria-label="时间窗口位置"
- @update:model-value="updateSlider"
- />
- </div>
- <div class="time-foot-item align-right">
- <span class="foot-label">终点</span>
- <strong>{{ displayDate(maxTime) }}</strong>
- </div>
- </div>
- </section>
- </template>
|