diff --git a/src/components.d.ts b/src/components.d.ts index 4ebbd6b..70b49c8 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -55,6 +55,6 @@ declare module 'vue' { MessageBoxDemo: typeof import('./components/MessageBoxDemo.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] 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'] } } diff --git a/src/components/hazard_inspect/timeline.vue b/src/components/hazard_inspect/timeline.vue deleted file mode 100644 index dbc8026..0000000 --- a/src/components/hazard_inspect/timeline.vue +++ /dev/null @@ -1,535 +0,0 @@ - - - - - diff --git a/src/components/hazard_inspect/timeline/timeline.css b/src/components/hazard_inspect/timeline/timeline.css new file mode 100644 index 0000000..f6b55fb --- /dev/null +++ b/src/components/hazard_inspect/timeline/timeline.css @@ -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; +} diff --git a/src/components/hazard_inspect/timeline/timeline.ts b/src/components/hazard_inspect/timeline/timeline.ts new file mode 100644 index 0000000..ea4ae1b --- /dev/null +++ b/src/components/hazard_inspect/timeline/timeline.ts @@ -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 +} + +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 = 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 = computed(() => { + return `${props.totalFrames * pxPerFrame.value + 50}px` + }) + + const totalSeconds: ComputedRef = 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 = 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 = 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 = computed(() => { + if (isPlayheadDragging.value) { + return localCurrentFrame.value * pxPerFrame.value + } + return props.currentFrame * pxPerFrame.value + }) + + const trackRows: ComputedRef = 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: 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 { + 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, + } +} diff --git a/src/components/hazard_inspect/timeline/timeline.vue b/src/components/hazard_inspect/timeline/timeline.vue new file mode 100644 index 0000000..cdb457d --- /dev/null +++ b/src/components/hazard_inspect/timeline/timeline.vue @@ -0,0 +1,128 @@ + + + diff --git a/src/main.ts b/src/main.ts index 38484ca..076f93b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,14 +3,14 @@ import { ViteSSG } from 'vite-ssg' // import "~/styles/element/index.scss"; -// import ElementPlus from "element-plus"; -// import all element css, uncommented next line -// import "element-plus/dist/index.css"; +import { routes } from 'vue-router/auto-routes' // or use cdn, uncomment cdn link in `index.html` -import { routes } from 'vue-router/auto-routes' 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' @@ -18,7 +18,10 @@ import 'uno.css' // If you want to use ElMessage, import it. import 'element-plus/theme-chalk/src/message.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: // import { createApp } from "vue"; diff --git a/tsconfig.json b/tsconfig.json index 05ce76d..2d477dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,5 @@ "vueCompilerOptions": { "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"] }