mirror of
https://github.com/OpenBMB/ChatDev.git
synced 2026-04-26 11:48:22 +00:00
722 lines
21 KiB
Vue
Executable File
722 lines
21 KiB
Vue
Executable File
<script setup>
|
|
import { computed, ref, nextTick, watch } from 'vue'
|
|
import { BaseEdge, EdgeLabelRenderer, getBezierPath, getSmoothStepPath, MarkerType } from '@vue-flow/core'
|
|
import { useVueFlow } from '@vue-flow/core'
|
|
import RichTooltip from './RichTooltip.vue'
|
|
import { getEdgeHelp } from '../utils/helpContent.js'
|
|
import { configStore } from '../utils/configStore.js'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
const { findNode } = useVueFlow()
|
|
const { t } = useI18n()
|
|
|
|
const props = defineProps({
|
|
id: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
hoveredNodeId: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
source: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
target: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
sourceX: {
|
|
type: Number,
|
|
required: true,
|
|
},
|
|
sourceY: {
|
|
type: Number,
|
|
required: true,
|
|
},
|
|
targetX: {
|
|
type: Number,
|
|
required: true,
|
|
},
|
|
targetY: {
|
|
type: Number,
|
|
required: true,
|
|
},
|
|
sourcePosition: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
targetPosition: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
data: {
|
|
type: Object,
|
|
default: () => ({})
|
|
},
|
|
markerEnd: {
|
|
type: [String, Object],
|
|
default: MarkerType.ArrowClosed,
|
|
},
|
|
animated: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
style: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
})
|
|
|
|
// Unified hover logic
|
|
const hoverState = computed(() => {
|
|
const hovered = props.hoveredNodeId
|
|
return {
|
|
isEntry: hovered && hovered === props.target,
|
|
isExit: hovered && hovered === props.source,
|
|
isActive: !!(hovered && (hovered === props.target || hovered === props.source))
|
|
}
|
|
})
|
|
|
|
const edgeMarkerEnd = computed(() => {
|
|
const base = (typeof props.markerEnd === 'object' && props.markerEnd !== null)
|
|
? { type: MarkerType.Arrow, width: 18, height: 18, color: '#f2f2f2', strokeWidth: 2, ...props.markerEnd }
|
|
: props.markerEnd
|
|
|
|
const { isEntry, isExit } = hoverState.value
|
|
|
|
if (isEntry) {
|
|
// warm orange marker for incoming (entry) edges
|
|
return { ...(typeof base === 'object' ? base : {}), color: '#ff8a00' }
|
|
}
|
|
if (isExit) {
|
|
// cyan-turquoise marker for outgoing (exit) edges
|
|
return { ...(typeof base === 'object' ? base : {}), color: '#00b8d8' }
|
|
}
|
|
|
|
return base
|
|
})
|
|
|
|
const edgeStyle = computed(() => {
|
|
const baseStyle = {
|
|
stroke: '#f2f2f2',
|
|
strokeWidth: 1.2,
|
|
...props.style,
|
|
}
|
|
|
|
const { isEntry, isExit } = hoverState.value
|
|
|
|
if (isEntry) {
|
|
return {
|
|
...baseStyle,
|
|
// warm yellow -> orange
|
|
stroke: 'url(#incomingEdgeGradient)',
|
|
strokeWidth: 1.4,
|
|
transition: 'stroke 120ms ease, stroke-width 120ms ease',
|
|
}
|
|
}
|
|
|
|
if (isExit) {
|
|
return {
|
|
...baseStyle,
|
|
// cyan -> turquoise
|
|
stroke: 'url(#outgoingEdgeGradient)',
|
|
strokeWidth: 1.4,
|
|
transition: 'stroke 120ms ease, stroke-width 120ms ease',
|
|
}
|
|
}
|
|
|
|
if (props.data?.trigger === false) {
|
|
return {
|
|
...baseStyle,
|
|
stroke: '#868686',
|
|
strokeDasharray: '5, 5',
|
|
animation: 'none',
|
|
}
|
|
}
|
|
|
|
return baseStyle
|
|
})
|
|
|
|
// Ref for the animated overlay edge and animation handles
|
|
const animEdgeRef = ref(null)
|
|
let edgeAnimations = []
|
|
const labelAnimationDuration = ref(0) // Duration in ms for one dash cycle
|
|
|
|
const animEdgeStyle = computed(() => {
|
|
const { isEntry, isExit } = hoverState.value
|
|
|
|
if (isEntry) {
|
|
return {
|
|
stroke: 'url(#incomingEdgeGradient)',
|
|
strokeWidth: 2.3,
|
|
pointerEvents: 'none',
|
|
filter: 'url(#incomingEdgeGlow)',
|
|
}
|
|
}
|
|
|
|
if (isExit) {
|
|
return {
|
|
stroke: 'url(#outgoingEdgeGradient)',
|
|
strokeWidth: 2.3,
|
|
pointerEvents: 'none',
|
|
filter: 'url(#outgoingEdgeGlow)',
|
|
}
|
|
}
|
|
|
|
return { display: 'none' }
|
|
})
|
|
|
|
const isHovered = computed(() => hoverState.value.isActive)
|
|
|
|
watch(isHovered, async (val) => {
|
|
if (val) {
|
|
await nextTick()
|
|
runEdgeAnimation()
|
|
} else {
|
|
cancelEdgeAnimation()
|
|
}
|
|
})
|
|
|
|
function runEdgeAnimation() {
|
|
const pathEl = animEdgeRef.value?.pathEl
|
|
if (!pathEl) return
|
|
|
|
const totalLength = pathEl.getTotalLength()
|
|
const dash = 12
|
|
pathEl.style.strokeDasharray = `${dash} ${Math.round(dash * 2.5)}`
|
|
pathEl.style.strokeDashoffset = '0'
|
|
|
|
const duration = Math.min(Math.max(totalLength * 6, 8000), 8000)
|
|
|
|
// Dash offset animation (infinite loop)
|
|
const dashAnim = pathEl.animate(
|
|
[{ strokeDashoffset: '0' }, { strokeDashoffset: `-${totalLength}` }],
|
|
{ duration: duration * totalLength / 150, iterations: Infinity, easing: 'linear' }
|
|
)
|
|
|
|
// Pulse opacity to enhance glow effect
|
|
const glowAnim = pathEl.animate(
|
|
[{ strokeOpacity: 2 }, { strokeOpacity: 1 }, { strokeOpacity: 2 }],
|
|
{ duration: Math.max(600, Math.round(duration / 2)), iterations: Infinity, easing: 'ease-in-out' }
|
|
)
|
|
|
|
edgeAnimations = [dashAnim, glowAnim]
|
|
|
|
// Calculate label animation duration based on dash cycle speed
|
|
// Dash cycle = dash + gap. Here gap is approx 2.5 * dash.
|
|
// We approximate wavelength as dash + gap ≈ 3.5 * dash
|
|
// Since dash ≈ L/10, Wavelength ≈ 0.35 * L
|
|
// Velocity = L / duration
|
|
// Duration of one wavelength passing = Wavelength / Velocity = (0.35 L) / (L / duration) = 0.35 * duration
|
|
labelAnimationDuration.value = duration * 0.35
|
|
}
|
|
|
|
function cancelEdgeAnimation() {
|
|
edgeAnimations.forEach((a) => a.cancel && a.cancel())
|
|
edgeAnimations = []
|
|
if (animEdgeRef.value?.pathEl) {
|
|
animEdgeRef.value.pathEl.style.strokeDashoffset = '0'
|
|
animEdgeRef.value.pathEl.style.strokeOpacity = '1'
|
|
animEdgeRef.value.pathEl.style.strokeDasharray = ''
|
|
}
|
|
labelAnimationDuration.value = 0
|
|
}
|
|
|
|
function getArcMidpoint({
|
|
startX,
|
|
startY,
|
|
endX,
|
|
endY,
|
|
radiusX,
|
|
radiusY,
|
|
xAxisRotation = 0,
|
|
largeArcFlag = 0,
|
|
sweepFlag = 0,
|
|
}) {
|
|
let rx = Math.abs(radiusX)
|
|
let ry = Math.abs(radiusY)
|
|
|
|
if (!rx || !ry) {
|
|
return null
|
|
}
|
|
|
|
const phi = (xAxisRotation * Math.PI) / 180
|
|
const cosPhi = Math.cos(phi)
|
|
const sinPhi = Math.sin(phi)
|
|
|
|
const dx = (startX - endX) / 2
|
|
const dy = (startY - endY) / 2
|
|
|
|
const x1p = cosPhi * dx + sinPhi * dy
|
|
const y1p = -sinPhi * dx + cosPhi * dy
|
|
|
|
let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
|
|
if (lambda > 1) {
|
|
const scale = Math.sqrt(lambda)
|
|
rx *= scale
|
|
ry *= scale
|
|
}
|
|
|
|
const rxSq = rx * rx
|
|
const rySq = ry * ry
|
|
const x1pSq = x1p * x1p
|
|
const y1pSq = y1p * y1p
|
|
const denom = rxSq * y1pSq + rySq * x1pSq
|
|
|
|
if (denom === 0) {
|
|
return null
|
|
}
|
|
|
|
let factor = (rxSq * rySq - denom) / denom
|
|
factor = Math.max(0, factor)
|
|
|
|
const coef = (largeArcFlag === sweepFlag ? -1 : 1) * Math.sqrt(factor)
|
|
|
|
const cxp = coef * ((rx * y1p) / ry)
|
|
const cyp = coef * (-(ry * x1p) / rx)
|
|
|
|
const cx = cosPhi * cxp - sinPhi * cyp + (startX + endX) / 2
|
|
const cy = sinPhi * cxp + cosPhi * cyp + (startY + endY) / 2
|
|
|
|
const angleBetween = (ux, uy, vx, vy) => Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)
|
|
|
|
const v1x = (x1p - cxp) / rx
|
|
const v1y = (y1p - cyp) / ry
|
|
const v2x = (-x1p - cxp) / rx
|
|
const v2y = (-y1p - cyp) / ry
|
|
|
|
let startAngle = angleBetween(1, 0, v1x, v1y)
|
|
let sweep = angleBetween(v1x, v1y, v2x, v2y)
|
|
|
|
if (!sweepFlag && sweep > 0) {
|
|
sweep -= 2 * Math.PI
|
|
} else if (sweepFlag && sweep < 0) {
|
|
sweep += 2 * Math.PI
|
|
}
|
|
|
|
const midAngle = startAngle + sweep / 2
|
|
|
|
const midX = cosPhi * rx * Math.cos(midAngle) - sinPhi * ry * Math.sin(midAngle) + cx
|
|
const midY = sinPhi * rx * Math.cos(midAngle) + cosPhi * ry * Math.sin(midAngle) + cy
|
|
|
|
if (!Number.isFinite(midX) || !Number.isFinite(midY)) {
|
|
return null
|
|
}
|
|
|
|
return { x: midX, y: midY }
|
|
}
|
|
|
|
// Get the path for the edge
|
|
const path = computed(() => {
|
|
// First obtain source and target node dimensions
|
|
const sourceNode = findNode(props.source)
|
|
const targetNode = findNode(props.target)
|
|
const sourceHeight = sourceNode?.dimensions.height
|
|
const sourceWidth = sourceNode?.dimensions.width
|
|
const targetHeight = targetNode?.dimensions.height
|
|
const targetWidth = targetNode?.dimensions.width
|
|
|
|
// Check if this is a self-loop edge (source === target)
|
|
const isSelfLoop = props.source === props.target
|
|
|
|
// Check if target node is to the left of source node
|
|
const isLeftwardEdge = props.targetX < props.sourceX
|
|
|
|
// Check if path can point from top/bottom of source node to target handle
|
|
const isSourceNodeToTargetHandle = props.targetX >= props.sourceX - sourceWidth / 2
|
|
&& props.targetX < props.sourceX
|
|
|
|
// Check if path can point from source handle to top/bottom of target node
|
|
const isSourceHandleToTargetNode = (props.targetX >= props.sourceX
|
|
&& props.targetX < props.sourceX + sourceWidth / 4) &&
|
|
(Math.abs(props.targetY - props.sourceY) > (targetHeight + sourceHeight) / 2)
|
|
|
|
|
|
// Check if path can point from top/bottom of source node to top/bottom of target node
|
|
const isSourceNodeToTargetNode = props.targetX >= props.sourceX - sourceWidth
|
|
&& props.targetX < props.sourceX - sourceWidth / 2
|
|
|
|
if (isSelfLoop) {
|
|
const startX = props.sourceX - sourceWidth / 6
|
|
const startY = props.sourceY - sourceHeight / 2
|
|
|
|
const endX = props.targetX + targetWidth / 6
|
|
const endY = props.targetY - targetHeight / 2
|
|
|
|
// For self-loop edges, create a circular path using SVG arc
|
|
const radiusX = Math.abs(startX - endX) * 0.2
|
|
const radiusY = 20
|
|
|
|
// Calculate the arc path
|
|
const arcPath = `M ${startX - 5} ${startY} A ${radiusX} ${radiusY} 0 1 0 ${endX + 5} ${endY}`
|
|
|
|
// Calculate label position (center of the arc)
|
|
const labelX = (startX + endX) / 2
|
|
const labelY = Math.min(startY, endY) - radiusY - 20
|
|
|
|
return [arcPath, labelX, labelY]
|
|
}
|
|
|
|
else if (isSourceNodeToTargetNode) {
|
|
let adjustedSourceX = props.sourceX - sourceWidth / 2 - 1
|
|
let adjustedSourceY = props.sourceY
|
|
let adjustedTargetX = props.targetX + targetWidth / 2 + 1
|
|
let adjustedTargetY = props.targetY
|
|
|
|
if (props.targetY > props.sourceY) {
|
|
adjustedSourceY = props.sourceY + sourceHeight / 2
|
|
adjustedTargetY = props.targetY - targetHeight / 2 - 1
|
|
} else {
|
|
adjustedSourceY = props.sourceY - sourceHeight / 2
|
|
adjustedTargetY = props.targetY + targetHeight / 2 + 1
|
|
}
|
|
|
|
return getBezierPath({
|
|
sourceX: adjustedSourceX,
|
|
sourceY: adjustedSourceY,
|
|
targetX: adjustedTargetX,
|
|
targetY: adjustedTargetY,
|
|
sourcePosition: props.sourcePosition,
|
|
targetPosition: props.targetPosition,
|
|
})
|
|
}
|
|
|
|
else if (isSourceNodeToTargetHandle) {
|
|
let adjustedSourceX = props.sourceX - sourceWidth / 2
|
|
let adjustedSourceY = props.sourceY
|
|
|
|
const isTargetBelowSource = props.targetY > props.sourceY
|
|
if (isTargetBelowSource) {
|
|
adjustedSourceY = props.sourceY + sourceHeight / 2
|
|
} else {
|
|
adjustedSourceY = props.sourceY - sourceHeight / 2
|
|
}
|
|
|
|
return getBezierPath({
|
|
sourceX: adjustedSourceX,
|
|
sourceY: adjustedSourceY,
|
|
targetX: props.targetX,
|
|
targetY: props.targetY,
|
|
sourcePosition: props.sourcePosition,
|
|
targetPosition: props.targetPosition,
|
|
})
|
|
}
|
|
|
|
else if (isSourceHandleToTargetNode) {
|
|
let adjustedTargetX = props.targetX + targetWidth / 2
|
|
let adjustedTargetY = props.targetY
|
|
|
|
const isTargetBelowSource = props.targetY > props.sourceY
|
|
if (isTargetBelowSource) {
|
|
adjustedTargetY = props.targetY - targetHeight / 2
|
|
} else {
|
|
adjustedTargetY = props.targetY + targetHeight / 2
|
|
}
|
|
|
|
return getBezierPath({
|
|
sourceX: props.sourceX,
|
|
sourceY: props.sourceY,
|
|
targetX: adjustedTargetX,
|
|
targetY: adjustedTargetY,
|
|
sourcePosition: props.sourcePosition,
|
|
targetPosition: props.targetPosition,
|
|
})
|
|
}
|
|
|
|
else if (isLeftwardEdge) {
|
|
// Determine clockwise (1) or counterclockwise (0) based on vertical position
|
|
const isClockwise = props.targetY > props.sourceY
|
|
|
|
// Adjust coordinates based on clockwise direction
|
|
let adjustedSourceX = props.sourceX
|
|
let adjustedSourceY = props.sourceY
|
|
let adjustedTargetX = props.targetX
|
|
let adjustedTargetY = props.targetY
|
|
|
|
if (isClockwise) {
|
|
adjustedSourceX = props.sourceX - sourceWidth / 2.05
|
|
adjustedSourceY = props.sourceY + sourceHeight / 2
|
|
adjustedTargetX = props.targetX + targetWidth / 2.05
|
|
adjustedTargetY = props.targetY + targetHeight / 2
|
|
} else {
|
|
adjustedSourceX = props.sourceX - sourceWidth / 2.05
|
|
adjustedSourceY = props.sourceY - sourceHeight / 2
|
|
adjustedTargetX = props.targetX + targetWidth / 2.05
|
|
adjustedTargetY = props.targetY - targetHeight / 2
|
|
}
|
|
|
|
const radiusMultiplier = (isLeftwardEdge && !isClockwise) ? 1.1 : 1.1
|
|
const radiusX = Math.abs(adjustedSourceX - adjustedTargetX) * 0.55 * radiusMultiplier
|
|
const radiusY = Math.abs(adjustedSourceY - adjustedTargetY) * 0.70 * radiusMultiplier + 110
|
|
|
|
const sweepFlag = isClockwise ? 1 : 0
|
|
|
|
// Calculate the arc path
|
|
const arcPath = `M ${adjustedSourceX} ${adjustedSourceY} A ${radiusX} ${radiusY} 1 0 ${sweepFlag} ${adjustedTargetX} ${adjustedTargetY}`
|
|
|
|
const fallbackLabelX = (adjustedSourceX + adjustedTargetX) / 2
|
|
const fallbackLabelY = (adjustedSourceY + adjustedTargetY) / 2
|
|
|
|
const arcMidpoint = getArcMidpoint({
|
|
startX: adjustedSourceX,
|
|
startY: adjustedSourceY,
|
|
endX: adjustedTargetX,
|
|
endY: adjustedTargetY,
|
|
radiusX,
|
|
radiusY,
|
|
xAxisRotation: 1,
|
|
largeArcFlag: 0,
|
|
sweepFlag,
|
|
})
|
|
|
|
const labelX = arcMidpoint?.x ?? fallbackLabelX
|
|
const labelY = arcMidpoint?.y ?? fallbackLabelY
|
|
|
|
return [arcPath, labelX, labelY]
|
|
}
|
|
|
|
// Default to bezier path for non-loopback and non-leftward edges
|
|
return getBezierPath({
|
|
sourceX: props.sourceX,
|
|
sourceY: props.sourceY,
|
|
targetX: props.targetX,
|
|
targetY: props.targetY,
|
|
sourcePosition: props.sourcePosition,
|
|
targetPosition: props.targetPosition,
|
|
})
|
|
})
|
|
|
|
// Extract path components from computed result
|
|
const edgePath = computed(() => path.value[0])
|
|
const labelX = computed(() => path.value[1])
|
|
const labelY = computed(() => path.value[2])
|
|
|
|
// Get edge label text depending on function/keyword
|
|
const edgeLabel = computed(() => {
|
|
const condition = props.data?.condition
|
|
if (condition === undefined || condition === null) {
|
|
return ''
|
|
}
|
|
|
|
if (typeof condition === 'object' && condition !== null && condition.type) {
|
|
if (condition.type === 'function') {
|
|
// Don't show label for default condition
|
|
const name = condition.config?.name || ''
|
|
if (name === 'true') {
|
|
return ''
|
|
}
|
|
return name
|
|
} else if (condition.type === 'keyword') {
|
|
// Format keyword condition: "Includes:", "Excludes:", "Regex:"
|
|
const parts = []
|
|
const config = condition.config || {}
|
|
|
|
if (config.any && Array.isArray(config.any) && config.any.length > 0) {
|
|
parts.push(t('components.workflow_edge.includes', { values: config.any.join(', ') }))
|
|
}
|
|
if (config.none && Array.isArray(config.none) && config.none.length > 0) {
|
|
parts.push(t('components.workflow_edge.excludes', { values: config.none.join(', ') }))
|
|
}
|
|
if (config.regex && Array.isArray(config.regex) && config.regex.length > 0) {
|
|
parts.push(t('components.workflow_edge.regex', { values: config.regex.join(', ') }))
|
|
}
|
|
|
|
return parts.join('\n')
|
|
}
|
|
}
|
|
|
|
return ''
|
|
})
|
|
|
|
const edgeLabelKey = computed(() => `${props.id}-${edgeLabel.value}`)
|
|
|
|
const labelStyle = computed(() => {
|
|
// Base style
|
|
const s = {
|
|
position: 'absolute',
|
|
transform: `translate(-50%, -50%) translate(${labelX.value}px, ${labelY.value}px)`,
|
|
pointerEvents: 'none',
|
|
backgroundColor: 'rgba(10, 10, 10, 0.9)',
|
|
color: 'rgba(240, 240, 240, 1)',
|
|
padding: '4px 6px',
|
|
borderRadius: '3px',
|
|
border: '1px solid #f2f2f2',
|
|
fontSize: '12px',
|
|
whiteSpace: 'pre-line',
|
|
textAlign: 'center',
|
|
lineHeight: '1.4',
|
|
transition: 'border-color 200ms ease, box-shadow 200ms ease, color 200ms ease',
|
|
}
|
|
|
|
const { isEntry, isExit } = hoverState.value
|
|
|
|
if (isEntry) {
|
|
s.borderColor = '#ff8a00'
|
|
s.color = '#ffdec2'
|
|
} else if (isExit) {
|
|
s.borderColor = '#00b8d8'
|
|
s.color = '#c2f8ff'
|
|
}
|
|
|
|
// Inject CSS var for animation duration
|
|
if (labelAnimationDuration.value > 0) {
|
|
s['--label-anim-duration'] = `${labelAnimationDuration.value}ms`
|
|
}
|
|
|
|
return s
|
|
})
|
|
|
|
const edgeHelpContent = computed(() => getEdgeHelp(props.data))
|
|
|
|
// Compute tooltip position (geometric midpoint of edge path)
|
|
const tooltipX = computed(() => {
|
|
const sourceNode = findNode(props.source)
|
|
const targetNode = findNode(props.target)
|
|
const isSelfLoop = props.source === props.target
|
|
const isLeftwardEdge = props.targetX < props.sourceX
|
|
|
|
// For self-loops and leftward edges with arc paths, use arc midpoint calculation
|
|
if (isSelfLoop || isLeftwardEdge) {
|
|
// Return labelX for arcs (already computed as arc midpoint)
|
|
return labelX.value
|
|
}
|
|
|
|
// For bezier paths, compute midpoint at t=0.5
|
|
return (props.sourceX + props.targetX) / 2
|
|
})
|
|
|
|
const tooltipY = computed(() => {
|
|
const sourceNode = findNode(props.source)
|
|
const targetNode = findNode(props.target)
|
|
const isSelfLoop = props.source === props.target
|
|
const isLeftwardEdge = props.targetX < props.sourceX
|
|
|
|
// For self-loops and leftward edges with arc paths, use arc midpoint calculation
|
|
if (isSelfLoop || isLeftwardEdge) {
|
|
// Return labelY for arcs (already computed as arc midpoint)
|
|
return labelY.value
|
|
}
|
|
|
|
// For bezier paths, compute midpoint at t=0.5
|
|
return (props.sourceY + props.targetY) / 2
|
|
})
|
|
|
|
const shouldShowTooltip = computed(() => configStore.ENABLE_HELP_TOOLTIPS)
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<!-- Invisible SVG defs to provide gradients for edge strokes -->
|
|
<svg style="position: absolute; width: 0; height: 0; overflow: hidden;" aria-hidden="true" focusable="false">
|
|
<defs>
|
|
<linearGradient id="incomingEdgeGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stop-color="#FFD97A" />
|
|
<stop offset="60%" stop-color="#FFB84D" />
|
|
<stop offset="100%" stop-color="#FF6A00" />
|
|
</linearGradient>
|
|
<linearGradient id="outgoingEdgeGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stop-color="#00F5D4" />
|
|
<stop offset="60%" stop-color="#00C7E6" />
|
|
<stop offset="100%" stop-color="#00A0FF" />
|
|
</linearGradient>
|
|
<filter id="incomingEdgeGlow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feGaussianBlur in="SourceAlpha" stdDeviation="6" result="blur"/>
|
|
<feFlood flood-color="#FF8A00" flood-opacity="0.65" result="color"/>
|
|
<feComposite in="color" in2="blur" operator="in" result="glow"/>
|
|
<feMerge>
|
|
<feMergeNode in="glow"/>
|
|
<feMergeNode in="SourceGraphic"/>
|
|
</feMerge>
|
|
</filter>
|
|
<filter id="outgoingEdgeGlow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feGaussianBlur in="SourceAlpha" stdDeviation="6" result="blur"/>
|
|
<feFlood flood-color="#00B8D8" flood-opacity="0.65" result="color"/>
|
|
<feComposite in="color" in2="blur" operator="in" result="glow"/>
|
|
<feMerge>
|
|
<feMergeNode in="glow"/>
|
|
<feMergeNode in="SourceGraphic"/>
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
</svg>
|
|
<BaseEdge
|
|
:id="id"
|
|
:path="edgePath"
|
|
:marker-end="edgeMarkerEnd"
|
|
:style="edgeStyle"
|
|
:animated=false
|
|
/>
|
|
<!-- Animated overlay edge (on top of base) -->
|
|
<BaseEdge
|
|
ref="animEdgeRef"
|
|
:id="`${id}-anim`"
|
|
:path="edgePath"
|
|
:style="animEdgeStyle"
|
|
:animated="false"
|
|
class="nodrag nopan"
|
|
/>
|
|
<!-- Tooltip-enabled hover area at edge midpoint -->
|
|
<EdgeLabelRenderer v-if="shouldShowTooltip">
|
|
<RichTooltip :content="edgeHelpContent" placement="top">
|
|
<div
|
|
:style="{
|
|
position: 'absolute',
|
|
transform: `translate(-50%, -50%) translate(${tooltipX}px, ${tooltipY}px)`,
|
|
pointerEvents: 'all',
|
|
width: '20px',
|
|
height: '20px',
|
|
borderRadius: '50%',
|
|
cursor: 'pointer'
|
|
}"
|
|
class="edge-tooltip-trigger"
|
|
/>
|
|
</RichTooltip>
|
|
</EdgeLabelRenderer>
|
|
<EdgeLabelRenderer v-if="edgeLabel">
|
|
<div
|
|
:key="edgeLabelKey"
|
|
:style="labelStyle"
|
|
:class="{ 'animated-label': labelAnimationDuration > 0, 'nodrag': true, 'nopan': true }"
|
|
>
|
|
{{ edgeLabel }}
|
|
</div>
|
|
</EdgeLabelRenderer>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@keyframes edge-dash {
|
|
to { stroke-dashoffset: -20; }
|
|
}
|
|
|
|
@keyframes edge-glow {
|
|
0%, 100% { stroke-opacity: 1; }
|
|
50% { stroke-opacity: 0.65; }
|
|
}
|
|
|
|
@keyframes label-pulse {
|
|
0%, 100% {
|
|
box-shadow: 0 0 1px inset rgba(255, 255, 255, 0);
|
|
background-color: rgba(10, 10, 10, 0.95);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 8px inset currentColor;
|
|
background-color: rgba(10, 10, 10, 0.95);
|
|
}
|
|
}
|
|
|
|
.animated-label {
|
|
animation: label-pulse var(--label-anim-duration) infinite linear;
|
|
}
|
|
|
|
.edge-tooltip-trigger {
|
|
background: transparent;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.edge-tooltip-trigger:hover {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
</style>
|