This commit is contained in:
2026-03-26 06:10:19 -03:00
parent 731964ca10
commit e27cb5bcc3
41 changed files with 2079 additions and 95 deletions

View File

@@ -15,3 +15,10 @@ export { default as TimeSeriesRenderer } from './renderers/TimeSeriesRenderer.vu
export { default as GraphRenderer } from './renderers/GraphRenderer.vue'
export { default as FrameRenderer } from './renderers/FrameRenderer.vue'
export { default as TableRenderer } from './renderers/TableRenderer.vue'
// Interaction plugins
export type { InteractionPlugin, PluginContext } from './plugins/InteractionPlugin'
export { BBoxDrawPlugin } from './plugins/BBoxDrawPlugin'
export type { BBoxResult, BBoxCallback } from './plugins/BBoxDrawPlugin'
export { CrosshairPlugin } from './plugins/CrosshairPlugin'
export type { CrosshairCallback } from './plugins/CrosshairPlugin'

View File

@@ -0,0 +1,88 @@
/**
* BBoxDrawPlugin — draw bounding boxes on the frame viewer.
*
* User drags on the canvas to draw a rectangle.
* On pointer up, emits the bbox coordinates via the callback.
* The frame viewer panel feeds this into the selection store.
*/
import type { InteractionPlugin, PluginContext } from './InteractionPlugin'
export interface BBoxResult {
x: number
y: number
w: number
h: number
}
export type BBoxCallback = (bbox: BBoxResult) => void
export class BBoxDrawPlugin implements InteractionPlugin {
name = 'bbox-draw'
private ctx: CanvasRenderingContext2D | null = null
private drawing = false
private startX = 0
private startY = 0
private currentBox: BBoxResult | null = null
private callback: BBoxCallback
constructor(callback: BBoxCallback) {
this.callback = callback
}
onMount(context: PluginContext): void {
this.ctx = context.ctx
}
onUnmount(): void {
this.ctx = null
this.drawing = false
this.currentBox = null
}
onPointerDown(e: PointerEvent): void {
this.drawing = true
this.startX = e.offsetX
this.startY = e.offsetY
this.currentBox = null
}
onPointerMove(e: PointerEvent): void {
if (!this.drawing) return
const x = Math.min(this.startX, e.offsetX)
const y = Math.min(this.startY, e.offsetY)
const w = Math.abs(e.offsetX - this.startX)
const h = Math.abs(e.offsetY - this.startY)
this.currentBox = { x, y, w, h }
}
onPointerUp(_e: PointerEvent): void {
if (!this.drawing) return
this.drawing = false
if (this.currentBox && this.currentBox.w > 5 && this.currentBox.h > 5) {
this.callback(this.currentBox)
}
this.currentBox = null
}
render(ctx: CanvasRenderingContext2D): void {
if (!this.currentBox) return
const box = this.currentBox
ctx.strokeStyle = '#4f9cf9'
ctx.lineWidth = 2
ctx.setLineDash([6, 3])
ctx.strokeRect(box.x, box.y, box.w, box.h)
ctx.setLineDash([])
// Semi-transparent fill
ctx.fillStyle = 'rgba(79, 156, 249, 0.1)'
ctx.fillRect(box.x, box.y, box.w, box.h)
}
}

View File

@@ -0,0 +1,60 @@
/**
* CrosshairPlugin — synchronized vertical crosshair across time-series panels.
*
* When the user hovers on any panel with this plugin, the crosshair
* position (as a timestamp) is written to the selection store.
* All panels with this plugin render a vertical line at that timestamp.
*/
import type { InteractionPlugin, PluginContext } from './InteractionPlugin'
export type CrosshairCallback = (timestamp: number | null) => void
export class CrosshairPlugin implements InteractionPlugin {
name = 'crosshair'
private width = 0
private callback: CrosshairCallback
/** Current crosshair X position (pixels), set externally from store */
public crosshairX: number | null = null
constructor(callback: CrosshairCallback) {
this.callback = callback
}
onMount(context: PluginContext): void {
this.width = context.width
}
onUnmount(): void {
this.crosshairX = null
}
onPointerMove(e: PointerEvent): void {
// Convert pixel X to normalized position (0-1)
const normalized = e.offsetX / this.width
this.callback(normalized)
}
onPointerDown(_e: PointerEvent): void {
// no-op for crosshair
}
onPointerUp(_e: PointerEvent): void {
this.callback(null)
}
render(ctx: CanvasRenderingContext2D): void {
if (this.crosshairX === null) return
ctx.strokeStyle = '#a78bfa'
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
ctx.beginPath()
ctx.moveTo(this.crosshairX, 0)
ctx.lineTo(this.crosshairX, ctx.canvas.height)
ctx.stroke()
ctx.setLineDash([])
}
}

View File

@@ -0,0 +1,36 @@
/**
* Interaction plugin interface.
*
* Plugins attach to a Panel's overlay canvas. They receive pointer events
* and emit typed results via the callback. The panel handles rendering
* the overlay and routing events to the active plugin.
*/
export interface PluginContext {
/** Canvas element for drawing overlays */
canvas: HTMLCanvasElement
/** 2D rendering context */
ctx: CanvasRenderingContext2D
/** Canvas dimensions (may differ from display size) */
width: number
height: number
}
export interface InteractionPlugin {
/** Unique plugin name */
name: string
/** Called when the plugin is mounted on a panel */
onMount(context: PluginContext): void
/** Called when the plugin is unmounted */
onUnmount(): void
/** Pointer event handlers (optional) */
onPointerDown?(e: PointerEvent): void
onPointerMove?(e: PointerEvent): void
onPointerUp?(e: PointerEvent): void
/** Called each animation frame to render the overlay */
render(ctx: CanvasRenderingContext2D): void
}

View File

@@ -8,6 +8,10 @@ export interface FrameBBox {
h: number
confidence: number
label: string
resolved_brand?: string | null
source?: string | null
stage?: string | null
ocr_text?: string | null
}
const props = defineProps<{
@@ -46,27 +50,37 @@ function draw() {
const bw = box.w * scale
const bh = box.h * scale
// Box outline
ctx.strokeStyle = confidenceColor(box.confidence)
const color = sourceColor(box)
const resolved = box.resolved_brand || box.ocr_text
// Box outline only — no labels, no percentages
ctx.strokeStyle = color
ctx.lineWidth = 2
if (!resolved) {
ctx.setLineDash([4, 3])
}
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)
ctx.setLineDash([])
}
}
img.src = `data:image/jpeg;base64,${props.imageSrc}`
}
const SOURCE_COLORS: Record<string, string> = {
yolo: '#f5a623', // yellow — raw detection
ocr: '#ff8c42', // orange — text extracted
ocr_matched: '#3ecf8e', // green — brand resolved
local_vlm: '#4f9cf9', // blue — VLM resolved
cloud_llm: '#a78bfa', // purple — cloud resolved
unresolved: '#e05252', // red — nothing matched
}
function sourceColor(box: FrameBBox): string {
if (box.resolved_brand) return SOURCE_COLORS.ocr_matched
if (box.source && box.source in SOURCE_COLORS) return SOURCE_COLORS[box.source]
return confidenceColor(box.confidence)
}
function confidenceColor(conf: number): string {
if (conf >= 0.7) return 'var(--conf-high)'
if (conf >= 0.4) return 'var(--conf-mid)'

View File

@@ -11,8 +11,19 @@ export interface GraphNode {
const props = defineProps<{
nodes: GraphNode[]
/** Stages that have a region editor (bbox/polygon) */
regionStages?: string[]
}>()
const emit = defineEmits<{
'open-region-editor': [stage: string]
'open-stage-editor': [stage: string]
}>()
const regionStageSet = computed(() => new Set(props.regionStages ?? [
'detect_objects', 'run_ocr', 'match_brands', 'escalate_vlm', 'escalate_cloud',
]))
const statusColors: Record<string, string> = {
pending: 'var(--status-idle)',
running: 'var(--status-processing)',
@@ -23,17 +34,15 @@ const statusColors: Record<string, string> = {
const flowNodes = computed(() =>
props.nodes.map((n, i) => ({
id: n.id,
label: n.id.replace(/_/g, ' '),
position: { x: 20, y: i * 70 },
style: {
background: statusColors[n.status] ?? statusColors.pending,
color: n.status === 'pending' ? '#ccc' : '#000',
border: 'none',
borderRadius: 'var(--panel-radius)',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--font-size-sm)',
fontWeight: '600',
padding: '8px 16px',
type: 'stage',
position: { x: 20, y: i * 80 },
data: {
label: n.id.replace(/_/g, ' '),
status: n.status,
color: statusColors[n.status] ?? statusColors.pending,
textColor: n.status === 'pending' ? '#888' : '#000',
hasRegionEditor: regionStageSet.value.has(n.id),
isRunning: n.status === 'running',
},
}))
)
@@ -63,7 +72,38 @@ const flowEdges = computed(() => {
:nodes-connectable="false"
:zoom-on-scroll="false"
:pan-on-scroll="false"
/>
>
<template #node-stage="{ data, id }">
<div
class="stage-node"
:class="{ running: data.isRunning }"
:style="{ background: data.color, color: data.textColor }"
>
<span class="stage-label">{{ data.label }}</span>
<span class="stage-actions">
<button
v-if="data.hasRegionEditor"
class="stage-btn region-btn"
title="Region editor"
@click.stop="emit('open-region-editor', id)"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="5" cy="5" r="3.5"/><line x1="7.5" y1="7.5" x2="11" y2="11"/>
</svg>
</button>
<button
class="stage-btn config-btn"
title="Stage config"
@click.stop="emit('open-stage-editor', id)"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="6" cy="6" r="2"/><path d="M6 1v2M6 9v2M1 6h2M9 6h2M2.5 2.5l1.4 1.4M8.1 8.1l1.4 1.4M2.5 9.5l1.4-1.4M8.1 3.9l1.4-1.4"/>
</svg>
</button>
</span>
</div>
</template>
</VueFlow>
</div>
</template>
@@ -77,4 +117,66 @@ const flowEdges = computed(() => {
.graph-renderer :deep(.vue-flow__background) {
background: transparent;
}
/* Hide default node styling — we use custom template */
.graph-renderer :deep(.vue-flow__node-stage) {
padding: 0;
border: none;
background: transparent;
border-radius: 0;
}
.stage-node {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: var(--panel-radius);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
font-weight: 600;
min-width: 180px;
}
.stage-node.running {
animation: node-pulse 1.5s infinite;
}
.stage-label {
flex: 1;
}
.stage-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.stage-node:hover .stage-actions {
opacity: 1;
}
.stage-btn {
background: rgba(0, 0, 0, 0.15);
border: none;
border-radius: 3px;
width: 20px;
height: 20px;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: inherit;
}
.stage-btn:hover {
background: rgba(0, 0, 0, 0.3);
}
@keyframes node-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
</style>