完善时间线标尺、缩放与播放头
This commit is contained in:
parent
ce29a7c38d
commit
cdff0950d9
|
|
@ -55,6 +55,6 @@ declare module 'vue' {
|
||||||
MessageBoxDemo: typeof import('./components/MessageBoxDemo.vue')['default']
|
MessageBoxDemo: typeof import('./components/MessageBoxDemo.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
Timeline: typeof import('./components/hazard_inspect/timeline.vue')['default']
|
Timeline: typeof import('./components/hazard_inspect/timeline/timeline.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,535 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { ElScrollbar } from 'element-plus'
|
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
currentFrame: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
totalFrames: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
hazardRanges: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['hazardClick', 'frameChange'])
|
|
||||||
|
|
||||||
const FPS = 30
|
|
||||||
const pxPerFrame = ref(2)
|
|
||||||
const minPxPerFrame = 0.5
|
|
||||||
const maxPxPerFrame = 10
|
|
||||||
const timelineContainer = ref(null)
|
|
||||||
const isDragging = ref(false)
|
|
||||||
const isPlayheadDragging = ref(false)
|
|
||||||
const isRulerDragging = ref(false)
|
|
||||||
const dragStartX = ref(0)
|
|
||||||
const scrollStartLeft = ref(0)
|
|
||||||
const localCurrentFrame = ref(0)
|
|
||||||
|
|
||||||
const trackWidth = computed(() => {
|
|
||||||
return `${props.totalFrames * pxPerFrame.value + 50}px`
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalSeconds = computed(() => {
|
|
||||||
return Math.ceil(props.totalFrames / FPS)
|
|
||||||
})
|
|
||||||
|
|
||||||
function updatePxPerFrame() {
|
|
||||||
if (!timelineContainer.value)
|
|
||||||
return
|
|
||||||
const containerWidth = timelineContainer.value.clientWidth - 20
|
|
||||||
const calculatedPxPerFrame = containerWidth / props.totalFrames
|
|
||||||
pxPerFrame.value = Math.max(minPxPerFrame, Math.min(calculatedPxPerFrame, maxPxPerFrame))
|
|
||||||
}
|
|
||||||
|
|
||||||
function zoomIn() {
|
|
||||||
pxPerFrame.value = Math.min(pxPerFrame.value * 1.5, maxPxPerFrame)
|
|
||||||
}
|
|
||||||
|
|
||||||
function zoomOut() {
|
|
||||||
pxPerFrame.value = Math.max(pxPerFrame.value / 1.5, minPxPerFrame)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleZoomIn() {
|
|
||||||
zoomIn()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleZoomOut() {
|
|
||||||
zoomOut()
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeTicks = computed(() => {
|
|
||||||
const ticks = []
|
|
||||||
for (let s = 0; s <= totalSeconds.value; s += 10) {
|
|
||||||
ticks.push({
|
|
||||||
second: s,
|
|
||||||
position: s * FPS * pxPerFrame.value,
|
|
||||||
label: formatTime(s),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return ticks
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatTime(seconds) {
|
|
||||||
const mins = Math.floor(seconds / 60)
|
|
||||||
const secs = seconds % 60
|
|
||||||
if (mins > 0) {
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
return `${secs}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
const playheadPos = computed(() => {
|
|
||||||
return props.currentFrame * pxPerFrame.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const trackRows = computed(() => {
|
|
||||||
const ranges = props.hazardRanges
|
|
||||||
const sortedHazards = Object.entries(ranges)
|
|
||||||
.map(([id, frames]) => ({
|
|
||||||
id,
|
|
||||||
ranges: frames.reduce((acc, _, i) => {
|
|
||||||
if (i % 2 === 0) {
|
|
||||||
acc.push([frames[i], frames[i + 1]])
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, []),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const aStart = a.ranges[0]?.[0] || 0
|
|
||||||
const bStart = b.ranges[0]?.[0] || 0
|
|
||||||
return aStart - bStart
|
|
||||||
})
|
|
||||||
|
|
||||||
const rows = []
|
|
||||||
sortedHazards.forEach((hazard) => {
|
|
||||||
let assigned = false
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
|
||||||
const canPlace = hazard.ranges.every(([start, end]) => {
|
|
||||||
return rows[i].every((existing) => {
|
|
||||||
return end <= existing.start || start >= existing.end
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if (canPlace) {
|
|
||||||
rows[i].push({
|
|
||||||
id: hazard.id,
|
|
||||||
start: hazard.ranges[0][0],
|
|
||||||
end: hazard.ranges[0][1],
|
|
||||||
ranges: hazard.ranges,
|
|
||||||
})
|
|
||||||
assigned = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!assigned) {
|
|
||||||
rows.push([{
|
|
||||||
id: hazard.id,
|
|
||||||
start: hazard.ranges[0][0],
|
|
||||||
end: hazard.ranges[0][1],
|
|
||||||
ranges: hazard.ranges,
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return rows
|
|
||||||
})
|
|
||||||
|
|
||||||
function getHazardStyle(start, end) {
|
|
||||||
const left = start * pxPerFrame.value
|
|
||||||
const width = (end - start) * pxPerFrame.value
|
|
||||||
return {
|
|
||||||
left: `${left}px`,
|
|
||||||
width: `${width}px`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleHazardClick(hazardId) {
|
|
||||||
emit('hazardClick', Number(hazardId))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWheel(e) {
|
|
||||||
if (!timelineContainer.value)
|
|
||||||
return
|
|
||||||
e.preventDefault()
|
|
||||||
const container = timelineContainer.value
|
|
||||||
const delta = e.deltaY || e.deltaX
|
|
||||||
container.scrollLeft += delta
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseDown(e) {
|
|
||||||
if (e.button !== 0)
|
|
||||||
return
|
|
||||||
const target = e.target
|
|
||||||
const playhead = document.getElementById('playhead')
|
|
||||||
if (playhead && (playhead.contains(target) || target === playhead))
|
|
||||||
return
|
|
||||||
const ruler = timelineContainer.value?.querySelector('.timeline-ruler')
|
|
||||||
if (ruler && (ruler.contains(target) || target === ruler)) {
|
|
||||||
isRulerDragging.value = true
|
|
||||||
}
|
|
||||||
isDragging.value = true
|
|
||||||
dragStartX.value = e.clientX
|
|
||||||
if (timelineContainer.value)
|
|
||||||
scrollStartLeft.value = timelineContainer.value.scrollLeft
|
|
||||||
document.body.style.cursor = 'grabbing'
|
|
||||||
document.body.style.userSelect = 'none'
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseMove(e) {
|
|
||||||
if (!isDragging.value || !timelineContainer.value)
|
|
||||||
return
|
|
||||||
const delta = dragStartX.value - e.clientX
|
|
||||||
timelineContainer.value.scrollLeft = scrollStartLeft.value + delta
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseUp() {
|
|
||||||
isDragging.value = false
|
|
||||||
isPlayheadDragging.value = false
|
|
||||||
isRulerDragging.value = false
|
|
||||||
document.body.style.cursor = ''
|
|
||||||
document.body.style.userSelect = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePlayheadMouseDown(e) {
|
|
||||||
e.stopPropagation()
|
|
||||||
isPlayheadDragging.value = true
|
|
||||||
dragStartX.value = e.clientX
|
|
||||||
document.body.style.cursor = 'ew-resize'
|
|
||||||
document.body.style.userSelect = 'none'
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRulerClick(e) {
|
|
||||||
if (isRulerDragging.value)
|
|
||||||
return
|
|
||||||
if (!timelineContainer.value)
|
|
||||||
return
|
|
||||||
const rect = timelineContainer.value.getBoundingClientRect()
|
|
||||||
const scrollLeft = timelineContainer.value.scrollLeft
|
|
||||||
const x = e.clientX - rect.left + scrollLeft
|
|
||||||
const frame = Math.floor(x / pxPerFrame.value)
|
|
||||||
const clampedFrame = Math.max(0, Math.min(frame, props.totalFrames - 1))
|
|
||||||
localCurrentFrame.value = clampedFrame
|
|
||||||
emit('frameChange', clampedFrame)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePlayheadDrag(e) {
|
|
||||||
if (!isPlayheadDragging.value || !timelineContainer.value)
|
|
||||||
return
|
|
||||||
const rect = timelineContainer.value.getBoundingClientRect()
|
|
||||||
const scrollLeft = timelineContainer.value.scrollLeft
|
|
||||||
const x = e.clientX - rect.left + scrollLeft
|
|
||||||
const frame = Math.floor(x / pxPerFrame.value)
|
|
||||||
const clampedFrame = Math.max(0, Math.min(frame, props.totalFrames - 1))
|
|
||||||
localCurrentFrame.value = clampedFrame
|
|
||||||
emit('frameChange', clampedFrame)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.currentFrame, () => {
|
|
||||||
if (!isPlayheadDragging.value) {
|
|
||||||
localCurrentFrame.value = props.currentFrame
|
|
||||||
}
|
|
||||||
nextTick(() => {
|
|
||||||
if (!timelineContainer.value)
|
|
||||||
return
|
|
||||||
const container = timelineContainer.value
|
|
||||||
const playhead = document.getElementById('playhead')
|
|
||||||
if (!playhead)
|
|
||||||
return
|
|
||||||
|
|
||||||
const playheadLeft = playhead.offsetLeft
|
|
||||||
const containerWidth = container.clientWidth
|
|
||||||
const containerScrollLeft = container.scrollLeft
|
|
||||||
|
|
||||||
const playheadVisible = playheadLeft >= containerScrollLeft && playheadLeft <= containerScrollLeft + containerWidth - 50
|
|
||||||
|
|
||||||
if (!playheadVisible) {
|
|
||||||
container.scrollLeft = playheadLeft - containerWidth / 2
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const progressContainer = timelineContainer.value
|
|
||||||
if (!progressContainer)
|
|
||||||
return
|
|
||||||
|
|
||||||
updatePxPerFrame()
|
|
||||||
|
|
||||||
progressContainer.addEventListener('wheel', handleWheel, { passive: false })
|
|
||||||
progressContainer.addEventListener('mousedown', handleMouseDown)
|
|
||||||
window.addEventListener('mousemove', handlePlayheadDrag)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.totalFrames, () => {
|
|
||||||
nextTick(() => {
|
|
||||||
updatePxPerFrame()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('mousemove', handleMouseMove)
|
|
||||||
window.removeEventListener('mousemove', handlePlayheadDrag)
|
|
||||||
window.removeEventListener('mouseup', handleMouseUp)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="timeline-wrapper">
|
|
||||||
<div class="timeline-toolbar">
|
|
||||||
<span class="timeline-title">隐患时间线 (共 {{ Object.keys(hazardRanges).length }} 个隐患)</span>
|
|
||||||
<div class="timeline-zoom-controls">
|
|
||||||
<button class="zoom-btn" title="缩小" @click="handleZoomOut">
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<button class="zoom-btn" title="放大" @click="handleZoomIn">
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref="timelineContainer"
|
|
||||||
class="timeline-container"
|
|
||||||
@mousemove="handleMouseMove"
|
|
||||||
@mouseup="handleMouseUp"
|
|
||||||
@mouseleave="handleMouseUp"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
id="playhead"
|
|
||||||
class="playhead"
|
|
||||||
:style="{ left: `${playheadPos}px` }"
|
|
||||||
@mousedown="handlePlayheadMouseDown"
|
|
||||||
>
|
|
||||||
<div class="playhead-indicator" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="timeline-content" :style="{ width: trackWidth }">
|
|
||||||
<div class="timeline-ruler" @click="handleRulerClick">
|
|
||||||
<div
|
|
||||||
v-for="tick in timeTicks"
|
|
||||||
:key="tick.second"
|
|
||||||
class="time-tick"
|
|
||||||
:style="{ left: `${tick.position}px` }"
|
|
||||||
>
|
|
||||||
<div class="tick-mark" />
|
|
||||||
<span class="tick-label">{{ tick.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ElScrollbar :style="{ height: '100%' }">
|
|
||||||
<div id="tracks-list" class="tracks-list">
|
|
||||||
<div
|
|
||||||
v-for="(row, rowIndex) in trackRows"
|
|
||||||
:key="rowIndex"
|
|
||||||
class="track-row"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="hazard in row"
|
|
||||||
:key="hazard.id"
|
|
||||||
class="hazard-block"
|
|
||||||
:style="getHazardStyle(hazard.start, hazard.end)"
|
|
||||||
@click="handleHazardClick(hazard.id)"
|
|
||||||
>
|
|
||||||
{{ parseInt(hazard.id) + 1 }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ElScrollbar>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.timeline-wrapper {
|
|
||||||
height: 33.333%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-top: 1px solid var(--ep-border-color);
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-toolbar {
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-bottom: 1px solid var(--ep-border-color-light);
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-title {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ep-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-zoom-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-btn {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px solid var(--ep-border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--ep-bg-color);
|
|
||||||
color: var(--ep-text-color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-btn:hover {
|
|
||||||
background-color: var(--ep-fill-color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
position: relative;
|
|
||||||
background-color: var(--ep-bg-color);
|
|
||||||
cursor: grab;
|
|
||||||
/* padding-top: 28px; */
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-container:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-tick {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tick-mark {
|
|
||||||
width: 1px;
|
|
||||||
height: 8px;
|
|
||||||
background-color: var(--ep-color-info);
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tick-label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--ep-text-color-secondary);
|
|
||||||
margin-left: 4px;
|
|
||||||
margin-top: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playhead {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 2px;
|
|
||||||
background-color: #ef4444;
|
|
||||||
z-index: 25;
|
|
||||||
cursor: ew-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playhead-indicator {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -4px;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
background-color: #ef4444;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-content {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-ruler {
|
|
||||||
background-color: var(--ep-fill-color-light);
|
|
||||||
border-bottom: 1px solid var(--ep-border-color);
|
|
||||||
height: 24px;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-tick-inner {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tick-line {
|
|
||||||
width: 1px;
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--ep-border-color-lighter);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-tick-inner.is-major .tick-line {
|
|
||||||
background-color: var(--ep-color-info);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-row {
|
|
||||||
position: relative;
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: 1px solid var(--ep-border-color-lighter);
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-row:hover {
|
|
||||||
background-color: var(--ep-fill-color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hazard-block {
|
|
||||||
position: absolute;
|
|
||||||
height: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--ep-color-primary);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hazard-block:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--ep-color-primary) 80%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-container::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-container::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--ep-color-info);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-container::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: color-mix(in srgb, var(--ep-color-info) 60%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-scrollbar) {
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-scrollbar__wrap) {
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
.timeline-wrapper {
|
||||||
|
height: 33.333%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid var(--ep-border-color);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-toolbar {
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--ep-border-color-light);
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ep-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ep-text-color-secondary);
|
||||||
|
margin-right: 8px;
|
||||||
|
width: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-slider {
|
||||||
|
flex: 1;
|
||||||
|
height: 24px;
|
||||||
|
width: 100px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--ep-bg-color);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-tick {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick-mark {
|
||||||
|
width: 1px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--ep-color-info);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick-mark.major {
|
||||||
|
height: 12px;
|
||||||
|
background-color: var(--ep-color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick-mark.minor {
|
||||||
|
height: 6px;
|
||||||
|
background-color: var(--ep-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--ep-text-color-secondary);
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-top: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playhead {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background-color: #ef4444;
|
||||||
|
z-index: 25;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playhead-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -4px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: #ef4444;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-ruler {
|
||||||
|
background-color: var(--ep-fill-color-light);
|
||||||
|
border-bottom: 1px solid var(--ep-border-color);
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 5;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-ruler-click-area {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-tick-inner {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick-line {
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--ep-border-color-lighter);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-tick-inner.is-major .tick-line {
|
||||||
|
background-color: var(--ep-color-info);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row {
|
||||||
|
position: relative;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--ep-border-color-lighter);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row:hover {
|
||||||
|
background-color: var(--ep-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hazard-block {
|
||||||
|
position: absolute;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--ep-color-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hazard-block:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--ep-color-primary) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--ep-color-info);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: color-mix(in srgb, var(--ep-color-info) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-scrollbar) {
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-scrollbar__wrap) {
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,330 @@
|
||||||
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export interface TimelineProps {
|
||||||
|
currentFrame: number
|
||||||
|
totalFrames: number
|
||||||
|
hazardRanges: Record<string, number[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineEmits {
|
||||||
|
(e: 'hazardClick', id: number): void
|
||||||
|
(e: 'frameChange', frame: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeTick {
|
||||||
|
second: number
|
||||||
|
position: number
|
||||||
|
label: string
|
||||||
|
type?: 'major' | 'minor'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HazardItem {
|
||||||
|
id: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
ranges: number[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HazardRow {
|
||||||
|
id: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
ranges: number[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTimeline(
|
||||||
|
props: TimelineProps,
|
||||||
|
emit: TimelineEmits,
|
||||||
|
) {
|
||||||
|
const FPS = 30
|
||||||
|
const pxPerFrame = ref(2)
|
||||||
|
const minPxPerFrame = 0.5
|
||||||
|
const maxPxPerFrame = 3
|
||||||
|
const timelineContainer: Ref<HTMLElement | null> = ref(null)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const isPlayheadDragging = ref(false)
|
||||||
|
const isRulerDragging = ref(false)
|
||||||
|
const dragStartX = ref(0)
|
||||||
|
const scrollStartLeft = ref(0)
|
||||||
|
const localCurrentFrame = ref(0)
|
||||||
|
|
||||||
|
const trackWidth: ComputedRef<string> = computed(() => {
|
||||||
|
return `${props.totalFrames * pxPerFrame.value + 50}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalSeconds: ComputedRef<number> = computed(() => {
|
||||||
|
return Math.ceil(props.totalFrames / FPS)
|
||||||
|
})
|
||||||
|
|
||||||
|
function updatePxPerFrame(): void {
|
||||||
|
if (!timelineContainer.value)
|
||||||
|
return
|
||||||
|
const containerWidth = timelineContainer.value.clientWidth - 20
|
||||||
|
const calculatedPxPerFrame = containerWidth / props.totalFrames
|
||||||
|
pxPerFrame.value = Math.max(minPxPerFrame, Math.min(calculatedPxPerFrame, maxPxPerFrame))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setZoom(value: number): void {
|
||||||
|
pxPerFrame.value = Math.max(minPxPerFrame, Math.min(value, maxPxPerFrame))
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeTicks: ComputedRef<TimeTick[]> = computed(() => {
|
||||||
|
const ticks: TimeTick[] = []
|
||||||
|
for (let s = 0; s <= totalSeconds.value; s += 10) {
|
||||||
|
ticks.push({
|
||||||
|
second: s,
|
||||||
|
position: s * FPS * pxPerFrame.value,
|
||||||
|
label: formatTime(s),
|
||||||
|
type: 'major',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
})
|
||||||
|
|
||||||
|
const secondTicks: ComputedRef<TimeTick[]> = computed(() => {
|
||||||
|
const ticks: TimeTick[] = []
|
||||||
|
for (let s = 0; s <= totalSeconds.value; s += 1) {
|
||||||
|
ticks.push({
|
||||||
|
second: s,
|
||||||
|
position: s * FPS * pxPerFrame.value,
|
||||||
|
label: '',
|
||||||
|
type: 'minor',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
if (mins > 0) {
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
return `${secs}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
const playheadPos: ComputedRef<number> = computed(() => {
|
||||||
|
if (isPlayheadDragging.value) {
|
||||||
|
return localCurrentFrame.value * pxPerFrame.value
|
||||||
|
}
|
||||||
|
return props.currentFrame * pxPerFrame.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const trackRows: ComputedRef<HazardRow[][]> = computed(() => {
|
||||||
|
const ranges = props.hazardRanges
|
||||||
|
const sortedHazards = Object.entries(ranges)
|
||||||
|
.map(([id, frames]) => ({
|
||||||
|
id,
|
||||||
|
ranges: frames.reduce<number[][]>((acc, _, i) => {
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
acc.push([frames[i], frames[i + 1]])
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, []),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aStart = a.ranges[0]?.[0] || 0
|
||||||
|
const bStart = b.ranges[0]?.[0] || 0
|
||||||
|
return aStart - bStart
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows: HazardRow[][] = []
|
||||||
|
sortedHazards.forEach((hazard) => {
|
||||||
|
let assigned = false
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const canPlace = hazard.ranges.every(([start, end]) => {
|
||||||
|
return rows[i].every((existing) => {
|
||||||
|
return end <= existing.start || start >= existing.end
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (canPlace) {
|
||||||
|
rows[i].push({
|
||||||
|
id: hazard.id,
|
||||||
|
start: hazard.ranges[0][0],
|
||||||
|
end: hazard.ranges[0][1],
|
||||||
|
ranges: hazard.ranges,
|
||||||
|
})
|
||||||
|
assigned = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!assigned) {
|
||||||
|
rows.push([{
|
||||||
|
id: hazard.id,
|
||||||
|
start: hazard.ranges[0][0],
|
||||||
|
end: hazard.ranges[0][1],
|
||||||
|
ranges: hazard.ranges,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
function getHazardStyle(start: number, end: number): Record<string, string> {
|
||||||
|
const left = start * pxPerFrame.value
|
||||||
|
const width = (end - start) * pxPerFrame.value
|
||||||
|
return {
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHazardClick(hazardId: string): void {
|
||||||
|
emit('hazardClick', Number(hazardId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(e: WheelEvent): void {
|
||||||
|
if (!timelineContainer.value)
|
||||||
|
return
|
||||||
|
e.preventDefault()
|
||||||
|
const container = timelineContainer.value
|
||||||
|
const delta = e.deltaY || e.deltaX
|
||||||
|
container.scrollLeft += delta
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseDown(e: MouseEvent): void {
|
||||||
|
if (e.button !== 0)
|
||||||
|
return
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
const playhead = document.getElementById('playhead')
|
||||||
|
if (playhead && (playhead.contains(target) || target === playhead))
|
||||||
|
return
|
||||||
|
const ruler = timelineContainer.value?.querySelector('.timeline-ruler')
|
||||||
|
if (ruler && (ruler.contains(target) || target === ruler)) {
|
||||||
|
isRulerDragging.value = true
|
||||||
|
}
|
||||||
|
isDragging.value = true
|
||||||
|
dragStartX.value = e.clientX
|
||||||
|
if (timelineContainer.value)
|
||||||
|
scrollStartLeft.value = timelineContainer.value.scrollLeft
|
||||||
|
document.body.style.cursor = 'grabbing'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(e: MouseEvent): void {
|
||||||
|
if (!isDragging.value || !timelineContainer.value)
|
||||||
|
return
|
||||||
|
const delta = dragStartX.value - e.clientX
|
||||||
|
timelineContainer.value.scrollLeft = scrollStartLeft.value + delta
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp(): void {
|
||||||
|
isDragging.value = false
|
||||||
|
isPlayheadDragging.value = false
|
||||||
|
isRulerDragging.value = false
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayheadMouseDown(e: MouseEvent): void {
|
||||||
|
e.stopPropagation()
|
||||||
|
isPlayheadDragging.value = true
|
||||||
|
dragStartX.value = e.clientX
|
||||||
|
document.body.style.cursor = 'ew-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRulerClick(e: MouseEvent): void {
|
||||||
|
if (isRulerDragging.value)
|
||||||
|
return
|
||||||
|
if (!timelineContainer.value)
|
||||||
|
return
|
||||||
|
const rect = timelineContainer.value.getBoundingClientRect()
|
||||||
|
const scrollLeft = timelineContainer.value.scrollLeft
|
||||||
|
const x = e.clientX - rect.left + scrollLeft
|
||||||
|
const frame = Math.floor(x / pxPerFrame.value)
|
||||||
|
const clampedFrame = Math.max(0, Math.min(frame, props.totalFrames - 1))
|
||||||
|
localCurrentFrame.value = clampedFrame
|
||||||
|
emit('frameChange', clampedFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayheadDrag(e: MouseEvent): void {
|
||||||
|
if (!isPlayheadDragging.value || !timelineContainer.value)
|
||||||
|
return
|
||||||
|
const rect = timelineContainer.value.getBoundingClientRect()
|
||||||
|
const scrollLeft = timelineContainer.value.scrollLeft
|
||||||
|
const x = e.clientX - rect.left + scrollLeft
|
||||||
|
const frame = Math.floor(x / pxPerFrame.value)
|
||||||
|
const clampedFrame = Math.max(0, Math.min(frame, props.totalFrames - 1))
|
||||||
|
localCurrentFrame.value = clampedFrame
|
||||||
|
emit('frameChange', clampedFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.currentFrame, () => {
|
||||||
|
if (!isPlayheadDragging.value) {
|
||||||
|
localCurrentFrame.value = props.currentFrame
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
if (!timelineContainer.value)
|
||||||
|
return
|
||||||
|
const container = timelineContainer.value
|
||||||
|
const playhead = document.getElementById('playhead')
|
||||||
|
if (!playhead)
|
||||||
|
return
|
||||||
|
|
||||||
|
const playheadLeft = playhead.offsetLeft
|
||||||
|
const containerWidth = container.clientWidth
|
||||||
|
const containerScrollLeft = container.scrollLeft
|
||||||
|
|
||||||
|
const playheadVisible = playheadLeft >= containerScrollLeft && playheadLeft <= containerScrollLeft + containerWidth - 50
|
||||||
|
|
||||||
|
if (!playheadVisible) {
|
||||||
|
container.scrollLeft = playheadLeft - containerWidth / 2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function initMounted(): void {
|
||||||
|
const progressContainer = timelineContainer.value
|
||||||
|
if (!progressContainer)
|
||||||
|
return
|
||||||
|
|
||||||
|
updatePxPerFrame()
|
||||||
|
|
||||||
|
progressContainer.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
|
progressContainer.addEventListener('mousedown', handleMouseDown)
|
||||||
|
window.addEventListener('mousemove', handleMouseMove)
|
||||||
|
window.addEventListener('mousemove', handlePlayheadDrag)
|
||||||
|
window.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.totalFrames, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
updatePxPerFrame()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function initUnmounted(): void {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
window.removeEventListener('mousemove', handlePlayheadDrag)
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pxPerFrame,
|
||||||
|
minPxPerFrame,
|
||||||
|
maxPxPerFrame,
|
||||||
|
timelineContainer,
|
||||||
|
isDragging,
|
||||||
|
isPlayheadDragging,
|
||||||
|
isRulerDragging,
|
||||||
|
localCurrentFrame,
|
||||||
|
trackWidth,
|
||||||
|
timeTicks,
|
||||||
|
secondTicks,
|
||||||
|
playheadPos,
|
||||||
|
trackRows,
|
||||||
|
setZoom,
|
||||||
|
getHazardStyle,
|
||||||
|
handleHazardClick,
|
||||||
|
handleMouseMove,
|
||||||
|
handleMouseUp,
|
||||||
|
handlePlayheadMouseDown,
|
||||||
|
handleRulerClick,
|
||||||
|
handlePlayheadDrag,
|
||||||
|
initMounted,
|
||||||
|
initUnmounted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimelineEmits, TimelineProps } from './timeline'
|
||||||
|
import { ElScrollbar, ElSlider } from 'element-plus'
|
||||||
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useTimeline } from './timeline'
|
||||||
|
import './timeline.css'
|
||||||
|
|
||||||
|
const props = defineProps<TimelineProps>()
|
||||||
|
|
||||||
|
const emit = defineEmits<TimelineEmits>()
|
||||||
|
|
||||||
|
const {
|
||||||
|
timelineContainer,
|
||||||
|
trackWidth,
|
||||||
|
timeTicks,
|
||||||
|
secondTicks,
|
||||||
|
playheadPos,
|
||||||
|
trackRows,
|
||||||
|
pxPerFrame,
|
||||||
|
minPxPerFrame,
|
||||||
|
maxPxPerFrame,
|
||||||
|
setZoom,
|
||||||
|
getHazardStyle,
|
||||||
|
handleHazardClick,
|
||||||
|
handleMouseMove,
|
||||||
|
handleMouseUp,
|
||||||
|
handlePlayheadMouseDown,
|
||||||
|
handleRulerClick,
|
||||||
|
initMounted,
|
||||||
|
initUnmounted,
|
||||||
|
} = useTimeline(props, emit)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initMounted()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
initUnmounted()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="timeline-wrapper">
|
||||||
|
<div class="timeline-toolbar">
|
||||||
|
<span class="timeline-title">隐患时间线 (共 {{ Object.keys(hazardRanges).length }} 个隐患)</span>
|
||||||
|
<div class="timeline-zoom-controls">
|
||||||
|
<div class="timeline-zoom-label">
|
||||||
|
<el-text type="info" size="small">
|
||||||
|
缩放
|
||||||
|
</el-text>
|
||||||
|
</div>
|
||||||
|
<ElSlider
|
||||||
|
v-model="pxPerFrame"
|
||||||
|
:min="minPxPerFrame"
|
||||||
|
:max="maxPxPerFrame"
|
||||||
|
:step="0.5"
|
||||||
|
:show-tooltip="true"
|
||||||
|
:format-tooltip="(val: number) => `缩放: ${val}`"
|
||||||
|
class="zoom-slider"
|
||||||
|
@change="(val: number | number[]) => setZoom(Array.isArray(val) ? val[0] : val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="timelineContainer"
|
||||||
|
class="timeline-container"
|
||||||
|
@mousemove="handleMouseMove"
|
||||||
|
@mouseup="handleMouseUp"
|
||||||
|
@mouseleave="handleMouseUp"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="playhead"
|
||||||
|
class="playhead"
|
||||||
|
:style="{ left: `${playheadPos}px` }"
|
||||||
|
@mousedown="handlePlayheadMouseDown"
|
||||||
|
>
|
||||||
|
<div class="playhead-indicator" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline-content" :style="{ width: trackWidth }">
|
||||||
|
<div class="timeline-ruler">
|
||||||
|
<div
|
||||||
|
v-for="tick in secondTicks"
|
||||||
|
:key="tick.second"
|
||||||
|
class="time-tick"
|
||||||
|
:style="{ left: `${tick.position}px` }"
|
||||||
|
>
|
||||||
|
<div class="tick-mark minor" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="tick in timeTicks"
|
||||||
|
:key="tick.second"
|
||||||
|
class="time-tick"
|
||||||
|
:style="{ left: `${tick.position}px` }"
|
||||||
|
>
|
||||||
|
<div class="tick-mark major" />
|
||||||
|
<span class="tick-label">{{ tick.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="timeline-ruler-click-area"
|
||||||
|
@click="handleRulerClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElScrollbar :style="{ height: '100%' }">
|
||||||
|
<div id="tracks-list" class="tracks-list">
|
||||||
|
<div
|
||||||
|
v-for="(row, rowIndex) in trackRows"
|
||||||
|
:key="rowIndex"
|
||||||
|
class="track-row"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="hazard in row"
|
||||||
|
:key="hazard.id"
|
||||||
|
class="hazard-block"
|
||||||
|
:style="getHazardStyle(hazard.start, hazard.end)"
|
||||||
|
@click="handleHazardClick(hazard.id)"
|
||||||
|
>
|
||||||
|
{{ parseInt(hazard.id) + 1 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
13
src/main.ts
13
src/main.ts
|
|
@ -3,14 +3,14 @@ import { ViteSSG } from 'vite-ssg'
|
||||||
|
|
||||||
// import "~/styles/element/index.scss";
|
// import "~/styles/element/index.scss";
|
||||||
|
|
||||||
// import ElementPlus from "element-plus";
|
import { routes } from 'vue-router/auto-routes'
|
||||||
// import all element css, uncommented next line
|
|
||||||
// import "element-plus/dist/index.css";
|
|
||||||
|
|
||||||
// or use cdn, uncomment cdn link in `index.html`
|
// or use cdn, uncomment cdn link in `index.html`
|
||||||
|
|
||||||
import { routes } from 'vue-router/auto-routes'
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
// import ElementPlus from "element-plus";
|
||||||
|
// import all element css, uncommented next line
|
||||||
|
// import "element-plus/dist/index.css";
|
||||||
|
|
||||||
import '~/styles/index.scss'
|
import '~/styles/index.scss'
|
||||||
|
|
||||||
|
|
@ -18,7 +18,10 @@ import 'uno.css'
|
||||||
// If you want to use ElMessage, import it.
|
// If you want to use ElMessage, import it.
|
||||||
import 'element-plus/theme-chalk/src/message.scss'
|
import 'element-plus/theme-chalk/src/message.scss'
|
||||||
import 'element-plus/theme-chalk/src/message-box.scss'
|
import 'element-plus/theme-chalk/src/message-box.scss'
|
||||||
import 'element-plus/theme-chalk/src/overlay.scss' // the modal class for message box
|
import 'element-plus/theme-chalk/src/overlay.scss'
|
||||||
|
import 'element-plus/theme-chalk/src/slider.scss'
|
||||||
|
import 'element-plus/theme-chalk/src/input-number.scss'
|
||||||
|
import 'element-plus/theme-chalk/src/input.scss'
|
||||||
|
|
||||||
// if you do not need ssg:
|
// if you do not need ssg:
|
||||||
// import { createApp } from "vue";
|
// import { createApp } from "vue";
|
||||||
|
|
|
||||||
|
|
@ -23,5 +23,5 @@
|
||||||
"vueCompilerOptions": {
|
"vueCompilerOptions": {
|
||||||
"target": 3
|
"target": 3
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "src/components/hazard_inspect/timeline/timeline.js"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue