timeline
This commit is contained in:
263
ui/detection-app/src/components/FrameStrip.vue
Normal file
263
ui/detection-app/src/components/FrameStrip.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
frames: Array<{ seq: number; timestamp: number; jpeg_b64: string }>
|
||||
currentIndex: number
|
||||
selectionStart: number
|
||||
selectionEnd: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'frame-click': [index: number]
|
||||
'selection-change': [start: number, end: number]
|
||||
}>()
|
||||
|
||||
const stripEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// --- Drag handle logic ---
|
||||
type DragTarget = 'start' | 'end' | null
|
||||
const dragging = ref<DragTarget>(null)
|
||||
|
||||
function onHandleMousedown(target: DragTarget, e: MouseEvent) {
|
||||
dragging.value = target
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onMousemove(e: MouseEvent) {
|
||||
if (!dragging.value || !stripEl.value) return
|
||||
const rect = stripEl.value.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const ratio = Math.max(0, Math.min(1, x / rect.width))
|
||||
const idx = Math.round(ratio * (props.frames.length - 1))
|
||||
|
||||
if (dragging.value === 'start') {
|
||||
const newStart = Math.min(idx, props.selectionEnd)
|
||||
emit('selection-change', newStart, props.selectionEnd)
|
||||
} else {
|
||||
const newEnd = Math.max(idx, props.selectionStart)
|
||||
emit('selection-change', props.selectionStart, newEnd)
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseup() {
|
||||
dragging.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousemove', onMousemove)
|
||||
window.addEventListener('mouseup', onMouseup)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', onMousemove)
|
||||
window.removeEventListener('mouseup', onMouseup)
|
||||
})
|
||||
|
||||
// Handle positions as % of strip width
|
||||
const startPct = computed(() => {
|
||||
if (props.frames.length <= 1) return 0
|
||||
return (props.selectionStart / (props.frames.length - 1)) * 100
|
||||
})
|
||||
const endPct = computed(() => {
|
||||
if (props.frames.length <= 1) return 100
|
||||
return (props.selectionEnd / (props.frames.length - 1)) * 100
|
||||
})
|
||||
|
||||
function isInSelection(idx: number) {
|
||||
return idx >= props.selectionStart && idx <= props.selectionEnd
|
||||
}
|
||||
|
||||
// --- Scrub slider ---
|
||||
function onScrub(e: Event) {
|
||||
const val = Number((e.target as HTMLInputElement).value)
|
||||
emit('frame-click', val)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="frame-strip" ref="stripEl">
|
||||
<!-- Thumbnails -->
|
||||
<div class="thumbs">
|
||||
<div
|
||||
v-for="(frame, idx) in frames"
|
||||
:key="frame.seq"
|
||||
class="thumb"
|
||||
:class="{
|
||||
current: idx === currentIndex,
|
||||
dimmed: !isInSelection(idx),
|
||||
}"
|
||||
@click="emit('frame-click', idx)"
|
||||
:title="`Frame ${frame.seq} · ${frame.timestamp.toFixed(2)}s`"
|
||||
>
|
||||
<img :src="`data:image/jpeg;base64,${frame.jpeg_b64}`" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrub slider -->
|
||||
<div class="scrub-row">
|
||||
<input
|
||||
type="range"
|
||||
class="scrub-slider"
|
||||
:min="0"
|
||||
:max="frames.length - 1"
|
||||
:value="currentIndex"
|
||||
@input="onScrub"
|
||||
/>
|
||||
<span class="scrub-label">{{ currentIndex + 1 }}/{{ frames.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Selection handles -->
|
||||
<div class="handles">
|
||||
<div
|
||||
class="handle handle-start"
|
||||
:style="{ left: startPct + '%' }"
|
||||
@mousedown="onHandleMousedown('start', $event)"
|
||||
title="Drag to set selection start"
|
||||
/>
|
||||
<div
|
||||
class="handle handle-end"
|
||||
:style="{ left: endPct + '%' }"
|
||||
@mousedown="onHandleMousedown('end', $event)"
|
||||
title="Drag to set selection end"
|
||||
/>
|
||||
<div
|
||||
class="selection-range"
|
||||
:style="{ left: startPct + '%', width: (endPct - startPct) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.frame-strip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface-1);
|
||||
border-top: var(--panel-border);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.thumbs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
padding: 4px 6px 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.thumbs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.thumb {
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: 50px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.1s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.thumb.current {
|
||||
border-color: #00bcd4;
|
||||
}
|
||||
|
||||
.thumb.dimmed {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.thumb:hover:not(.current) {
|
||||
border-color: var(--surface-3);
|
||||
}
|
||||
|
||||
/* Scrub slider row */
|
||||
.scrub-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.scrub-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--surface-3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.scrub-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #00bcd4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scrub-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #00bcd4;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.scrub-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Handle track area */
|
||||
.handles {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.selection-range {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
height: 4px;
|
||||
background: #00bcd4;
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 10px;
|
||||
height: 14px;
|
||||
transform: translateX(-50%);
|
||||
cursor: ew-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 2px;
|
||||
transform: translateX(-50%);
|
||||
width: 3px;
|
||||
height: 10px;
|
||||
background: #00bcd4;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user