phase 8
This commit is contained in:
@@ -138,7 +138,7 @@ function onJobStarted(newJobId: string) {
|
|||||||
<!-- === BBOX EDITOR MODE === -->
|
<!-- === BBOX EDITOR MODE === -->
|
||||||
<div v-else-if="pipeline.layoutMode === 'bbox_editor'" class="editor-layout">
|
<div v-else-if="pipeline.layoutMode === 'bbox_editor'" class="editor-layout">
|
||||||
<div class="editor-top">
|
<div class="editor-top">
|
||||||
<SplitPane direction="horizontal" :initial-size="210" size-mode="px" :min="210" :max="350" anchor="second">
|
<SplitPane direction="horizontal" :initial-size="245" size-mode="px" :min="245" :max="350" anchor="second">
|
||||||
<template #first>
|
<template #first>
|
||||||
<FramePanel :source="source" :status="status" :overlays="editorOverlays" :frame-image="currentFrameImage" :editor-boxes="editorBoxes" />
|
<FramePanel :source="source" :status="status" :overlays="editorOverlays" :frame-image="currentFrameImage" :editor-boxes="editorBoxes" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, watch } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { ParameterEditor, useEditorExecution } from 'mpr-ui-framework'
|
||||||
|
import type { ConfigField } from 'mpr-ui-framework'
|
||||||
import {
|
import {
|
||||||
runEdgeDetection,
|
runEdgeDetection,
|
||||||
runEdgeDetectionDebug,
|
runEdgeDetectionDebug,
|
||||||
@@ -8,30 +10,13 @@ import {
|
|||||||
} from '@/cv'
|
} from '@/cv'
|
||||||
import type { EdgeDetectionParams } from '@/cv'
|
import type { EdgeDetectionParams } from '@/cv'
|
||||||
|
|
||||||
interface ConfigField {
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
default: unknown
|
|
||||||
description: string
|
|
||||||
min: number | null
|
|
||||||
max: number | null
|
|
||||||
options: string[] | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** Stage name (e.g. "detect_edges") */
|
|
||||||
stage: string
|
stage: string
|
||||||
/** Job ID (used for server mode fallback) */
|
|
||||||
jobId: string
|
jobId: string
|
||||||
/** Currently displayed frame image (base64 JPEG) */
|
|
||||||
frameImage?: string | null
|
frameImage?: string | null
|
||||||
/** Currently displayed frame sequence number */
|
|
||||||
frameRef?: number | null
|
frameRef?: number | null
|
||||||
/** All checkpoint frames — when provided, Apply runs on the selection range */
|
|
||||||
frames?: Array<{ seq: number; jpeg_b64: string }>
|
frames?: Array<{ seq: number; jpeg_b64: string }>
|
||||||
/** Index into frames[] for selection start (default 0) */
|
|
||||||
selectionStart?: number
|
selectionStart?: number
|
||||||
/** Index into frames[] for selection end (default frames.length - 1) */
|
|
||||||
selectionEnd?: number
|
selectionEnd?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -46,14 +31,10 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const fields = ref<ConfigField[]>([])
|
const fields = ref<ConfigField[]>([])
|
||||||
const values = ref<Record<string, unknown>>({})
|
const values = ref<Record<string, unknown>>({})
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
const regionCount = ref<number | null>(null)
|
const regionCount = ref<number | null>(null)
|
||||||
const debugEnabled = ref(true)
|
const debugEnabled = ref(true)
|
||||||
const autoApply = ref(true) // auto-run on slider change (fast CV); uncheck for heavy stages
|
|
||||||
const execMode = ref<'local' | 'server'>('local')
|
const execMode = ref<'local' | 'server'>('local')
|
||||||
const execTimeMs = ref<number | null>(null)
|
const processingIndex = ref<number | null>(null)
|
||||||
const processingIndex = ref<number | null>(null) // current frame index during multi-frame run
|
|
||||||
|
|
||||||
// Config field defaults for detect_edges (used when API is unavailable)
|
// Config field defaults for detect_edges (used when API is unavailable)
|
||||||
const EDGE_DEFAULTS: ConfigField[] = [
|
const EDGE_DEFAULTS: ConfigField[] = [
|
||||||
@@ -67,8 +48,27 @@ const EDGE_DEFAULTS: ConfigField[] = [
|
|||||||
{ name: 'edge_pair_min_distance', type: 'int', default: 15, description: 'Min pair distance (px)', min: 5, max: 200, options: null },
|
{ name: 'edge_pair_min_distance', type: 'int', default: 15, description: 'Min pair distance (px)', min: 5, max: 200, options: null },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// --- Execution logic (detection-specific) ---
|
||||||
|
|
||||||
|
async function executeDetection() {
|
||||||
|
const hasFrames = props.frames && props.frames.length > 0
|
||||||
|
if (!props.frameImage && !hasFrames) return
|
||||||
|
|
||||||
|
if (execMode.value === 'local') {
|
||||||
|
await runLocal()
|
||||||
|
} else {
|
||||||
|
await runServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
loading, error, autoApply, execTimeMs,
|
||||||
|
apply: applyDetection, onParameterChange,
|
||||||
|
} = useEditorExecution(executeDetection)
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Try loading from API, fall back to hardcoded defaults
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/detect/config/stages/${props.stage}`)
|
const resp = await fetch(`/api/detect/config/stages/${props.stage}`)
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
@@ -85,26 +85,25 @@ onMounted(async () => {
|
|||||||
values.value[f.name] = f.default
|
values.value[f.name] = f.default
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-run on first frame if already available
|
|
||||||
if (props.frameImage) {
|
if (props.frameImage) {
|
||||||
applyDetection()
|
applyDetection()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-run when frame arrives after mount (checkpoint load is async)
|
|
||||||
watch(() => props.frameImage, (newVal, oldVal) => {
|
watch(() => props.frameImage, (newVal, oldVal) => {
|
||||||
if (newVal && !oldVal && fields.value.length > 0) {
|
if (newVal && !oldVal && fields.value.length > 0) {
|
||||||
applyDetection()
|
applyDetection()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-run when selection range changes (strip handle drag)
|
|
||||||
watch([() => props.selectionStart, () => props.selectionEnd], () => {
|
watch([() => props.selectionStart, () => props.selectionEnd], () => {
|
||||||
if (autoApply.value) onSliderChange()
|
if (autoApply.value) onParameterChange()
|
||||||
})
|
})
|
||||||
|
|
||||||
const numericFields = computed(() => fields.value.filter(f => f.type === 'int' || f.type === 'float'))
|
function onFieldUpdate(name: string, value: unknown) {
|
||||||
const boolFields = computed(() => fields.value.filter(f => f.type === 'bool'))
|
values.value[name] = value
|
||||||
|
onParameterChange()
|
||||||
|
}
|
||||||
|
|
||||||
function resetDefaults() {
|
function resetDefaults() {
|
||||||
for (const f of fields.value) {
|
for (const f of fields.value) {
|
||||||
@@ -113,42 +112,9 @@ function resetDefaults() {
|
|||||||
applyDetection()
|
applyDetection()
|
||||||
}
|
}
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
// --- Local CV execution ---
|
||||||
|
|
||||||
function onSliderChange() {
|
|
||||||
if (!autoApply.value) return
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer)
|
|
||||||
debounceTimer = setTimeout(() => applyDetection(), 150)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyDetection() {
|
|
||||||
const hasFrames = props.frames && props.frames.length > 0
|
|
||||||
if (!props.frameImage && !hasFrames) {
|
|
||||||
error.value = 'No frame available'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
execTimeMs.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (execMode.value === 'local') {
|
|
||||||
await runLocal()
|
|
||||||
} else {
|
|
||||||
await runServer()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
error.value = `${execMode.value} failed: ${e}`
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Browser-side CV — runs on current frame or selection range */
|
|
||||||
async function runLocal() {
|
async function runLocal() {
|
||||||
const t0 = performance.now()
|
|
||||||
|
|
||||||
const params: Partial<EdgeDetectionParams> = {
|
const params: Partial<EdgeDetectionParams> = {
|
||||||
cannyLow: values.value['edge_canny_low'] as number,
|
cannyLow: values.value['edge_canny_low'] as number,
|
||||||
cannyHigh: values.value['edge_canny_high'] as number,
|
cannyHigh: values.value['edge_canny_high'] as number,
|
||||||
@@ -159,7 +125,6 @@ async function runLocal() {
|
|||||||
pairMinDistance: values.value['edge_pair_min_distance'] as number,
|
pairMinDistance: values.value['edge_pair_min_distance'] as number,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which frames to process
|
|
||||||
const targetFrames = props.frames && props.frames.length > 0
|
const targetFrames = props.frames && props.frames.length > 0
|
||||||
? props.frames.slice(props.selectionStart ?? 0, (props.selectionEnd ?? props.frames.length - 1) + 1)
|
? props.frames.slice(props.selectionStart ?? 0, (props.selectionEnd ?? props.frames.length - 1) + 1)
|
||||||
: [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage! }]
|
: [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage! }]
|
||||||
@@ -195,19 +160,18 @@ async function runLocal() {
|
|||||||
totalRegions += result.regions.length
|
totalRegions += result.regions.length
|
||||||
regions_by_frame[frameKey] = result.regions
|
regions_by_frame[frameKey] = result.regions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit incrementally so overlays appear per frame, not all at once
|
||||||
|
emit('replay-result', { regions_by_frame: { ...regions_by_frame }, debug: { ...debug }, frameWidth, frameHeight })
|
||||||
}
|
}
|
||||||
|
|
||||||
processingIndex.value = null
|
processingIndex.value = null
|
||||||
execTimeMs.value = Math.round(performance.now() - t0)
|
|
||||||
regionCount.value = totalRegions
|
regionCount.value = totalRegions
|
||||||
|
|
||||||
emit('replay-result', { regions_by_frame, debug, frameWidth, frameHeight })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Server-side CV — calls GPU box via proxy */
|
// --- Server CV execution ---
|
||||||
async function runServer() {
|
|
||||||
const t0 = performance.now()
|
|
||||||
|
|
||||||
|
async function runServer() {
|
||||||
const body: Record<string, unknown> = { image: props.frameImage }
|
const body: Record<string, unknown> = { image: props.frameImage }
|
||||||
for (const f of fields.value) {
|
for (const f of fields.value) {
|
||||||
if (f.name !== 'enabled') {
|
if (f.name !== 'enabled') {
|
||||||
@@ -231,7 +195,6 @@ async function runServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
execTimeMs.value = Math.round(performance.now() - t0)
|
|
||||||
regionCount.value = data.regions?.length ?? 0
|
regionCount.value = data.regions?.length ?? 0
|
||||||
|
|
||||||
const frameKey = String(props.frameRef ?? 0)
|
const frameKey = String(props.frameRef ?? 0)
|
||||||
@@ -260,59 +223,24 @@ async function runServer() {
|
|||||||
<button class="sliders-reset" @click="resetDefaults" title="Reset to defaults">Reset</button>
|
<button class="sliders-reset" @click="resetDefaults" title="Reset to defaults">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Local / Server toggle -->
|
|
||||||
<div class="mode-toggle">
|
<div class="mode-toggle">
|
||||||
<button
|
<button :class="['mode-btn', { active: execMode === 'local' }]" @click="execMode = 'local'">Local</button>
|
||||||
:class="['mode-btn', { active: execMode === 'local' }]"
|
<button :class="['mode-btn', { active: execMode === 'server' }]" @click="execMode = 'server'">Server</button>
|
||||||
@click="execMode = 'local'"
|
|
||||||
>Local</button>
|
|
||||||
<button
|
|
||||||
:class="['mode-btn', { active: execMode === 'server' }]"
|
|
||||||
@click="execMode = 'server'"
|
|
||||||
>Server</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="sliders-error">{{ error }}</div>
|
<div v-if="error" class="sliders-error">{{ error }}</div>
|
||||||
|
|
||||||
<div class="sliders-list">
|
<div class="sliders-list">
|
||||||
<!-- Boolean fields -->
|
<ParameterEditor
|
||||||
<label v-for="f in boolFields" :key="f.name" class="slider-field bool-field">
|
:fields="fields"
|
||||||
<input
|
:values="values"
|
||||||
type="checkbox"
|
@update="onFieldUpdate"
|
||||||
:checked="!!values[f.name]"
|
@reset="resetDefaults"
|
||||||
@change="(e) => { values[f.name] = (e.target as HTMLInputElement).checked; onSliderChange() }"
|
/>
|
||||||
/>
|
|
||||||
<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="slider-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) => { values[f.name] = Number((e.target as HTMLInputElement).value); onSliderChange() }"
|
|
||||||
/>
|
|
||||||
<div class="field-range">
|
|
||||||
<span>{{ f.min ?? 0 }}</span>
|
|
||||||
<span>{{ f.max ?? 500 }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="sliders-footer">
|
<div class="sliders-footer">
|
||||||
<button
|
<button class="apply-btn" :disabled="loading || autoApply" @click="applyDetection">
|
||||||
class="apply-btn"
|
|
||||||
:disabled="loading || autoApply"
|
|
||||||
@click="applyDetection"
|
|
||||||
>
|
|
||||||
{{ loading ? 'Running...' : 'Apply' }}
|
{{ loading ? 'Running...' : 'Apply' }}
|
||||||
</button>
|
</button>
|
||||||
<label class="auto-apply-toggle">
|
<label class="auto-apply-toggle">
|
||||||
@@ -411,79 +339,6 @@ async function runServer() {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sliders-footer {
|
.sliders-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -66,11 +66,13 @@ export function useCheckpointLoader(
|
|||||||
currentFrameRef.value = frame.seq
|
currentFrameRef.value = frame.seq
|
||||||
}
|
}
|
||||||
|
|
||||||
const { checkpointStageFor } = useStageRegistry()
|
const { stages, checkpointStageFor } = useStageRegistry()
|
||||||
|
|
||||||
// Auto-load checkpoint when entering editor mode
|
// Auto-load checkpoint when entering editor mode.
|
||||||
|
// Also watches stages — the registry fetch is async, so on first load
|
||||||
|
// stages may be empty. When they arrive, re-evaluate.
|
||||||
watch(
|
watch(
|
||||||
() => [pipeline.layoutMode, pipeline.editorStage, jobId.value] as const,
|
() => [pipeline.layoutMode, pipeline.editorStage, jobId.value, stages.value.length] as const,
|
||||||
([mode, stage, job]) => {
|
([mode, stage, job]) => {
|
||||||
if (mode === 'bbox_editor' && stage && job) {
|
if (mode === 'bbox_editor' && stage && job) {
|
||||||
const cpStage = checkpointStageFor(stage)
|
const cpStage = checkpointStageFor(stage)
|
||||||
|
|||||||
145
ui/framework/src/components/ParameterEditor.vue
Normal file
145
ui/framework/src/components/ParameterEditor.vue
Normal 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>
|
||||||
@@ -64,9 +64,11 @@ const isHorizontal = computed(() => props.direction === 'horizontal')
|
|||||||
|
|
||||||
const sizedStyle = computed(() => {
|
const sizedStyle = computed(() => {
|
||||||
if (props.sizeMode === 'px') {
|
if (props.sizeMode === 'px') {
|
||||||
|
const sizeStr = size.value + 'px'
|
||||||
|
const minStr = props.min + 'px'
|
||||||
return isHorizontal.value
|
return isHorizontal.value
|
||||||
? { width: size.value + 'px', flexShrink: '0' }
|
? { width: sizeStr, minWidth: minStr, flexShrink: '0' }
|
||||||
: { height: size.value + 'px', flexShrink: '0' }
|
: { height: sizeStr, minHeight: minStr, flexShrink: '0' }
|
||||||
}
|
}
|
||||||
return { flex: String(size.value) }
|
return { flex: String(size.value) }
|
||||||
})
|
})
|
||||||
|
|||||||
57
ui/framework/src/composables/useEditorExecution.ts
Normal file
57
ui/framework/src/composables/useEditorExecution.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,16 @@ export { SSEDataSource } from './datasources/SSEDataSource'
|
|||||||
export { StaticDataSource } from './datasources/StaticDataSource'
|
export { StaticDataSource } from './datasources/StaticDataSource'
|
||||||
export { useDataSource } from './composables/useDataSource'
|
export { useDataSource } from './composables/useDataSource'
|
||||||
export { useRegistry } from './composables/useRegistry'
|
export { useRegistry } from './composables/useRegistry'
|
||||||
|
export { useEditorExecution } from './composables/useEditorExecution'
|
||||||
|
export type { EditorExecutionOptions } from './composables/useEditorExecution'
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
export { default as Panel } from './components/Panel.vue'
|
export { default as Panel } from './components/Panel.vue'
|
||||||
export { default as LayoutGrid } from './components/LayoutGrid.vue'
|
export { default as LayoutGrid } from './components/LayoutGrid.vue'
|
||||||
export { default as ResizeHandle } from './components/ResizeHandle.vue'
|
export { default as ResizeHandle } from './components/ResizeHandle.vue'
|
||||||
export { default as SplitPane } from './components/SplitPane.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
|
// Renderers
|
||||||
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||||
|
|||||||
Reference in New Issue
Block a user