This commit is contained in:
2026-03-28 01:11:31 -03:00
parent 8a90436f33
commit acc99e691d
3 changed files with 144 additions and 22 deletions

View File

@@ -25,7 +25,7 @@ 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 } from './renderers/GraphRenderer.vue'
export type { GraphNode, GraphMode } from './renderers/GraphRenderer.vue'
export type { TableColumn } from './renderers/TableRenderer.vue'
export type { TimeSeriesSeries } from './renderers/TimeSeriesRenderer.vue'

View File

@@ -7,22 +7,38 @@ import '@vue-flow/core/dist/theme-default.css'
export interface GraphNode {
id: string
status: 'pending' | 'running' | 'done' | 'error' | 'skipped'
/** 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[]
}
const props = defineProps<{
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 statusColors: Record<string, string> = {
const STATUS_COLORS: Record<string, string> = {
pending: 'var(--status-idle)',
running: 'var(--status-processing)',
done: 'var(--status-live)',
@@ -30,35 +46,97 @@ const statusColors: Record<string, string> = {
skipped: '#4a6fa5',
}
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,
}
}
}
// 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) => ({
id: n.id,
type: 'stage',
position: { x: 20, y: i * 80 },
data: {
label: n.id.replace(/_/g, ' '),
status: n.status,
color: statusColors[n.status] ?? statusColors.pending,
textColor: '#fff',
hasRegionEditor: regionStageSet.value.has(n.id),
isRunning: n.status === 'running',
},
}))
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,
hasRegionEditor: regionStageSet.value.has(n.id),
hasEditors: (n.availableEditors?.length ?? 0) > 0,
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: '#555568' },
style: {
stroke: isActiveEdge ? 'var(--text-dim)' : '#555568',
strokeDasharray: isActiveEdge ? '4 4' : undefined,
},
})
}
return edges
})
function onNodeClick(id: string) {
emit('node-click', id)
}
</script>
<template>
@@ -75,10 +153,29 @@ const flowEdges = computed(() => {
<template #node-stage="{ data, id }">
<div
class="stage-node"
:class="{ running: data.isRunning }"
:style="{ background: data.color, color: data.textColor }"
:class="{
running: data.isRunning,
active: data.isActive,
outline: data.outline,
dimmed: data.opacity < 1,
}"
: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.hasRegionEditor"
@@ -135,16 +232,32 @@ const flowEdges = computed(() => {
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-label {
flex: 1;
}
.checkpoint-badge {
opacity: 0.7;
display: flex;
align-items: center;
}
.stage-actions {
display: flex;
gap: 2px;