Color edges according to source node. (#11810)

Fixes #11536

<img width="238" alt="image" src="https://github.com/user-attachments/assets/e253b188-a6ce-468f-864c-9e814a8a2584">

Also fixed incorrect edge offset when dragging an target-connected edge.

<img width="140" alt="image" src="https://github.com/user-attachments/assets/d75ac860-c614-4f40-9cbc-b955e94f5a58">
(cursor painted in, because it wasn't captured by the screenshot 😅)

# Important Notes
Split away edge layout code away from the component to separate file. That code is likely to be significantly changed soon, but no significant modifications were made right now.
This commit is contained in:
Paweł Grabarz 2024-12-09 16:29:35 +01:00 committed by GitHub
parent 3e23e72bed
commit 7e47a65b72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 296 additions and 287 deletions

View File

@ -16,6 +16,7 @@ distribution/lib/Standard/*/*/manifest.yaml
distribution/lib/Standard/*/*/polyglot distribution/lib/Standard/*/*/polyglot
distribution/lib/Standard/*/*/THIRD-PARTY distribution/lib/Standard/*/*/THIRD-PARTY
distribution/docs-js distribution/docs-js
docs/**/*.md
built-distribution/ built-distribution/
THIRD-PARTY THIRD-PARTY

View File

@ -55,6 +55,7 @@
be opened on OS X][11755]. be opened on OS X][11755].
- [Fix some UI elements drawing on top of visualization toolbar dropdown - [Fix some UI elements drawing on top of visualization toolbar dropdown
menus][11768]. menus][11768].
- [Edges are now colored based on their source component.][11810]
- [Highlight missing required arguments][11803]. - [Highlight missing required arguments][11803].
- [Arrows in some drop-down buttons are now clearly visible][11800] - [Arrows in some drop-down buttons are now clearly visible][11800]
@ -90,6 +91,7 @@
[11753]: https://github.com/enso-org/enso/pull/11753 [11753]: https://github.com/enso-org/enso/pull/11753
[11761]: https://github.com/enso-org/enso/pull/11761 [11761]: https://github.com/enso-org/enso/pull/11761
[11768]: https://github.com/enso-org/enso/pull/11768 [11768]: https://github.com/enso-org/enso/pull/11768
[11810]: https://github.com/enso-org/enso/pull/11810
[11803]: https://github.com/enso-org/enso/pull/11803 [11803]: https://github.com/enso-org/enso/pull/11803
[11800]: https://github.com/enso-org/enso/pull/11800 [11800]: https://github.com/enso-org/enso/pull/11800

View File

@ -240,10 +240,6 @@ const nodeColor = computed(() => {
} }
return 'var(--node-color-no-type)' return 'var(--node-color-no-type)'
}) })
watchEffect(() => {
if (!graphStore.cbEditedEdge) return
graphStore.cbEditedEdge.color = nodeColor.value
})
const selectedSuggestionIcon = computed(() => { const selectedSuggestionIcon = computed(() => {
return selectedSuggestion.value ? suggestionEntryToIcon(selectedSuggestion.value) : undefined return selectedSuggestion.value ? suggestionEntryToIcon(selectedSuggestion.value) : undefined

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { junctionPoints, pathElements, toSvgPath } from '@/components/GraphEditor/GraphEdge/layout'
import { injectGraphNavigator } from '@/providers/graphNavigator' import { injectGraphNavigator } from '@/providers/graphNavigator'
import { injectGraphSelection } from '@/providers/graphSelection' import { injectGraphSelection } from '@/providers/graphSelection'
import type { Edge } from '@/stores/graph' import type { Edge } from '@/stores/graph'
@ -7,14 +8,13 @@ import { assert } from '@/util/assert'
import { Rect } from '@/util/data/rect' import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import theme from '@/util/theme' import theme from '@/util/theme'
import { clamp } from '@vueuse/core'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
const selection = injectGraphSelection(true) const selection = injectGraphSelection(true)
const navigator = injectGraphNavigator(true) const navigator = injectGraphNavigator(true)
const graph = useGraphStore() const graph = useGraphStore()
const props = defineProps<{ const { edge, maskSource, animateFromSourceHover } = defineProps<{
edge: Edge edge: Edge
maskSource?: boolean maskSource?: boolean
animateFromSourceHover?: boolean animateFromSourceHover?: boolean
@ -27,32 +27,30 @@ const VISIBLE_PORT_MASK_PADDING = 6
const base = ref<SVGPathElement>() const base = ref<SVGPathElement>()
const mouseAnchor = computed(() => 'anchor' in props.edge && props.edge.anchor.type === 'mouse') const mouseAnchor = computed(() => 'anchor' in edge && edge.anchor.type === 'mouse')
const mouseAnchorPos = computed(() => (mouseAnchor.value ? navigator?.sceneMousePos : undefined)) const mouseAnchorPos = computed(() => (mouseAnchor.value ? navigator?.sceneMousePos : undefined))
const hoveredNode = computed(() => (mouseAnchor.value ? selection?.hoveredNode : undefined)) const hoveredNode = computed(() => (mouseAnchor.value ? selection?.hoveredNode : undefined))
const hoveredPort = computed(() => (mouseAnchor.value ? selection?.hoveredPort : undefined)) const hoveredPort = computed(() => (mouseAnchor.value ? selection?.hoveredPort : undefined))
const isSuggestion = computed(() => 'suggestion' in props.edge && props.edge.suggestion) const isSuggestion = computed(() => 'suggestion' in edge && edge.suggestion)
const connectedSourceNode = computed( const connectedSourceNode = computed(() => edge.source && graph.getSourceNodeId(edge.source))
() => props.edge.source && graph.getSourceNodeId(props.edge.source),
)
const sourceNode = computed(() => { const sourceNode = computed(() => {
if (connectedSourceNode.value) { if (connectedSourceNode.value) {
return connectedSourceNode.value return connectedSourceNode.value
} else if (hoveredNode.value != null && props.edge.target) { } else if (hoveredNode.value != null && edge.target) {
// When the source is not set (i.e. edge is dragged), use the currently hovered over expression // When the source is not set (i.e. edge is dragged), use the currently hovered over expression
// as the source, as long as it is an output node or the same node as the target. // as the source, as long as it is an output node or the same node as the target.
const nodeType = graph.db.nodeIdToNode.get(hoveredNode.value)?.type const nodeType = graph.db.nodeIdToNode.get(hoveredNode.value)?.type
const rawTargetNode = graph.getPortNodeId(props.edge.target) const rawTargetNode = graph.getPortNodeId(edge.target)
if (nodeType !== 'output' && hoveredNode.value != rawTargetNode) return hoveredNode.value if (nodeType !== 'output' && hoveredNode.value != rawTargetNode) return hoveredNode.value
} }
return undefined return undefined
}) })
const targetExpr = computed(() => { const targetExpr = computed(() => {
const setTarget = props.edge.target const setTarget = edge.target
if (setTarget) { if (setTarget) {
return setTarget return setTarget
} else if (hoveredNode.value != null && hoveredNode.value !== connectedSourceNode.value) { } else if (hoveredNode.value != null && hoveredNode.value !== connectedSourceNode.value) {
@ -77,8 +75,8 @@ const targetPos = computed<Vec2 | undefined>(() => {
return targetNodeRect.value.pos.add(new Vec2(targetRectRelative.center().x, yAdjustment)) return targetNodeRect.value.pos.add(new Vec2(targetRectRelative.center().x, yAdjustment))
} else if (mouseAnchorPos.value != null) { } else if (mouseAnchorPos.value != null) {
return mouseAnchorPos.value return mouseAnchorPos.value
} else if ('anchor' in props.edge && props.edge.anchor.type === 'fixed') { } else if ('anchor' in edge && edge.anchor.type === 'fixed') {
return props.edge.anchor.scenePos return edge.anchor.scenePos
} else { } else {
return undefined return undefined
} }
@ -92,9 +90,9 @@ const sourceRect = computed<Rect | undefined>(() => {
if (sourceNodeRect.value) { if (sourceNodeRect.value) {
return sourceNodeRect.value return sourceNodeRect.value
} else if ( } else if (
'anchor' in props.edge && 'anchor' in edge &&
props.edge.anchor.type === 'mouse' && edge.anchor.type === 'mouse' &&
props.edge.target != null && edge.target != null &&
mouseAnchorPos.value != null mouseAnchorPos.value != null
) { ) {
return new Rect(mouseAnchorPos.value, Vec2.Zero) return new Rect(mouseAnchorPos.value, Vec2.Zero)
@ -122,7 +120,7 @@ type NodeMask = {
const startsInPort = computed(() => currentJunctionPoints.value?.startsInPort) const startsInPort = computed(() => currentJunctionPoints.value?.startsInPort)
const sourceMask = computed<NodeMask | undefined>(() => { const sourceMask = computed<NodeMask | undefined>(() => {
if (!props.maskSource && !startsInPort.value) return if (!maskSource && !startsInPort.value) return
const nodeRect = sourceNodeRect.value const nodeRect = sourceNodeRect.value
if (!nodeRect) return if (!nodeRect) return
const animProgress = const animProgress =
@ -130,268 +128,28 @@ const sourceMask = computed<NodeMask | undefined>(() => {
(sourceNode.value && graph.nodeHoverAnimations.get(sourceNode.value)) ?? 0 (sourceNode.value && graph.nodeHoverAnimations.get(sourceNode.value)) ?? 0
: 0 : 0
const padding = animProgress * VISIBLE_PORT_MASK_PADDING const padding = animProgress * VISIBLE_PORT_MASK_PADDING
if (!props.maskSource && padding === 0) return if (!maskSource && padding === 0) return
const rect = nodeRect.expand(padding) const rect = nodeRect.expand(padding)
const radius = 16 + padding const radius = 16 + padding
const id = `mask_for_edge_to-${props.edge.target ?? 'unconnected'}` const id = `mask_for_edge_to-${edge.target ?? 'unconnected'}`
return { id, rect, radius } return { id, rect, radius }
}) })
const edgeColor = computed(() => const edgeColor = computed(() =>
'color' in props.edge ? props.edge.color 'color' in edge ? edge.color
: targetNode.value ? graph.db.getNodeColorStyle(targetNode.value)
: sourceNode.value ? graph.db.getNodeColorStyle(sourceNode.value) : sourceNode.value ? graph.db.getNodeColorStyle(sourceNode.value)
: undefined, : undefined,
) )
/** The inputs to the edge state computation. */
interface Inputs {
/**
* The width and height of the node that originates the edge, if any.
* The edge may begin anywhere around the bottom half of the node.
*/
sourceSize: Vec2
/**
* The coordinates of the node input port that is the edge's destination, relative to the source
* position. The edge enters the port from above.
*/
targetOffset: Vec2
}
interface JunctionPoints {
points: Vec2[]
maxRadius: number
startsInPort: boolean
}
function circleIntersection(x: number, r1: number, r2: number): number {
const xNorm = clamp(x, -r2, r1)
return Math.sqrt(r1 * r1 + r2 * r2 - xNorm * xNorm)
}
/**
* Edge layout calculation.
*
* # Corners
*
* ```text
*
* ```
*
* The fundamental unit of edge layout is the [`Corner`]. A corner is a line segment attached to a
* 90° arc. The length of the straight segment, the radius of the arc, and the orientation of the
* shape may vary. Any shape of edge is built from corners.
*
* The shape of a corner can be fully-specified by two points: The horizontal end, and the vertical
* end.
*
* In special cases, a corner may be *trivial*: It may have a radius of zero, in which case either
* the horizontal or vertical end will not be in the usual orientation. The layout algorithm only
* produces trivial corners when the source is directly in line with the target, or in some cases
* when subdividing a corner (see [Partial edges] below).
*
* # Junction points
*
* ```text
* 3
* 1 /
* \
* \ \
* 2 4
* ```
*
* The layout algorithm doesn't directly place corners. The layout algorithm places a sequence of
* junction points--coordinates where two horizontal corner ends or two vertical corner ends meet
* (or just one corner end, at an end of an edge). A series of junction points, always alternating
* horizontal/vertical, has a one-to-one relationship with a sequence of corners.
*/
/**
* Calculate the start and end positions of each 1-corner section composing an edge to the
* given offset. Return the points and the maximum radius that should be used to draw the corners
* connecting them.
*/
function junctionPoints(inputs: Inputs): JunctionPoints | null {
const halfSourceSize = inputs.sourceSize?.scale(0.5) ?? Vec2.Zero
// The maximum x-distance from the source (our local coordinate origin) for the point where the
// edge will begin.
const sourceMaxXOffset = Math.max(halfSourceSize.x, 0)
const attachmentTarget = inputs.targetOffset
const targetWellBelowSource = inputs.targetOffset.y >= theme.edge.min_approach_height
const targetBelowSource = inputs.targetOffset.y > 0
const targetBeyondSource = Math.abs(inputs.targetOffset.x) > sourceMaxXOffset
const horizontalRoomFor3Corners =
targetBeyondSource &&
Math.abs(inputs.targetOffset.x) - sourceMaxXOffset >=
3.0 * (theme.edge.radius - theme.edge.three_corner.max_squeeze)
const horizontalRoomFor3CornersNoSqueeze =
targetBeyondSource &&
Math.abs(inputs.targetOffset.x) - sourceMaxXOffset >=
3.0 * theme.edge.radius + theme.edge.three_corner.radius_max
if (targetWellBelowSource || (targetBelowSource && !horizontalRoomFor3Corners)) {
const innerTheme = theme.edge.one_corner
// The edge can originate anywhere along the length of the node.
const sourceX = clamp(inputs.targetOffset.x, -sourceMaxXOffset, sourceMaxXOffset)
const distanceX = Math.max(Math.abs(inputs.targetOffset.x) - halfSourceSize.x, 0)
const radiusX = innerTheme.radius_x_base + distanceX * innerTheme.radius_x_factor
// The minimum length of straight line there should be at the target end of the edge. This
// is a fixed value, except it is reduced when the target is horizontally very close to the
// edge of the source, so that very short edges are less sharp.
const yAdjustment = Math.min(
Math.abs(inputs.targetOffset.x) - halfSourceSize.x + innerTheme.radius_y_adjustment / 2.0,
innerTheme.radius_y_adjustment,
)
const radiusY = Math.max(Math.abs(inputs.targetOffset.y) - yAdjustment, 0.0)
const maxRadius = Math.min(radiusX, radiusY)
// The radius the edge would have, if the arc portion were as large as possible.
const offsetX = Math.abs(inputs.targetOffset.x - sourceX)
const naturalRadius = Math.min(
Math.abs(inputs.targetOffset.x - sourceX),
Math.abs(inputs.targetOffset.y),
)
let sourceDY = 0
let startsInPort = true
if (naturalRadius > innerTheme.minimum_tangent_exit_radius) {
// Offset the beginning of the edge so that it is normal to the curve of the source node
// at the point that it exits the node.
const radius = Math.min(naturalRadius, maxRadius)
const arcOriginX = Math.abs(inputs.targetOffset.x) - radius
const sourceArcOrigin = halfSourceSize.x - theme.node.corner_radius
const circleOffset = arcOriginX - sourceArcOrigin
const intersection = circleIntersection(circleOffset, theme.node.corner_radius, radius)
sourceDY = -Math.abs(radius - intersection)
} else if (halfSourceSize.y != 0) {
sourceDY = 0 - innerTheme.source_node_overlap
startsInPort = offsetX < innerTheme.minimum_tangent_exit_radius
}
const source = new Vec2(sourceX, sourceDY)
return {
points: [source, inputs.targetOffset],
maxRadius,
startsInPort,
}
} else {
const radiusMax = theme.edge.three_corner.radius_max
// The edge originates from either side of the node.
const signX = Math.sign(inputs.targetOffset.x)
const sourceX = Math.abs(sourceMaxXOffset) * signX
const distanceX = Math.abs(inputs.targetOffset.x - sourceX)
let j0x: number
let j1x: number
let heightAdjustment: number
if (horizontalRoomFor3Corners) {
// J1
// /
//
//
// \
// J0
// Junctions (J0, J1) are in between source and target.
const j0Dx = Math.min(2 * radiusMax, distanceX / 2)
const j1Dx = Math.min(radiusMax, (distanceX - j0Dx) / 2)
j0x = sourceX + Math.abs(j0Dx) * signX
j1x = j0x + Math.abs(j1Dx) * signX
heightAdjustment = radiusMax - j1Dx
} else {
// J1
// /
// J0
// /
//
//
// J0 > source; J0 > J1; J1 > target.
j1x = inputs.targetOffset.x + Math.abs(radiusMax) * signX
const j0BeyondSource = Math.abs(inputs.targetOffset.x) + radiusMax * 2
const j0BeyondTarget = Math.abs(sourceX) + radiusMax
j0x = Math.abs(Math.max(j0BeyondTarget, j0BeyondSource)) * signX
heightAdjustment = 0
}
if (j0x == null || j1x == null || heightAdjustment == null) return null
const top = Math.min(
inputs.targetOffset.y - theme.edge.min_approach_height + heightAdjustment,
0,
)
const source = new Vec2(sourceX, 0)
const j0 = new Vec2(j0x, top / 2)
const j1 = new Vec2(j1x, top)
return {
points: [source, j0, j1, attachmentTarget],
maxRadius: radiusMax,
startsInPort: horizontalRoomFor3CornersNoSqueeze,
}
}
}
type Line = { axis: 'h' | 'v'; length: number }
type Arc = { radius: number; signX: number; signY: number; sweep: 0 | 1 }
type Element = Arc | Line
function pathElements(junctions: JunctionPoints): { start: Vec2; elements: Element[] } {
const elements: Element[] = []
const pushLine = (line: Line) => {
if (line.length === 0) return
const e = elements.pop()
if (e != null) {
if ('axis' in e && e.axis == line.axis) {
e.length += line.length
elements.push(e)
} else {
elements.push(e)
elements.push(line)
}
} else {
elements.push(line)
}
}
const start = junctions.points[0]
if (start == null) return { start: Vec2.Zero, elements: [] }
let prev = start
junctions.points.slice(1).forEach((j, i) => {
const d = j.sub(prev)
const radius = Math.min(junctions.maxRadius, Math.abs(d.x), Math.abs(d.y))
const signX = Math.sign(d.x)
const signY = Math.sign(d.y)
const dx = (Math.abs(d.x) - radius) * signX
const dy = (Math.abs(d.y) - radius) * signY
const h: Line = { axis: 'h', length: dx }
const v: Line = { axis: 'v', length: dy }
const sweep = (signX === signY) === (i % 2 === 0) ? 1 : 0
if (i % 2 == 0) {
pushLine(h)
elements.push({ radius, signX, signY, sweep })
pushLine(v)
} else {
pushLine(v)
elements.push({ radius, signX, signY, sweep })
pushLine(h)
}
prev = j
})
return { start, elements }
}
function render(sourcePos: Vec2, elements: Element[]): string {
let out = `M ${sourcePos.x} ${sourcePos.y}`
for (const e of elements) {
if ('axis' in e) {
out += ` ${e.axis} ${e.length}`
} else {
const dx = e.radius * e.signX
const dy = e.radius * e.signY
out += ` a ${e.radius} ${e.radius} 0 0 ${e.sweep} ${dx} ${dy}`
}
}
return out
}
const sourceOriginPoint = computed(() => { const sourceOriginPoint = computed(() => {
const source = sourceRect.value const source = sourceRect.value
if (source == null) return null if (source == null) return null
const target = targetPos.value const target = targetPos.value
const targetAbove = target != null ? target.y < source.bottom : false const targetAbove = target != null ? target.y < source.bottom : false
const targetAside = target != null ? source.left > target.x || source.right < target.x : false const targetAside = target != null ? source.left > target.x || source.right < target.x : false
const offset = targetAside || targetAbove ? theme.node.corner_radius : 0 const halfSourceHeight = (source.bottom - source.top) * 0.5
const offset =
targetAside || targetAbove ? Math.min(halfSourceHeight, theme.node.corner_radius) : 0
const sourceStartPosY = Math.max(source.top + offset, source.bottom - offset) const sourceStartPosY = Math.max(source.top + offset, source.bottom - offset)
return new Vec2(source.center().x, sourceStartPosY) return new Vec2(source.center().x, sourceStartPosY)
}) })
@ -420,12 +178,10 @@ const basePath = computed(() => {
const { start, elements } = pathElements const { start, elements } = pathElements
const origin = sourceOriginPoint.value const origin = sourceOriginPoint.value
if (origin == null) return undefined if (origin == null) return undefined
return render(origin.add(start), elements) return toSvgPath(origin.add(start), elements)
}) })
const activePath = computed( const activePath = computed(() => hovered.value && edge.source != null && edge.target != null)
() => hovered.value && props.edge.source != null && props.edge.target != null,
)
function lengthTo(path: SVGPathElement, pos: Vec2): number { function lengthTo(path: SVGPathElement, pos: Vec2): number {
const totalLength = path.getTotalLength() const totalLength = path.getTotalLength()
@ -463,7 +219,7 @@ const mouseLocationOnEdge = computed(() => {
const hovered = ref(false) const hovered = ref(false)
const activeStyle = computed(() => { const activeStyle = computed(() => {
if (!hovered.value) return {} if (!hovered.value) return {}
if (props.edge.source == null || props.edge.target == null) return {} if (edge.source == null || edge.target == null) return {}
const distances = mouseLocationOnEdge.value const distances = mouseLocationOnEdge.value
if (distances == null) return {} if (distances == null) return {}
const offset = const offset =
@ -477,6 +233,7 @@ const activeStyle = computed(() => {
}) })
const targetEndIsDimmed = computed(() => { const targetEndIsDimmed = computed(() => {
if (isSuggestion.value) return true
if (!hovered.value) return false if (!hovered.value) return false
const distances = mouseLocationOnEdge.value const distances = mouseLocationOnEdge.value
if (!distances) return false if (!distances) return false
@ -488,9 +245,9 @@ const baseStyle = computed(() => ({ '--node-base-color': edgeColor.value ?? 'tan
function click(event: PointerEvent) { function click(event: PointerEvent) {
const distances = mouseLocationOnEdge.value const distances = mouseLocationOnEdge.value
if (distances == null) return if (distances == null) return
if (!isConnected(props.edge)) return if (!isConnected(edge)) return
if (distances.sourceToMouse < distances.mouseToTarget) graph.disconnectTarget(props.edge, event) if (distances.sourceToMouse < distances.mouseToTarget) graph.disconnectTarget(edge, event)
else graph.disconnectSource(props.edge, event) else graph.disconnectSource(edge, event)
} }
function svgTranslate(offset: Vec2): string { function svgTranslate(offset: Vec2): string {
@ -498,7 +255,7 @@ function svgTranslate(offset: Vec2): string {
} }
const backwardEdgeArrowTransform = computed<string | undefined>(() => { const backwardEdgeArrowTransform = computed<string | undefined>(() => {
if (props.edge.source == null || props.edge.target == null) return if (edge.source == null || edge.target == null) return
const points = currentJunctionPoints.value?.points const points = currentJunctionPoints.value?.points
if (points == null || points.length < 3) return if (points == null || points.length < 3) return
const target = targetPos.value const target = targetPos.value
@ -532,7 +289,7 @@ const arrowPath = [
].join('') ].join('')
const sourceHoverAnimationStyle = computed(() => { const sourceHoverAnimationStyle = computed(() => {
if (!props.animateFromSourceHover || !base.value || !sourceNode.value) return {} if (!animateFromSourceHover || !base.value || !sourceNode.value) return {}
const progress = graph.nodeHoverAnimations.get(sourceNode.value) ?? 0 const progress = graph.nodeHoverAnimations.get(sourceNode.value) ?? 0
if (progress === 1) return {} if (progress === 1) return {}
const currentLength = progress * base.value.getTotalLength() const currentLength = progress * base.value.getTotalLength()
@ -540,6 +297,10 @@ const sourceHoverAnimationStyle = computed(() => {
strokeDasharray: `${currentLength}px 1000000px`, strokeDasharray: `${currentLength}px 1000000px`,
} }
}) })
const baseClass = computed(() => {
return { dimmed: activePath.value || isSuggestion.value }
})
</script> </script>
<template> <template>
@ -575,13 +336,13 @@ const sourceHoverAnimationStyle = computed(() => {
ref="base" ref="base"
:d="basePath" :d="basePath"
class="edge visible" class="edge visible"
:class="{ dimmed: activePath || isSuggestion }" :class="baseClass"
:style="{ ...baseStyle, ...sourceHoverAnimationStyle }" :style="{ ...baseStyle, ...sourceHoverAnimationStyle }"
:data-source-node-id="sourceNode" :data-source-node-id="sourceNode"
:data-target-node-id="targetNode" :data-target-node-id="targetNode"
/> />
<path <path
v-if="isConnected(props.edge)" v-if="isConnected(edge)"
:d="basePath" :d="basePath"
class="edge io clickable" class="edge io clickable"
:data-source-node-id="sourceNode" :data-source-node-id="sourceNode"
@ -599,6 +360,14 @@ const sourceHoverAnimationStyle = computed(() => {
:data-source-node-id="sourceNode" :data-source-node-id="sourceNode"
:data-target-node-id="targetNode" :data-target-node-id="targetNode"
/> />
<path
v-if="arrowTransform"
:transform="arrowTransform"
:d="arrowPath"
class="arrow visible"
:class="{ dimmed: targetEndIsDimmed }"
:style="baseStyle"
/>
<polygon <polygon
v-if="backwardEdgeArrowTransform" v-if="backwardEdgeArrowTransform"
:transform="backwardEdgeArrowTransform" :transform="backwardEdgeArrowTransform"
@ -608,13 +377,6 @@ const sourceHoverAnimationStyle = computed(() => {
:data-source-node-id="sourceNode" :data-source-node-id="sourceNode"
:data-target-node-id="targetNode" :data-target-node-id="targetNode"
/> />
<path
v-if="arrowTransform"
:transform="arrowTransform"
:d="arrowPath"
:class="{ arrow: true, visible: true, dimmed: targetEndIsDimmed }"
:style="baseStyle"
/>
</g> </g>
</template> </template>
</template> </template>

View File

@ -0,0 +1,250 @@
import { Vec2 } from '@/util/data/vec2'
import theme from '@/util/theme'
import { clamp } from '@vueuse/core'
/** The inputs to the edge state computation. */
export interface Inputs {
/**
* The width and height of the node that originates the edge, if any.
* The edge may begin anywhere around the bottom half of the node.
*/
sourceSize: Vec2
/**
* The coordinates of the node input port that is the edge's destination, relative to the source
* position. The edge enters the port from above.
*/
targetOffset: Vec2
}
export interface JunctionPoints {
points: Vec2[]
maxRadius: number
startsInPort: boolean
}
/**
* Edge layout calculation.
*
* # Corners
*
* ```text
*
* ```
*
* The fundamental unit of edge layout is the [`Corner`]. A corner is a line segment attached to a
* 90° arc. The length of the straight segment, the radius of the arc, and the orientation of the
* shape may vary. Any shape of edge is built from corners.
*
* The shape of a corner can be fully-specified by two points: The horizontal end, and the vertical
* end.
*
* In special cases, a corner may be *trivial*: It may have a radius of zero, in which case either
* the horizontal or vertical end will not be in the usual orientation. The layout algorithm only
* produces trivial corners when the source is directly in line with the target, or in some cases
* when subdividing a corner (see [Partial edges] below).
*
* # Junction points
*
* ```text
* 3
* 1 /
* \
* \ \
* 2 4
* ```
*
* The layout algorithm doesn't directly place corners. The layout algorithm places a sequence of
* junction points--coordinates where two horizontal corner ends or two vertical corner ends meet
* (or just one corner end, at an end of an edge). A series of junction points, always alternating
* horizontal/vertical, has a one-to-one relationship with a sequence of corners.
*/
/**
* Calculate the start and end positions of each 1-corner section composing an edge to the
* given offset. Return the points and the maximum radius that should be used to draw the corners
* connecting them.
*/
export function junctionPoints(inputs: Inputs): JunctionPoints | null {
const halfSourceSize = inputs.sourceSize?.scale(0.5) ?? Vec2.Zero
// The maximum x-distance from the source (our local coordinate origin) for the point where the
// edge will begin.
const sourceMaxXOffset = Math.max(halfSourceSize.x, 0)
const attachmentTarget = inputs.targetOffset
const targetWellBelowSource = inputs.targetOffset.y >= theme.edge.min_approach_height
const targetBelowSource = inputs.targetOffset.y > 0
const targetBeyondSource = Math.abs(inputs.targetOffset.x) > sourceMaxXOffset
const horizontalRoomFor3Corners =
targetBeyondSource &&
Math.abs(inputs.targetOffset.x) - sourceMaxXOffset >=
3.0 * (theme.edge.radius - theme.edge.three_corner.max_squeeze)
const horizontalRoomFor3CornersNoSqueeze =
targetBeyondSource &&
Math.abs(inputs.targetOffset.x) - sourceMaxXOffset >=
3.0 * theme.edge.radius + theme.edge.three_corner.radius_max
if (targetWellBelowSource || (targetBelowSource && !horizontalRoomFor3Corners)) {
const innerTheme = theme.edge.one_corner
// The edge can originate anywhere along the length of the node.
const sourceX = clamp(inputs.targetOffset.x, -sourceMaxXOffset, sourceMaxXOffset)
const distanceX = Math.max(Math.abs(inputs.targetOffset.x) - halfSourceSize.x, 0)
const radiusX = innerTheme.radius_x_base + distanceX * innerTheme.radius_x_factor
// The minimum length of straight line there should be at the target end of the edge. This
// is a fixed value, except it is reduced when the target is horizontally very close to the
// edge of the source, so that very short edges are less sharp.
const yAdjustment = Math.min(
Math.abs(inputs.targetOffset.x) - halfSourceSize.x + innerTheme.radius_y_adjustment / 2.0,
innerTheme.radius_y_adjustment,
)
const radiusY = Math.max(Math.abs(inputs.targetOffset.y) - yAdjustment, 0.0)
const maxRadius = Math.min(radiusX, radiusY)
// The radius the edge would have, if the arc portion were as large as possible.
const offsetX = Math.abs(inputs.targetOffset.x - sourceX)
const naturalRadius = Math.min(
Math.abs(inputs.targetOffset.x - sourceX),
Math.abs(inputs.targetOffset.y),
)
let sourceDY = 0
let startsInPort = true
if (naturalRadius > innerTheme.minimum_tangent_exit_radius) {
// Offset the beginning of the edge so that it is normal to the curve of the source node
// at the point that it exits the node.
const radius = Math.min(naturalRadius, maxRadius)
const arcOriginX = Math.abs(inputs.targetOffset.x) - radius
const sourceArcOrigin = halfSourceSize.x - theme.node.corner_radius
const circleOffset = arcOriginX - sourceArcOrigin
const intersection = circleIntersection(circleOffset, theme.node.corner_radius, radius)
sourceDY = -Math.abs(radius - intersection)
} else if (halfSourceSize.y != 0) {
sourceDY = 0 - innerTheme.source_node_overlap
startsInPort = offsetX < innerTheme.minimum_tangent_exit_radius
}
const source = new Vec2(sourceX, sourceDY)
return {
points: [source, inputs.targetOffset],
maxRadius,
startsInPort,
}
} else {
const radiusMax = theme.edge.three_corner.radius_max
// The edge originates from either side of the node.
const signX = Math.sign(inputs.targetOffset.x)
const sourceX = Math.abs(sourceMaxXOffset) * signX
const distanceX = Math.abs(inputs.targetOffset.x - sourceX)
let j0x: number
let j1x: number
let heightAdjustment: number
if (horizontalRoomFor3Corners) {
// J1
// /
// ╭──────╮
// ╭─────╮ │ ▢
// ╰─────╯────╯\
// J0
// Junctions (J0, J1) are in between source and target.
const j0Dx = Math.min(2 * radiusMax, distanceX / 2)
const j1Dx = Math.min(radiusMax, (distanceX - j0Dx) / 2)
j0x = sourceX + Math.abs(j0Dx) * signX
j1x = j0x + Math.abs(j1Dx) * signX
heightAdjustment = radiusMax - j1Dx
} else {
// J1
// /
// ╭──────╮ J0
// ▢ │/
// ╭─────╮ │
// ╰─────╯────╯
// J0 > source; J0 > J1; J1 > target.
j1x = inputs.targetOffset.x + Math.abs(radiusMax) * signX
const j0BeyondSource = Math.abs(inputs.targetOffset.x) + radiusMax * 2
const j0BeyondTarget = Math.abs(sourceX) + radiusMax
j0x = Math.abs(Math.max(j0BeyondTarget, j0BeyondSource)) * signX
heightAdjustment = 0
}
if (j0x == null || j1x == null || heightAdjustment == null) return null
const top = Math.min(
inputs.targetOffset.y - theme.edge.min_approach_height + heightAdjustment,
0,
)
const source = new Vec2(sourceX, 0)
const j0 = new Vec2(j0x, top / 2)
const j1 = new Vec2(j1x, top)
return {
points: [source, j0, j1, attachmentTarget],
maxRadius: radiusMax,
startsInPort: horizontalRoomFor3CornersNoSqueeze,
}
}
}
type Line = { axis: 'h' | 'v'; length: number }
type Arc = { radius: number; signX: number; signY: number; sweep: 0 | 1 }
type Element = Arc | Line
/**
* Convert calculated path junction points to an array of rounded horizontal/vertical path elements.
*/
export function pathElements(junctions: JunctionPoints): { start: Vec2; elements: Element[] } {
const elements: Element[] = []
const pushLine = (line: Line) => {
if (line.length === 0) return
const e = elements.pop()
if (e != null) {
if ('axis' in e && e.axis == line.axis) {
e.length += line.length
elements.push(e)
} else {
elements.push(e)
elements.push(line)
}
} else {
elements.push(line)
}
}
const start = junctions.points[0]
if (start == null) return { start: Vec2.Zero, elements: [] }
let prev = start
junctions.points.slice(1).forEach((j, i) => {
const d = j.sub(prev)
const radius = Math.min(junctions.maxRadius, Math.abs(d.x), Math.abs(d.y))
const signX = Math.sign(d.x)
const signY = Math.sign(d.y)
const dx = (Math.abs(d.x) - radius) * signX
const dy = (Math.abs(d.y) - radius) * signY
const h: Line = { axis: 'h', length: dx }
const v: Line = { axis: 'v', length: dy }
const sweep = (signX === signY) === (i % 2 === 0) ? 1 : 0
if (i % 2 == 0) {
pushLine(h)
elements.push({ radius, signX, signY, sweep })
pushLine(v)
} else {
pushLine(v)
elements.push({ radius, signX, signY, sweep })
pushLine(h)
}
prev = j
})
return { start, elements }
}
function circleIntersection(x: number, r1: number, r2: number): number {
const xNorm = clamp(x, -r2, r1)
return Math.sqrt(r1 * r1 + r2 * r2 - xNorm * xNorm)
}
/**
* Convert a set of generated path elements to svg path syntax representation.
*/
export function toSvgPath(sourcePos: Vec2, elements: Element[]): string {
let out = `M ${sourcePos.x} ${sourcePos.y}`
for (const e of elements) {
if ('axis' in e) {
out += ` ${e.axis} ${e.length}`
} else {
const dx = e.radius * e.signX
const dy = e.radius * e.signY
out += ` a ${e.radius} ${e.radius} 0 0 ${e.sweep} ${dx} ${dy}`
}
}
return out
}

View File

@ -24,8 +24,6 @@ export interface UnconnectedSource extends AnyUnconnectedEdge {
export interface UnconnectedTarget extends AnyUnconnectedEdge { export interface UnconnectedTarget extends AnyUnconnectedEdge {
source: AstId source: AstId
target: undefined target: undefined
/** If true, the target end should be drawn as with a self-argument arrow. */
targetIsSelfArgument?: boolean
/** If true, the edge will be rendered in its dimmed color. */ /** If true, the edge will be rendered in its dimmed color. */
suggestion?: boolean suggestion?: boolean
} }