This commit is contained in:
2026-03-23 16:55:13 -03:00
parent 4fdbdfc6d3
commit 3df9ed5ada
17 changed files with 848 additions and 4 deletions

View File

@@ -5,6 +5,7 @@ import 'mpr-ui-framework/src/tokens.css'
import LogPanel from './panels/LogPanel.vue'
import FunnelPanel from './panels/FunnelPanel.vue'
import PipelineGraphPanel from './panels/PipelineGraphPanel.vue'
import FramePanel from './panels/FramePanel.vue'
import type { StatsUpdate } from './types/sse-contract'
const jobId = ref(new URLSearchParams(window.location.search).get('job') || 'test-job')
@@ -41,7 +42,7 @@ source.connect()
<span class="job-id">job: {{ jobId }}</span>
</header>
<LayoutGrid :columns="2" :rows="2" gap="var(--space-2)">
<LayoutGrid :columns="3" :rows="2" gap="var(--space-2)">
<Panel title="Stats" :status="status">
<div class="stats" v-if="stats">
<div class="stat" v-for="s in [
@@ -61,6 +62,8 @@ source.connect()
<FunnelPanel :source="source" :status="status" />
<FramePanel :source="source" :status="status" />
<PipelineGraphPanel :source="source" :status="status" />
<LogPanel :source="source" :status="status" />

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Panel } from 'mpr-ui-framework'
import FrameRenderer from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
import type { FrameBBox } from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
import type { DataSource } from 'mpr-ui-framework'
const props = defineProps<{
source: DataSource
status?: 'idle' | 'live' | 'processing' | 'error'
}>()
const imageSrc = ref('')
const boxes = ref<FrameBBox[]>([])
props.source.on<{
frame_ref: number
timestamp: number
jpeg_b64: string
boxes: FrameBBox[]
}>('frame_update', (e) => {
imageSrc.value = e.jpeg_b64
boxes.value = e.boxes
})
</script>
<template>
<Panel title="Frame Viewer" :status="status">
<FrameRenderer :image-src="imageSrc" :boxes="boxes" />
</Panel>
</template>

View File

@@ -12,3 +12,4 @@ export { default as LayoutGrid } from './components/LayoutGrid.vue'
export { default as LogRenderer } from './renderers/LogRenderer.vue'
export { default as TimeSeriesRenderer } from './renderers/TimeSeriesRenderer.vue'
export { default as GraphRenderer } from './renderers/GraphRenderer.vue'
export { default as FrameRenderer } from './renderers/FrameRenderer.vue'

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
export interface FrameBBox {
x: number
y: number
w: number
h: number
confidence: number
label: string
}
const props = defineProps<{
/** Base64 JPEG image */
imageSrc: string
/** Bounding boxes to overlay */
boxes: FrameBBox[]
}>()
const canvas = ref<HTMLCanvasElement | null>(null)
const container = ref<HTMLElement | null>(null)
function draw() {
const cvs = canvas.value
const ctr = container.value
if (!cvs || !ctr || !props.imageSrc) return
const ctx = cvs.getContext('2d')
if (!ctx) return
const img = new window.Image()
img.onload = () => {
cvs.width = ctr.clientWidth
cvs.height = ctr.clientHeight
const scale = Math.min(cvs.width / img.width, cvs.height / img.height)
const dx = (cvs.width - img.width * scale) / 2
const dy = (cvs.height - img.height * scale) / 2
ctx.clearRect(0, 0, cvs.width, cvs.height)
ctx.drawImage(img, dx, dy, img.width * scale, img.height * scale)
for (const box of props.boxes) {
const bx = dx + box.x * scale
const by = dy + box.y * scale
const bw = box.w * scale
const bh = box.h * scale
// Box outline
ctx.strokeStyle = confidenceColor(box.confidence)
ctx.lineWidth = 2
ctx.strokeRect(bx, by, bw, bh)
// Label background
const label = `${box.label} ${(box.confidence * 100).toFixed(0)}%`
ctx.font = '11px var(--font-mono)'
const metrics = ctx.measureText(label)
const labelH = 16
ctx.fillStyle = confidenceColor(box.confidence)
ctx.fillRect(bx, by - labelH, metrics.width + 8, labelH)
// Label text
ctx.fillStyle = '#000'
ctx.fillText(label, bx + 4, by - 4)
}
}
img.src = `data:image/jpeg;base64,${props.imageSrc}`
}
function confidenceColor(conf: number): string {
if (conf >= 0.7) return 'var(--conf-high)'
if (conf >= 0.4) return 'var(--conf-mid)'
return 'var(--conf-low)'
}
watch(() => [props.imageSrc, props.boxes], () => nextTick(draw), { deep: true })
onMounted(() => {
nextTick(draw)
const observer = new ResizeObserver(() => draw())
if (container.value) observer.observe(container.value)
onUnmounted(() => observer.disconnect())
})
</script>
<template>
<div ref="container" class="frame-renderer">
<canvas ref="canvas" />
<div v-if="!imageSrc" class="frame-empty">No frame</div>
</div>
</template>
<style scoped>
.frame-renderer {
width: 100%;
height: 100%;
min-height: 200px;
position: relative;
}
.frame-renderer canvas {
display: block;
width: 100%;
height: 100%;
}
.frame-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
}
</style>