phase 9
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import { Panel, GraphRenderer } from 'mpr-ui-framework'
|
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 { usePipelineStore } from '../stores/pipeline'
|
||||||
import { useStageRegistry } from '../composables/useStageRegistry'
|
import { useStageRegistry } from '../composables/useStageRegistry'
|
||||||
|
|
||||||
@@ -15,6 +15,13 @@ const { stageNames, editableStages } = useStageRegistry()
|
|||||||
|
|
||||||
const nodes = ref<GraphNode[]>([])
|
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
|
// Initialize nodes from registry when it loads
|
||||||
watch(stageNames, (names) => {
|
watch(stageNames, (names) => {
|
||||||
if (names.length > 0 && nodes.value.length === 0) {
|
if (names.length > 0 && nodes.value.length === 0) {
|
||||||
@@ -54,6 +61,8 @@ function onOpenStageEditor(stage: string) {
|
|||||||
<Panel title="Pipeline" :status="status">
|
<Panel title="Pipeline" :status="status">
|
||||||
<GraphRenderer
|
<GraphRenderer
|
||||||
:nodes="nodes"
|
:nodes="nodes"
|
||||||
|
:mode="graphMode"
|
||||||
|
:active-stage="pipeline.editorStage"
|
||||||
:region-stages="editableStages"
|
:region-stages="editableStages"
|
||||||
@open-region-editor="onOpenRegionEditor"
|
@open-region-editor="onOpenRegionEditor"
|
||||||
@open-stage-editor="onOpenStageEditor"
|
@open-stage-editor="onOpenStageEditor"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export { default as TableRenderer } from './renderers/TableRenderer.vue'
|
|||||||
// Renderer types
|
// Renderer types
|
||||||
export type { FrameBBox, FrameOverlay } from './renderers/FrameRenderer.vue'
|
export type { FrameBBox, FrameOverlay } from './renderers/FrameRenderer.vue'
|
||||||
export type { LogEntry } from './renderers/LogRenderer.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 { TableColumn } from './renderers/TableRenderer.vue'
|
||||||
export type { TimeSeriesSeries } from './renderers/TimeSeriesRenderer.vue'
|
export type { TimeSeriesSeries } from './renderers/TimeSeriesRenderer.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -7,22 +7,38 @@ import '@vue-flow/core/dist/theme-default.css'
|
|||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
id: string
|
id: string
|
||||||
status: 'pending' | 'running' | 'done' | 'error' | 'skipped'
|
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[]
|
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) */
|
/** Stages that have a region editor (bbox/polygon) */
|
||||||
regionStages?: string[]
|
regionStages?: string[]
|
||||||
}>()
|
}>(), {
|
||||||
|
mode: 'observe',
|
||||||
|
activeStage: null,
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'open-region-editor': [stage: string]
|
'open-region-editor': [stage: string]
|
||||||
'open-stage-editor': [stage: string]
|
'open-stage-editor': [stage: string]
|
||||||
|
'node-click': [stage: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const regionStageSet = computed(() => new Set(props.regionStages ?? []))
|
const regionStageSet = computed(() => new Set(props.regionStages ?? []))
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
pending: 'var(--status-idle)',
|
pending: 'var(--status-idle)',
|
||||||
running: 'var(--status-processing)',
|
running: 'var(--status-processing)',
|
||||||
done: 'var(--status-live)',
|
done: 'var(--status-live)',
|
||||||
@@ -30,35 +46,97 @@ const statusColors: Record<string, string> = {
|
|||||||
skipped: '#4a6fa5',
|
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(() =>
|
const flowNodes = computed(() =>
|
||||||
props.nodes.map((n, i) => ({
|
props.nodes.map((n, i) => {
|
||||||
id: n.id,
|
const appearance = nodeAppearance(n)
|
||||||
type: 'stage',
|
return {
|
||||||
position: { x: 20, y: i * 80 },
|
id: n.id,
|
||||||
data: {
|
type: 'stage',
|
||||||
label: n.id.replace(/_/g, ' '),
|
position: { x: 20, y: i * 80 },
|
||||||
status: n.status,
|
data: {
|
||||||
color: statusColors[n.status] ?? statusColors.pending,
|
label: n.id.replace(/_/g, ' '),
|
||||||
textColor: '#fff',
|
status: n.status,
|
||||||
hasRegionEditor: regionStageSet.value.has(n.id),
|
...appearance,
|
||||||
isRunning: n.status === 'running',
|
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 flowEdges = computed(() => {
|
||||||
const edges = []
|
const edges = []
|
||||||
for (let i = 0; i < props.nodes.length - 1; i++) {
|
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({
|
edges.push({
|
||||||
id: `${props.nodes[i].id}->${props.nodes[i + 1].id}`,
|
id: `${props.nodes[i].id}->${props.nodes[i + 1].id}`,
|
||||||
source: props.nodes[i].id,
|
source: props.nodes[i].id,
|
||||||
target: props.nodes[i + 1].id,
|
target: props.nodes[i + 1].id,
|
||||||
animated: props.nodes[i].status === 'running',
|
animated: props.nodes[i].status === 'running',
|
||||||
style: { stroke: '#555568' },
|
style: {
|
||||||
|
stroke: isActiveEdge ? 'var(--text-dim)' : '#555568',
|
||||||
|
strokeDasharray: isActiveEdge ? '4 4' : undefined,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return edges
|
return edges
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function onNodeClick(id: string) {
|
||||||
|
emit('node-click', id)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -75,10 +153,29 @@ const flowEdges = computed(() => {
|
|||||||
<template #node-stage="{ data, id }">
|
<template #node-stage="{ data, id }">
|
||||||
<div
|
<div
|
||||||
class="stage-node"
|
class="stage-node"
|
||||||
:class="{ running: data.isRunning }"
|
:class="{
|
||||||
:style="{ background: data.color, color: data.textColor }"
|
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>
|
<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">
|
<span class="stage-actions">
|
||||||
<button
|
<button
|
||||||
v-if="data.hasRegionEditor"
|
v-if="data.hasRegionEditor"
|
||||||
@@ -135,16 +232,32 @@ const flowEdges = computed(() => {
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-node.running {
|
.stage-node.running {
|
||||||
animation: node-pulse 1.5s infinite;
|
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 {
|
.stage-label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkpoint-badge {
|
||||||
|
opacity: 0.7;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.stage-actions {
|
.stage-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|||||||
Reference in New Issue
Block a user