phase 12
This commit is contained in:
@@ -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'
|
||||
|
||||
88
ui/framework/src/plugins/BBoxDrawPlugin.ts
Normal file
88
ui/framework/src/plugins/BBoxDrawPlugin.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
60
ui/framework/src/plugins/CrosshairPlugin.ts
Normal file
60
ui/framework/src/plugins/CrosshairPlugin.ts
Normal 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([])
|
||||
}
|
||||
}
|
||||
36
ui/framework/src/plugins/InteractionPlugin.ts
Normal file
36
ui/framework/src/plugins/InteractionPlugin.ts
Normal 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
|
||||
}
|
||||
@@ -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)'
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user