init commit

This commit is contained in:
2026-04-12 07:19:48 -03:00
commit 9dbf89da02
111 changed files with 14925 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
columns?: number
rows?: number
gap?: string
}>(), {
columns: 2,
rows: 2,
gap: 'var(--space-2)',
})
</script>
<template>
<div
class="layout-grid"
:style="{
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
gridTemplateRows: `repeat(${props.rows}, 1fr)`,
gap: props.gap,
}"
>
<slot />
</div>
</template>
<style scoped>
.layout-grid {
display: grid;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
defineProps<{
title: string
status?: 'idle' | 'live' | 'processing' | 'error'
}>()
</script>
<template>
<div class="panel">
<div class="panel-header">
<span class="panel-title">{{ title }}</span>
<span class="panel-actions"><slot name="actions" /></span>
<span class="panel-status" :class="status ?? 'idle'" />
</div>
<div class="panel-body">
<slot />
</div>
<div class="panel-overlay">
<slot name="overlay" />
</div>
</div>
</template>
<style scoped>
.panel {
position: relative;
background: var(--surface-1);
border: var(--panel-border);
border-radius: var(--panel-radius);
overflow: hidden;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
gap: var(--space-2);
height: var(--panel-header-height);
padding: 0 var(--space-3);
background: var(--surface-2);
border-bottom: var(--panel-border);
flex-shrink: 0;
}
.panel-title {
font-family: var(--font-ui);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-2);
}
.panel-status {
width: 8px;
height: 8px;
border-radius: 50%;
}
.panel-status.idle { background: var(--status-idle); }
.panel-status.live { background: var(--status-live); }
.panel-status.processing { background: var(--status-processing); }
.panel-status.error { background: var(--status-error); }
.panel-body {
flex: 1;
overflow: hidden;
padding: var(--space-2);
min-height: 0;
}
.panel-overlay {
position: absolute;
inset: var(--panel-header-height) 0 0 0;
pointer-events: none;
}
.panel-overlay > :deep(*) {
pointer-events: auto;
}
</style>

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed } from 'vue'
export interface ConfigField {
name: string
type: string
default: unknown
description: string
min: number | null
max: number | null
options: string[] | null
}
const props = defineProps<{
fields: ConfigField[]
values: Record<string, unknown>
}>()
const emit = defineEmits<{
'update': [name: string, value: unknown]
'reset': []
}>()
const numericFields = computed(() => props.fields.filter(f => f.type === 'int' || f.type === 'float'))
const boolFields = computed(() => props.fields.filter(f => f.type === 'bool'))
function onInput(name: string, value: unknown) {
emit('update', name, value)
}
</script>
<template>
<div class="param-editor">
<!-- Boolean fields -->
<label v-for="f in boolFields" :key="f.name" class="param-field bool-field">
<input
type="checkbox"
:checked="!!values[f.name]"
@change="(e) => onInput(f.name, (e.target as HTMLInputElement).checked)"
/>
<span class="field-label" :title="f.description">{{ f.name.replace(/_/g, ' ') }}</span>
</label>
<!-- Numeric fields (range sliders) -->
<div v-for="f in numericFields" :key="f.name" class="param-field">
<div class="field-header">
<span class="field-label" :title="f.description">{{ f.name.replace(/^edge_/, '').replace(/_/g, ' ') }}</span>
<span class="field-value">{{ values[f.name] }}</span>
</div>
<input
type="range"
:min="f.min ?? 0"
:max="f.max ?? 500"
:step="f.type === 'float' ? 0.01 : 1"
:value="values[f.name] as number"
@input="(e) => onInput(f.name, Number((e.target as HTMLInputElement).value))"
/>
<div class="field-range">
<span>{{ f.min ?? 0 }}</span>
<span>{{ f.max ?? 500 }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.param-editor {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.param-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.bool-field {
flex-direction: row;
align-items: center;
gap: 6px;
cursor: pointer;
}
.field-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.field-label {
color: var(--text-secondary);
font-size: 10px;
text-transform: capitalize;
}
.field-value {
font-weight: 600;
font-size: 10px;
color: var(--text-primary);
min-width: 30px;
text-align: right;
}
.field-range {
display: flex;
justify-content: space-between;
font-size: 9px;
color: var(--text-dim);
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
background: var(--surface-3);
border-radius: 2px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-primary);
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-primary);
cursor: pointer;
border: none;
}
input[type="checkbox"] {
accent-color: #00bcd4;
}
</style>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
direction: 'horizontal' | 'vertical'
}>()
const emit = defineEmits<{
resize: [delta: number]
}>()
const dragging = ref(false)
let startPos = 0
function onPointerDown(e: PointerEvent) {
dragging.value = true
startPos = props.direction === 'horizontal' ? e.clientX : e.clientY
const el = e.target as HTMLElement
el.setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!dragging.value) return
const currentPos = props.direction === 'horizontal' ? e.clientX : e.clientY
const delta = currentPos - startPos
startPos = currentPos
emit('resize', delta)
}
function onPointerUp() {
dragging.value = false
}
</script>
<template>
<div
class="resize-handle"
:class="[direction, { dragging }]"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
/>
</template>
<style scoped>
.resize-handle {
flex-shrink: 0;
background: transparent;
transition: background 0.15s;
touch-action: none;
z-index: 10;
}
.resize-handle:hover,
.resize-handle.dragging {
background: var(--text-dim);
}
.resize-handle.horizontal {
width: 4px;
cursor: col-resize;
margin: 0 -2px;
}
.resize-handle.vertical {
height: 4px;
cursor: row-resize;
margin: -2px 0;
}
</style>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = withDefaults(defineProps<{
/** Split direction */
direction?: 'horizontal' | 'vertical'
/** Initial size of the sized pane (px or flex ratio) */
initialSize?: number
/** Size mode: 'px' = sized pane fixed in pixels, 'ratio' = flex ratio */
sizeMode?: 'px' | 'ratio'
/** Which pane is sized: 'first' or 'second'. Default: 'first'. */
anchor?: 'first' | 'second'
/** Min size (px in px-mode, ratio in ratio-mode) */
min?: number
/** Max size (px in px-mode, ratio in ratio-mode) */
max?: number
/** Whether the divider is draggable */
resizable?: boolean
}>(), {
direction: 'horizontal',
initialSize: 1,
sizeMode: 'ratio',
anchor: 'first',
min: 0.1,
max: 10,
resizable: true,
})
const size = ref(props.initialSize)
const dragging = ref(false)
let startPos = 0
function onPointerDown(e: PointerEvent) {
if (!props.resizable) return
dragging.value = true
startPos = props.direction === 'horizontal' ? e.clientX : e.clientY
const el = e.target as HTMLElement
el.setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!dragging.value) return
const currentPos = props.direction === 'horizontal' ? e.clientX : e.clientY
let delta = currentPos - startPos
startPos = currentPos
// Dragging right/down grows first pane, shrinks second.
// If anchor is 'second', invert so dragging grows the second pane.
if (props.anchor === 'second') delta = -delta
if (props.sizeMode === 'px') {
size.value = Math.max(props.min, Math.min(props.max, size.value + delta))
} else {
const scale = props.direction === 'horizontal' ? 0.01 : 0.02
size.value = Math.max(props.min, Math.min(props.max, size.value + delta * scale))
}
}
function onPointerUp() {
dragging.value = false
}
const isHorizontal = computed(() => props.direction === 'horizontal')
const sizedStyle = computed(() => {
if (props.sizeMode === 'px') {
const sizeStr = size.value + 'px'
const minStr = props.min + 'px'
return isHorizontal.value
? { width: sizeStr, minWidth: minStr, flexShrink: '0' }
: { height: sizeStr, minHeight: minStr, flexShrink: '0' }
}
return { flex: String(size.value) }
})
const flexStyle = computed(() => ({ flex: '1' }))
const firstStyle = computed(() => props.anchor === 'first' ? sizedStyle.value : flexStyle.value)
const secondStyle = computed(() => props.anchor === 'second' ? sizedStyle.value : flexStyle.value)
</script>
<template>
<div class="split-pane" :class="[direction]">
<div class="split-first" :style="firstStyle">
<slot name="first" />
</div>
<div
v-if="resizable"
class="split-divider"
:class="[direction, { dragging }]"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
/>
<div class="split-second" :style="secondStyle">
<slot name="second" />
</div>
</div>
</template>
<style scoped>
.split-pane {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.split-pane.horizontal {
flex-direction: row;
}
.split-pane.vertical {
flex-direction: column;
}
.split-first,
.split-second {
min-height: 0;
min-width: 0;
overflow: hidden;
}
/* Children must fill their pane */
.split-first > :deep(*),
.split-second > :deep(*) {
width: 100%;
height: 100%;
}
.split-divider {
flex-shrink: 0;
background: transparent;
transition: background 0.15s;
touch-action: none;
z-index: 10;
}
.split-divider:hover,
.split-divider.dragging {
background: var(--text-dim);
}
.split-divider.horizontal {
width: 4px;
cursor: col-resize;
margin: 0 -2px;
}
.split-divider.vertical {
height: 4px;
cursor: row-resize;
margin: -2px 0;
}
</style>

View File

@@ -0,0 +1,23 @@
import { onMounted, onUnmounted, type Ref } from 'vue'
import { DataSource, type DataSourceStatus } from '../datasources/DataSource'
/**
* Composable that connects a component to a DataSource.
*
* Connects on mount, disconnects on unmount.
* Returns reactive refs for data, status, and error.
*/
export function useDataSource<T = unknown>(source: DataSource<T>): {
data: Ref<T | null>
status: Ref<DataSourceStatus>
error: Ref<string | null>
} {
onMounted(() => source.connect())
onUnmounted(() => source.disconnect())
return {
data: source.data as Ref<T | null>,
status: source.status,
error: source.error as Ref<string | null>,
}
}

View File

@@ -0,0 +1,57 @@
import { ref } from 'vue'
export interface EditorExecutionOptions {
/** Debounce delay in ms for auto-apply. Default: 150 */
debounceMs?: number
}
/**
* Generic editor execution pattern — debounced apply with auto-apply toggle,
* loading/error/timing state tracking.
*
* The caller provides the actual execution function. This composable handles
* the orchestration: debounce, auto-apply, loading state, timing.
*/
export function useEditorExecution(
executeFn: () => Promise<void>,
options: EditorExecutionOptions = {},
) {
const debounceMs = options.debounceMs ?? 150
const loading = ref(false)
const error = ref<string | null>(null)
const autoApply = ref(true)
const execTimeMs = ref<number | null>(null)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function apply() {
loading.value = true
error.value = null
execTimeMs.value = null
const t0 = performance.now()
try {
await executeFn()
execTimeMs.value = Math.round(performance.now() - t0)
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
function onParameterChange() {
if (!autoApply.value) return
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => apply(), debounceMs)
}
return {
loading,
error,
autoApply,
execTimeMs,
apply,
onParameterChange,
}
}

View File

@@ -0,0 +1,77 @@
import { ref, type Ref } from 'vue'
/**
* Generic registry composable — fetches typed data from a URL, caches it,
* exposes it reactively.
*
* Use for any data that is loaded once at app init and rarely changes:
* stage definitions, config schemas, available models, etc.
*
* The registry is shared across all consumers (singleton per URL).
*/
const cache = new Map<string, { data: Ref<any>; loading: Ref<boolean>; error: Ref<string | null>; promise: Promise<void> | null }>()
export function useRegistry<T>(url: string): {
data: Ref<T[]>
loading: Ref<boolean>
error: Ref<string | null>
refresh: () => Promise<void>
} {
if (!cache.has(url)) {
const data = ref<T[]>([]) as Ref<T[]>
const loading = ref(false)
const error = ref<string | null>(null)
const entry = { data, loading, error, promise: null as Promise<void> | null }
cache.set(url, entry)
async function doFetch() {
loading.value = true
error.value = null
try {
const resp = await fetch(url)
if (!resp.ok) {
error.value = `Failed to fetch registry: ${resp.status}`
return
}
data.value = await resp.json()
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
entry.promise = doFetch()
}
const entry = cache.get(url)!
async function refresh() {
const data = entry.data
const loading = entry.loading
const error = entry.error
loading.value = true
error.value = null
try {
const resp = await fetch(url)
if (!resp.ok) {
error.value = `Failed to fetch registry: ${resp.status}`
return
}
data.value = await resp.json()
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
return {
data: entry.data as Ref<T[]>,
loading: entry.loading,
error: entry.error,
refresh,
}
}

View File

@@ -0,0 +1,40 @@
import { type Ref, ref } from 'vue'
export type DataSourceStatus = 'idle' | 'connecting' | 'live' | 'error'
/**
* Base class for all data sources.
*
* A DataSource connects to some event stream, exposes reactive state,
* and lets consumers subscribe to typed events. Panels read from these
* reactively — they never touch the transport layer directly.
*/
export abstract class DataSource<T = unknown> {
readonly id: string
readonly data: Ref<T | null> = ref(null) as Ref<T | null>
readonly status: Ref<DataSourceStatus> = ref('idle')
readonly error: Ref<string | null> = ref(null) as Ref<string | null>
private listeners = new Map<string, Set<(payload: any) => void>>()
constructor(id: string) {
this.id = id
}
abstract connect(): void
abstract disconnect(): void
/** Subscribe to a specific event type */
on<P = unknown>(eventType: string, handler: (payload: P) => void): () => void {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, new Set())
}
this.listeners.get(eventType)!.add(handler)
return () => this.listeners.get(eventType)?.delete(handler)
}
/** Emit an event to subscribers (called by subclasses) */
protected emit(eventType: string, payload: unknown): void {
this.listeners.get(eventType)?.forEach((fn) => fn(payload))
}
}

View File

@@ -0,0 +1,93 @@
import { DataSource } from './DataSource'
export interface SSEDataSourceOptions {
/** Unique identifier for this source */
id: string
/** SSE endpoint URL (e.g. '/api/detect/stream/job-123') */
url: string
/** Event types to listen for. Each is dispatched to subscribers via on(). */
eventTypes: string[]
/** Max reconnection attempts before giving up. Default: 10 */
maxRetries?: number
}
/**
* DataSource backed by native EventSource (Server-Sent Events).
*
* Connects to a single SSE endpoint and demultiplexes events by type.
* Multiple panels can subscribe to different event types from the same source.
*/
export class SSEDataSource extends DataSource {
private es: EventSource | null = null
private url: string
private eventTypes: string[]
private maxRetries: number
private retryCount = 0
constructor(opts: SSEDataSourceOptions) {
super(opts.id)
this.url = opts.url
this.eventTypes = opts.eventTypes
this.maxRetries = opts.maxRetries ?? 10
}
connect(): void {
if (this.es) return
this.status.value = 'connecting'
this.error.value = null
this.es = new EventSource(this.url)
this.es.onopen = () => {
this.status.value = 'live'
this.retryCount = 0
}
this.es.onerror = () => {
if (this.es?.readyState === EventSource.CLOSED) {
this.retryCount++
if (this.retryCount >= this.maxRetries) {
this.status.value = 'error'
this.error.value = `Connection lost after ${this.maxRetries} retries`
this.disconnect()
} else {
this.status.value = 'connecting'
}
}
}
// Register a listener for each event type
for (const eventType of this.eventTypes) {
this.es.addEventListener(eventType, (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data)
this.data.value = parsed
this.emit(eventType, parsed)
} catch {
// ignore malformed events
}
})
}
// Terminal event — pipeline finished (success, failure, or cancel)
this.es.addEventListener('done', () => {
this.status.value = 'idle'
})
}
disconnect(): void {
if (this.es) {
this.es.close()
this.es = null
}
}
/** Update the URL (e.g. when job ID changes) and reconnect */
setUrl(url: string): void {
this.url = url
if (this.status.value === 'live' || this.status.value === 'connecting') {
this.disconnect()
this.connect()
}
}
}

View File

@@ -0,0 +1,45 @@
import { DataSource } from './DataSource'
export interface StaticEvent {
type: string
data: unknown
/** Delay in ms before emitting this event (relative to previous). Default: 0 */
delay?: number
}
/**
* DataSource that replays a fixture array of events.
*
* Used for development and testing without a running backend.
* Events are emitted in sequence with optional delays.
*/
export class StaticDataSource extends DataSource {
private events: StaticEvent[]
private timeouts: ReturnType<typeof setTimeout>[] = []
constructor(id: string, events: StaticEvent[]) {
super(id)
this.events = events
}
connect(): void {
this.status.value = 'live'
this.error.value = null
let cumDelay = 0
for (const event of this.events) {
cumDelay += event.delay ?? 0
const timeout = setTimeout(() => {
this.data.value = event.data
this.emit(event.type, event.data)
}, cumDelay)
this.timeouts.push(timeout)
}
}
disconnect(): void {
for (const t of this.timeouts) clearTimeout(t)
this.timeouts = []
this.status.value = 'idle'
}
}

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { StaticDataSource } from '../StaticDataSource'
describe('StaticDataSource', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('emits events in order', async () => {
const source = new StaticDataSource('test', [
{ type: 'log', data: { msg: 'first' } },
{ type: 'log', data: { msg: 'second' } },
{ type: 'stats', data: { count: 42 } },
])
const received: { type: string; data: unknown }[] = []
source.on('log', (d) => received.push({ type: 'log', data: d }))
source.on('stats', (d) => received.push({ type: 'stats', data: d }))
source.connect()
// Events with delay=0 fire on next microtask via setTimeout(0)
await new Promise((r) => setTimeout(r, 10))
expect(source.status.value).toBe('live')
expect(received).toHaveLength(3)
expect(received[0]).toEqual({ type: 'log', data: { msg: 'first' } })
expect(received[1]).toEqual({ type: 'log', data: { msg: 'second' } })
expect(received[2]).toEqual({ type: 'stats', data: { count: 42 } })
source.disconnect()
expect(source.status.value).toBe('idle')
})
it('respects delays between events', async () => {
const source = new StaticDataSource('test-delay', [
{ type: 'a', data: 1 },
{ type: 'b', data: 2, delay: 50 },
])
const received: unknown[] = []
source.on('a', (d) => received.push(d))
source.on('b', (d) => received.push(d))
source.connect()
await new Promise((r) => setTimeout(r, 10))
expect(received).toHaveLength(1) // only 'a' so far
await new Promise((r) => setTimeout(r, 60))
expect(received).toHaveLength(2) // 'b' arrived after delay
source.disconnect()
})
it('updates data ref with latest event payload', async () => {
const source = new StaticDataSource('test-data', [
{ type: 'x', data: { v: 1 } },
{ type: 'x', data: { v: 2 } },
])
source.connect()
await new Promise((r) => setTimeout(r, 10))
expect(source.data.value).toEqual({ v: 2 })
source.disconnect()
})
it('cleans up on disconnect', async () => {
const source = new StaticDataSource('test-cleanup', [
{ type: 'a', data: 1 },
{ type: 'b', data: 2, delay: 100 },
])
const received: unknown[] = []
source.on('b', (d) => received.push(d))
source.connect()
await new Promise((r) => setTimeout(r, 10))
source.disconnect()
// 'b' should never fire since we disconnected before its delay
await new Promise((r) => setTimeout(r, 150))
expect(received).toHaveLength(0)
})
it('unsubscribe removes listener', async () => {
const source = new StaticDataSource('test-unsub', [
{ type: 'x', data: 1 },
])
const received: unknown[] = []
const unsub = source.on('x', (d) => received.push(d))
unsub()
source.connect()
await new Promise((r) => setTimeout(r, 10))
expect(received).toHaveLength(0)
source.disconnect()
})
})

38
ui/framework/src/index.ts Normal file
View File

@@ -0,0 +1,38 @@
// Framework public API
export { DataSource, type DataSourceStatus } from './datasources/DataSource'
export { SSEDataSource } from './datasources/SSEDataSource'
export { StaticDataSource } from './datasources/StaticDataSource'
export { useDataSource } from './composables/useDataSource'
export { useRegistry } from './composables/useRegistry'
export { useEditorExecution } from './composables/useEditorExecution'
export type { EditorExecutionOptions } from './composables/useEditorExecution'
// Components
export { default as Panel } from './components/Panel.vue'
export { default as LayoutGrid } from './components/LayoutGrid.vue'
export { default as ResizeHandle } from './components/ResizeHandle.vue'
export { default as SplitPane } from './components/SplitPane.vue'
export { default as ParameterEditor } from './components/ParameterEditor.vue'
export type { ConfigField } from './components/ParameterEditor.vue'
// Renderers
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'
export { default as TableRenderer } from './renderers/TableRenderer.vue'
// Renderer types
export type { FrameBBox, FrameOverlay } from './renderers/FrameRenderer.vue'
export type { LogEntry } from './renderers/LogRenderer.vue'
export type { GraphNode, GraphMode } from './renderers/GraphRenderer.vue'
export type { TableColumn } from './renderers/TableRenderer.vue'
export type { TimeSeriesSeries } from './renderers/TimeSeriesRenderer.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

@@ -0,0 +1,178 @@
<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
resolved_brand?: string | null
source?: string | null
stage?: string | null
ocr_text?: string | null
}
export interface FrameOverlay {
/** Base64 encoded image (same dimensions as main image) */
src: string
label: string
visible: boolean
/** Opacity 0-1, default 0.5 */
opacity?: number
/** Image format — 'jpeg' (default) or 'png' (supports transparency) */
srcFormat?: 'jpeg' | 'png'
}
const props = defineProps<{
/** Base64 JPEG image */
imageSrc: string
/** Bounding boxes to overlay */
boxes: FrameBBox[]
/** Debug overlay layers (edge images, line visualizations, etc.) */
overlays?: FrameOverlay[]
}>()
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)
// Draw debug overlays (edge images, line visualizations)
drawOverlays(ctx, dx, dy, img.width * scale, img.height * scale)
// Draw bounding boxes on top
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
const color = sourceColor(box)
const resolved = box.resolved_brand || box.ocr_text
ctx.strokeStyle = color
ctx.lineWidth = 2
if (!resolved) {
ctx.setLineDash([4, 3])
}
ctx.strokeRect(bx, by, bw, bh)
ctx.setLineDash([])
}
}
img.src = `data:image/jpeg;base64,${props.imageSrc}`
}
/** Pending overlay images that need async loading */
const overlayCache = new Map<string, HTMLImageElement>()
function drawOverlays(ctx: CanvasRenderingContext2D, dx: number, dy: number, dw: number, dh: number) {
const layers = props.overlays ?? []
for (const layer of layers) {
if (!layer.visible || !layer.src) continue
const cached = overlayCache.get(layer.src)
if (cached && cached.complete) {
ctx.globalAlpha = layer.opacity ?? 0.5
ctx.drawImage(cached, dx, dy, dw, dh)
ctx.globalAlpha = 1.0
} else if (!cached) {
// Load async, redraw when ready
const overlay = new window.Image()
overlay.onload = () => draw()
overlay.src = `data:image/${layer.srcFormat ?? 'jpeg'};base64,${layer.src}`
overlayCache.set(layer.src, overlay)
}
}
}
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
}
// CV region labels — distinct from source-based colors
const REGION_COLORS: Record<string, string> = {
edge_region: '#00bcd4', // cyan
contour_region: '#ffd54f', // yellow
color_region: '#e040fb', // magenta
candidate: '#4caf50', // green — passed readability
rejected: '#e05252', // red — failed readability
}
function sourceColor(box: FrameBBox): string {
if (REGION_COLORS[box.label]) return REGION_COLORS[box.label]
if (box.resolved_brand) return SOURCE_COLORS.ocr_matched
if (box.source && SOURCE_COLORS[box.source]) 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)'
return 'var(--conf-low)'
}
watch(() => [props.imageSrc, props.boxes, props.overlays], () => 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>

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed } from 'vue'
import { VueFlow } from '@vue-flow/core'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
export interface GraphNode {
id: string
status: 'pending' | 'running' | 'done' | 'error' | 'skipped' | 'placeholder'
/** Whether a checkpoint exists at this stage */
hasCheckpoint?: boolean
/** Stage category (e.g. 'cv', 'ai', 'preprocessing') */
category?: string
/** Which editors are available for this stage */
availableEditors?: string[]
}
export type GraphMode = 'observe' | 'edit-in-pipeline' | 'edit-isolated'
const props = withDefaults(defineProps<{
nodes: GraphNode[]
/** Interaction mode — changes visual treatment and click behavior */
mode?: GraphMode
/** Currently edited stage (highlighted in edit modes) */
activeStage?: string | null
/** Stages that have a region editor (bbox/polygon) */
regionStages?: string[]
}>(), {
mode: 'observe',
activeStage: null,
})
const emit = defineEmits<{
'open-region-editor': [stage: string]
'open-stage-editor': [stage: string]
'node-click': [stage: string]
}>()
const regionStageSet = computed(() => new Set(props.regionStages ?? []))
const STATUS_COLORS: Record<string, string> = {
pending: 'var(--status-idle)',
running: 'var(--status-processing)',
done: 'var(--status-live)',
error: 'var(--status-error)',
skipped: '#4a6fa5',
placeholder: 'transparent',
}
function nodeAppearance(node: GraphNode) {
const isActive = node.id === props.activeStage
const mode = props.mode
// Edit-isolated: only the active node is fully visible
if (mode === 'edit-isolated' && !isActive) {
return {
color: 'var(--surface-3)',
textColor: 'var(--text-dim)',
opacity: 0.5,
outline: false,
}
}
// Edit-in-pipeline: active node highlighted, upstream dimmed, downstream normal
if (mode === 'edit-in-pipeline' && props.activeStage) {
const activeIdx = props.nodes.findIndex(n => n.id === props.activeStage)
const nodeIdx = props.nodes.findIndex(n => n.id === node.id)
if (isActive) {
return {
color: 'var(--status-processing)',
textColor: '#fff',
opacity: 1,
outline: true,
}
}
if (nodeIdx < activeIdx) {
// Upstream: frozen from checkpoint
return {
color: 'var(--surface-3)',
textColor: 'var(--text-secondary)',
opacity: 0.7,
outline: false,
}
}
}
// Placeholder: hollow, no text
if (node.status === 'placeholder') {
return {
color: 'transparent',
textColor: 'transparent',
opacity: 0.6,
outline: false,
}
}
// Default: observe mode or downstream in edit-in-pipeline
return {
color: STATUS_COLORS[node.status] ?? STATUS_COLORS.pending,
textColor: '#fff',
opacity: 1,
outline: isActive,
}
}
const flowNodes = computed(() =>
props.nodes.map((n, i) => {
const appearance = nodeAppearance(n)
return {
id: n.id,
type: 'stage',
position: { x: 20, y: i * 80 },
data: {
label: n.id.replace(/_/g, ' '),
status: n.status,
...appearance,
hasCheckpoint: n.hasCheckpoint ?? false,
hasStageEditor: regionStageSet.value.has(n.id),
isRunning: n.status === 'running',
isActive: n.id === props.activeStage,
},
}
})
)
const flowEdges = computed(() => {
const edges = []
for (let i = 0; i < props.nodes.length - 1; i++) {
const isActiveEdge = props.mode !== 'observe' && props.activeStage
&& props.nodes.findIndex(n => n.id === props.activeStage) > i
edges.push({
id: `${props.nodes[i].id}->${props.nodes[i + 1].id}`,
source: props.nodes[i].id,
target: props.nodes[i + 1].id,
animated: props.nodes[i].status === 'running',
style: {
stroke: isActiveEdge ? 'var(--text-dim)' : '#555568',
strokeDasharray: isActiveEdge ? '4 4' : undefined,
},
})
}
return edges
})
function onNodeClick(id: string) {
emit('node-click', id)
}
</script>
<template>
<div class="graph-renderer">
<VueFlow
:nodes="flowNodes"
:edges="flowEdges"
:fit-view-on-init="true"
:nodes-draggable="false"
:nodes-connectable="false"
:zoom-on-scroll="false"
:pan-on-scroll="false"
>
<template #node-stage="{ data, id }">
<div
class="stage-node"
:class="{
running: data.isRunning,
active: data.isActive,
outline: data.outline,
dimmed: data.opacity < 1,
placeholder: data.status === 'placeholder',
}"
:style="{
background: data.color,
color: data.textColor,
opacity: data.opacity,
}"
@click="onNodeClick(id)"
>
<span class="stage-label">{{ data.label }}</span>
<!-- Checkpoint indicator -->
<span v-if="data.hasCheckpoint" class="checkpoint-badge" title="Checkpoint available">
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<circle cx="5" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<circle cx="5" cy="5" r="1.5"/>
</svg>
</span>
<span class="stage-actions">
<button
v-if="data.hasStageEditor"
class="stage-btn editor-btn"
title="Stage 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>
<style scoped>
.graph-renderer {
width: 100%;
height: 100%;
min-height: 200px;
}
.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;
cursor: pointer;
transition: opacity 0.2s, box-shadow 0.2s;
}
.stage-node.running {
animation: node-pulse 1.5s infinite;
}
.stage-node.outline {
box-shadow: 0 0 0 2px var(--status-processing);
}
.stage-node.dimmed {
pointer-events: none;
}
.stage-node.placeholder {
border: 1px dashed var(--text-secondary);
background: transparent;
color: transparent;
pointer-events: none;
}
.stage-node.placeholder .stage-actions,
.stage-node.placeholder .checkpoint-badge {
display: none;
}
.stage-label {
flex: 1;
}
.checkpoint-badge {
opacity: 0.7;
display: flex;
align-items: center;
}
.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>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
export interface LogEntry {
level: string
stage: string
msg: string
ts: string
}
const props = withDefaults(defineProps<{
entries: LogEntry[]
rowHeight?: number
autoScroll?: boolean
}>(), {
rowHeight: 24,
autoScroll: true,
})
const container = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const containerHeight = ref(0)
const userScrolled = ref(false)
const visibleRange = computed(() => {
const start = Math.floor(scrollTop.value / props.rowHeight)
const visible = Math.ceil(containerHeight.value / props.rowHeight) + 2
return {
start: Math.max(0, start - 1),
end: Math.min(props.entries.length, start + visible),
}
})
const totalHeight = computed(() => props.entries.length * props.rowHeight)
const visibleEntries = computed(() =>
props.entries.slice(visibleRange.value.start, visibleRange.value.end).map((entry, i) => ({
...entry,
index: visibleRange.value.start + i,
}))
)
function onScroll(e: Event) {
const el = e.target as HTMLElement
scrollTop.value = el.scrollTop
// If user scrolled away from bottom, pause auto-scroll
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < props.rowHeight * 2
userScrolled.value = !atBottom
}
function scrollToBottom() {
if (container.value && props.autoScroll && !userScrolled.value) {
container.value.scrollTop = container.value.scrollHeight
}
}
watch(() => props.entries.length, () => {
nextTick(scrollToBottom)
})
onMounted(() => {
if (container.value) {
containerHeight.value = container.value.clientHeight
const observer = new ResizeObserver(([entry]) => {
containerHeight.value = entry.contentRect.height
})
observer.observe(container.value)
onUnmounted(() => observer.disconnect())
}
})
const levelClass = (level: string) => level.toLowerCase()
</script>
<template>
<div class="log-renderer" ref="container" @scroll="onScroll">
<div class="log-spacer" :style="{ height: totalHeight + 'px' }">
<div
class="log-viewport"
:style="{ transform: `translateY(${visibleRange.start * rowHeight}px)` }"
>
<div
v-for="entry in visibleEntries"
:key="entry.index"
class="log-row"
:class="levelClass(entry.level)"
:style="{ height: rowHeight + 'px' }"
>
<span class="log-ts">{{ entry.ts }}</span>
<span class="log-level">{{ entry.level }}</span>
<span class="log-stage">{{ entry.stage }}</span>
<span class="log-msg">{{ entry.msg }}</span>
</div>
</div>
</div>
<div v-if="entries.length === 0" class="log-empty">
Waiting for log events...
</div>
</div>
</template>
<style scoped>
.log-renderer {
overflow-y: auto;
height: 100%;
font-family: var(--font-mono);
font-size: 12px;
}
.log-spacer {
position: relative;
}
.log-viewport {
position: absolute;
left: 0;
right: 0;
}
.log-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-2);
line-height: 1;
}
.log-ts { color: var(--text-dim); min-width: 80px; flex-shrink: 0; }
.log-level { min-width: 56px; font-weight: 600; flex-shrink: 0; }
.log-stage { color: var(--status-processing); min-width: 120px; flex-shrink: 0; }
.log-msg { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.log-row.info .log-level { color: var(--status-live); }
.log-row.warning .log-level { color: var(--status-escalating); }
.log-row.error .log-level { color: var(--status-error); }
.log-row.debug .log-level { color: var(--text-dim); }
.log-empty {
color: var(--text-dim);
padding: var(--space-6);
text-align: center;
}
</style>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed } from 'vue'
export interface TableColumn {
key: string
label: string
width?: string
}
const props = defineProps<{
columns: TableColumn[]
rows: Record<string, unknown>[]
sortKey?: string
sortDir?: 'asc' | 'desc'
}>()
const emits = defineEmits<{
sort: [key: string]
}>()
const sorted = computed(() => {
if (!props.sortKey) return props.rows
const key = props.sortKey
const dir = props.sortDir === 'desc' ? -1 : 1
return [...props.rows].sort((a, b) => {
const av = a[key] as number | string
const bv = b[key] as number | string
if (av < bv) return -1 * dir
if (av > bv) return 1 * dir
return 0
})
})
</script>
<template>
<div class="table-renderer">
<table>
<thead>
<tr>
<th
v-for="col in columns"
:key="col.key"
:style="{ width: col.width }"
@click="emits('sort', col.key)"
class="sortable"
>
{{ col.label }}
<span v-if="sortKey === col.key" class="sort-indicator">
{{ sortDir === 'desc' ? '' : '' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in sorted" :key="i">
<td v-for="col in columns" :key="col.key">
{{ row[col.key] }}
</td>
</tr>
<tr v-if="rows.length === 0">
<td :colspan="columns.length" class="empty">No detections yet</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.table-renderer {
overflow: auto;
height: 100%;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th {
position: sticky;
top: 0;
background: var(--surface-2);
color: var(--text-secondary);
font-weight: 600;
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: var(--panel-border);
cursor: pointer;
user-select: none;
}
th:hover {
color: var(--text-primary);
}
.sort-indicator {
font-size: 9px;
margin-left: 4px;
}
td {
padding: var(--space-1) var(--space-3);
border-bottom: 1px solid var(--surface-3);
white-space: normal;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
}
tr:hover td {
background: var(--surface-3);
}
.empty {
color: var(--text-dim);
text-align: center;
padding: var(--space-6);
}
</style>

View File

@@ -0,0 +1,198 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import uPlot from 'uplot'
import 'uplot/dist/uPlot.min.css'
export interface TimeSeriesSeries {
label: string
color: string
}
const props = withDefaults(defineProps<{
/** Array of series configs (label + color) */
series: TimeSeriesSeries[]
/** Data: [timestamps[], series1[], series2[], ...] */
data: uPlot.AlignedData
/** Chart title (optional) */
title?: string
/** Stacked area mode */
stacked?: boolean
}>(), {
stacked: false,
})
const container = ref<HTMLElement | null>(null)
const zoomed = ref(false)
let chart: uPlot | null = null
function buildOpts(): uPlot.Options {
const seriesOpts: uPlot.Series[] = [
{ label: 'Time' },
...props.series.map((s) => ({
label: s.label,
stroke: s.color,
fill: props.stacked ? s.color + '40' : undefined,
width: 2,
})),
]
return {
width: container.value?.clientWidth ?? 400,
height: container.value?.clientHeight ?? 200,
series: seriesOpts,
axes: [
{
stroke: '#555568',
grid: { stroke: '#2e2e3822' },
size: 40,
font: '10px monospace',
ticks: { size: 3 },
},
{
stroke: '#555568',
grid: { stroke: '#2e2e3822' },
size: 35,
font: '10px monospace',
ticks: { size: 3 },
},
],
cursor: { show: true },
legend: { show: true, live: false },
padding: [8, 8, 0, 0],
hooks: {
setScale: [(_self: uPlot, scaleKey: string) => {
if (scaleKey === 'x') zoomed.value = true
}],
},
}
}
function resetZoom() {
if (!chart) return
const data = chart.data
if (data && data[0] && data[0].length > 0) {
const min = data[0][0]
const max = data[0][data[0].length - 1]
chart.setScale('x', { min, max })
}
zoomed.value = false
}
function getLegendHeight(): number {
if (!container.value) return 0
const legend = container.value.querySelector('.u-legend') as HTMLElement | null
return legend ? legend.offsetHeight : 0
}
function createChart() {
if (!container.value) return
if (chart) chart.destroy()
chart = new uPlot(buildOpts(), props.data, container.value)
// Refit after legend renders
nextTick(() => resize())
}
function resize() {
if (!chart || !container.value) return
const legendH = getLegendHeight()
const availableH = container.value.clientHeight
// uPlot height = canvas height (chart sets total = canvas + legend)
const chartH = Math.max(60, availableH - legendH)
chart.setSize({
width: container.value.clientWidth,
height: chartH,
})
}
watch(() => props.data, (newData) => {
if (chart) {
chart.setData(newData)
} else {
nextTick(createChart)
}
}, { deep: true })
onMounted(() => {
nextTick(createChart)
const observer = new ResizeObserver(resize)
if (container.value) observer.observe(container.value)
onUnmounted(() => {
observer.disconnect()
chart?.destroy()
chart = null
})
})
</script>
<template>
<div class="timeseries-wrapper">
<button v-if="zoomed" class="reset-zoom" @click="resetZoom" title="Reset zoom"></button>
<div ref="container" class="timeseries-renderer" />
</div>
</template>
<style scoped>
.timeseries-wrapper {
width: 100%;
height: 100%;
position: relative;
}
.reset-zoom {
position: absolute;
top: 4px;
right: 4px;
z-index: 20;
background: var(--surface-2);
border: 1px solid var(--surface-3);
border-radius: 4px;
color: var(--text-secondary);
font-size: 14px;
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.15s;
}
.reset-zoom:hover {
opacity: 1;
color: var(--text-primary);
}
.timeseries-renderer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* uPlot creates a .u-wrap for canvas + a .u-legend below it */
.timeseries-renderer :deep(.u-wrap) {
flex: 1;
min-height: 0;
}
.timeseries-renderer :deep(.u-legend) {
flex-shrink: 0;
}
.timeseries-renderer :deep(.u-legend) {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
padding: 2px 0;
display: flex;
flex-wrap: wrap;
gap: 0 8px;
}
.timeseries-renderer :deep(.u-legend .u-series) {
display: inline-flex;
padding: 0;
}
</style>

View File

@@ -0,0 +1,59 @@
/* Framework design tokens — retheme by replacing this file */
:root {
/* spacing scale (4px base) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
/* color — dark theme (observability UIs are always dark) */
--surface-0: #0d0d0f;
--surface-1: #16161a;
--surface-2: #1e1e24;
--surface-3: #26262f;
--border: #2e2e38;
--text-primary: #e8e8f0;
--text-secondary: #8888a0;
--text-dim: #555568;
/* status colors */
--status-idle: #555568;
--status-live: #3ecf8e;
--status-processing: #4f9cf9;
--status-escalating: #f5a623;
--status-error: #f06565;
/* confidence color scale (low → high) */
--conf-low: #f06565;
--conf-mid: #f5a623;
--conf-high: #3ecf8e;
/* typography */
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--font-ui: 'Inter', system-ui, sans-serif;
--font-size-sm: 11px;
--font-size-base: 13px;
--font-size-lg: 15px;
/* panel chrome */
--panel-radius: 6px;
--panel-border: 1px solid var(--border);
--panel-header-height: 36px;
}
/* Animated gradient outline for buttons in a waiting state.
Usage: add class="waiting" to any button/element. */
@keyframes waiting-glow {
0% { box-shadow: 0 0 3px 1px var(--status-processing); }
33% { box-shadow: 0 0 3px 1px var(--status-live); }
66% { box-shadow: 0 0 3px 1px var(--status-escalating); }
100% { box-shadow: 0 0 3px 1px var(--status-processing); }
}
.waiting {
animation: waiting-glow 2s linear infinite;
outline: 1px solid transparent;
}