phase 9
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { Panel, GraphRenderer } from 'mpr-ui-framework'
|
||||
import type { GraphNode, DataSource } from 'mpr-ui-framework'
|
||||
import type { GraphNode, GraphMode, DataSource } from 'mpr-ui-framework'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
import { useStageRegistry } from '../composables/useStageRegistry'
|
||||
|
||||
@@ -15,6 +15,13 @@ const { stageNames, editableStages } = useStageRegistry()
|
||||
|
||||
const nodes = ref<GraphNode[]>([])
|
||||
|
||||
// Derive graph mode from pipeline layout mode
|
||||
const graphMode = computed<GraphMode>(() => {
|
||||
if (pipeline.layoutMode === 'bbox_editor') return 'edit-isolated'
|
||||
if (pipeline.layoutMode === 'stage_editor') return 'edit-in-pipeline'
|
||||
return 'observe'
|
||||
})
|
||||
|
||||
// Initialize nodes from registry when it loads
|
||||
watch(stageNames, (names) => {
|
||||
if (names.length > 0 && nodes.value.length === 0) {
|
||||
@@ -54,6 +61,8 @@ function onOpenStageEditor(stage: string) {
|
||||
<Panel title="Pipeline" :status="status">
|
||||
<GraphRenderer
|
||||
:nodes="nodes"
|
||||
:mode="graphMode"
|
||||
:active-stage="pipeline.editorStage"
|
||||
:region-stages="editableStages"
|
||||
@open-region-editor="onOpenRegionEditor"
|
||||
@open-stage-editor="onOpenStageEditor"
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user