mirror of
https://github.com/enso-org/enso.git
synced 2024-12-19 10:31:36 +03:00
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:
parent
3e23e72bed
commit
7e47a65b72
@ -16,6 +16,7 @@ distribution/lib/Standard/*/*/manifest.yaml
|
||||
distribution/lib/Standard/*/*/polyglot
|
||||
distribution/lib/Standard/*/*/THIRD-PARTY
|
||||
distribution/docs-js
|
||||
docs/**/*.md
|
||||
|
||||
built-distribution/
|
||||
THIRD-PARTY
|
||||
|
@ -55,6 +55,7 @@
|
||||
be opened on OS X][11755].
|
||||
- [Fix some UI elements drawing on top of visualization toolbar dropdown
|
||||
menus][11768].
|
||||
- [Edges are now colored based on their source component.][11810]
|
||||
- [Highlight missing required arguments][11803].
|
||||
- [Arrows in some drop-down buttons are now clearly visible][11800]
|
||||
|
||||
@ -90,6 +91,7 @@
|
||||
[11753]: https://github.com/enso-org/enso/pull/11753
|
||||
[11761]: https://github.com/enso-org/enso/pull/11761
|
||||
[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
|
||||
[11800]: https://github.com/enso-org/enso/pull/11800
|
||||
|
||||
|
@ -240,10 +240,6 @@ const nodeColor = computed(() => {
|
||||
}
|
||||
return 'var(--node-color-no-type)'
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!graphStore.cbEditedEdge) return
|
||||
graphStore.cbEditedEdge.color = nodeColor.value
|
||||
})
|
||||
|
||||
const selectedSuggestionIcon = computed(() => {
|
||||
return selectedSuggestion.value ? suggestionEntryToIcon(selectedSuggestion.value) : undefined
|
||||
|
@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { junctionPoints, pathElements, toSvgPath } from '@/components/GraphEditor/GraphEdge/layout'
|
||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||
import type { Edge } from '@/stores/graph'
|
||||
@ -7,14 +8,13 @@ import { assert } from '@/util/assert'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import theme from '@/util/theme'
|
||||
import { clamp } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const selection = injectGraphSelection(true)
|
||||
const navigator = injectGraphNavigator(true)
|
||||
const graph = useGraphStore()
|
||||
|
||||
const props = defineProps<{
|
||||
const { edge, maskSource, animateFromSourceHover } = defineProps<{
|
||||
edge: Edge
|
||||
maskSource?: boolean
|
||||
animateFromSourceHover?: boolean
|
||||
@ -27,32 +27,30 @@ const VISIBLE_PORT_MASK_PADDING = 6
|
||||
|
||||
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 hoveredNode = computed(() => (mouseAnchor.value ? selection?.hoveredNode : 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(
|
||||
() => props.edge.source && graph.getSourceNodeId(props.edge.source),
|
||||
)
|
||||
const connectedSourceNode = computed(() => edge.source && graph.getSourceNodeId(edge.source))
|
||||
|
||||
const sourceNode = computed(() => {
|
||||
if (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
|
||||
// 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 rawTargetNode = graph.getPortNodeId(props.edge.target)
|
||||
const rawTargetNode = graph.getPortNodeId(edge.target)
|
||||
if (nodeType !== 'output' && hoveredNode.value != rawTargetNode) return hoveredNode.value
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const targetExpr = computed(() => {
|
||||
const setTarget = props.edge.target
|
||||
const setTarget = edge.target
|
||||
if (setTarget) {
|
||||
return setTarget
|
||||
} 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))
|
||||
} else if (mouseAnchorPos.value != null) {
|
||||
return mouseAnchorPos.value
|
||||
} else if ('anchor' in props.edge && props.edge.anchor.type === 'fixed') {
|
||||
return props.edge.anchor.scenePos
|
||||
} else if ('anchor' in edge && edge.anchor.type === 'fixed') {
|
||||
return edge.anchor.scenePos
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
@ -92,9 +90,9 @@ const sourceRect = computed<Rect | undefined>(() => {
|
||||
if (sourceNodeRect.value) {
|
||||
return sourceNodeRect.value
|
||||
} else if (
|
||||
'anchor' in props.edge &&
|
||||
props.edge.anchor.type === 'mouse' &&
|
||||
props.edge.target != null &&
|
||||
'anchor' in edge &&
|
||||
edge.anchor.type === 'mouse' &&
|
||||
edge.target != null &&
|
||||
mouseAnchorPos.value != null
|
||||
) {
|
||||
return new Rect(mouseAnchorPos.value, Vec2.Zero)
|
||||
@ -122,7 +120,7 @@ type NodeMask = {
|
||||
|
||||
const startsInPort = computed(() => currentJunctionPoints.value?.startsInPort)
|
||||
const sourceMask = computed<NodeMask | undefined>(() => {
|
||||
if (!props.maskSource && !startsInPort.value) return
|
||||
if (!maskSource && !startsInPort.value) return
|
||||
const nodeRect = sourceNodeRect.value
|
||||
if (!nodeRect) return
|
||||
const animProgress =
|
||||
@ -130,268 +128,28 @@ const sourceMask = computed<NodeMask | undefined>(() => {
|
||||
(sourceNode.value && graph.nodeHoverAnimations.get(sourceNode.value)) ?? 0
|
||||
: 0
|
||||
const padding = animProgress * VISIBLE_PORT_MASK_PADDING
|
||||
if (!props.maskSource && padding === 0) return
|
||||
if (!maskSource && padding === 0) return
|
||||
const rect = nodeRect.expand(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 }
|
||||
})
|
||||
|
||||
const edgeColor = computed(() =>
|
||||
'color' in props.edge ? props.edge.color
|
||||
: targetNode.value ? graph.db.getNodeColorStyle(targetNode.value)
|
||||
'color' in edge ? edge.color
|
||||
: sourceNode.value ? graph.db.getNodeColorStyle(sourceNode.value)
|
||||
: 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 source = sourceRect.value
|
||||
if (source == null) return null
|
||||
const target = targetPos.value
|
||||
const targetAbove = target != null ? target.y < source.bottom : 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)
|
||||
return new Vec2(source.center().x, sourceStartPosY)
|
||||
})
|
||||
@ -420,12 +178,10 @@ const basePath = computed(() => {
|
||||
const { start, elements } = pathElements
|
||||
const origin = sourceOriginPoint.value
|
||||
if (origin == null) return undefined
|
||||
return render(origin.add(start), elements)
|
||||
return toSvgPath(origin.add(start), elements)
|
||||
})
|
||||
|
||||
const activePath = computed(
|
||||
() => hovered.value && props.edge.source != null && props.edge.target != null,
|
||||
)
|
||||
const activePath = computed(() => hovered.value && edge.source != null && edge.target != null)
|
||||
|
||||
function lengthTo(path: SVGPathElement, pos: Vec2): number {
|
||||
const totalLength = path.getTotalLength()
|
||||
@ -463,7 +219,7 @@ const mouseLocationOnEdge = computed(() => {
|
||||
const hovered = ref(false)
|
||||
const activeStyle = computed(() => {
|
||||
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
|
||||
if (distances == null) return {}
|
||||
const offset =
|
||||
@ -477,6 +233,7 @@ const activeStyle = computed(() => {
|
||||
})
|
||||
|
||||
const targetEndIsDimmed = computed(() => {
|
||||
if (isSuggestion.value) return true
|
||||
if (!hovered.value) return false
|
||||
const distances = mouseLocationOnEdge.value
|
||||
if (!distances) return false
|
||||
@ -488,9 +245,9 @@ const baseStyle = computed(() => ({ '--node-base-color': edgeColor.value ?? 'tan
|
||||
function click(event: PointerEvent) {
|
||||
const distances = mouseLocationOnEdge.value
|
||||
if (distances == null) return
|
||||
if (!isConnected(props.edge)) return
|
||||
if (distances.sourceToMouse < distances.mouseToTarget) graph.disconnectTarget(props.edge, event)
|
||||
else graph.disconnectSource(props.edge, event)
|
||||
if (!isConnected(edge)) return
|
||||
if (distances.sourceToMouse < distances.mouseToTarget) graph.disconnectTarget(edge, event)
|
||||
else graph.disconnectSource(edge, event)
|
||||
}
|
||||
|
||||
function svgTranslate(offset: Vec2): string {
|
||||
@ -498,7 +255,7 @@ function svgTranslate(offset: Vec2): string {
|
||||
}
|
||||
|
||||
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
|
||||
if (points == null || points.length < 3) return
|
||||
const target = targetPos.value
|
||||
@ -532,7 +289,7 @@ const arrowPath = [
|
||||
].join('')
|
||||
|
||||
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
|
||||
if (progress === 1) return {}
|
||||
const currentLength = progress * base.value.getTotalLength()
|
||||
@ -540,6 +297,10 @@ const sourceHoverAnimationStyle = computed(() => {
|
||||
strokeDasharray: `${currentLength}px 1000000px`,
|
||||
}
|
||||
})
|
||||
|
||||
const baseClass = computed(() => {
|
||||
return { dimmed: activePath.value || isSuggestion.value }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -575,13 +336,13 @@ const sourceHoverAnimationStyle = computed(() => {
|
||||
ref="base"
|
||||
:d="basePath"
|
||||
class="edge visible"
|
||||
:class="{ dimmed: activePath || isSuggestion }"
|
||||
:class="baseClass"
|
||||
:style="{ ...baseStyle, ...sourceHoverAnimationStyle }"
|
||||
:data-source-node-id="sourceNode"
|
||||
:data-target-node-id="targetNode"
|
||||
/>
|
||||
<path
|
||||
v-if="isConnected(props.edge)"
|
||||
v-if="isConnected(edge)"
|
||||
:d="basePath"
|
||||
class="edge io clickable"
|
||||
:data-source-node-id="sourceNode"
|
||||
@ -599,6 +360,14 @@ const sourceHoverAnimationStyle = computed(() => {
|
||||
:data-source-node-id="sourceNode"
|
||||
:data-target-node-id="targetNode"
|
||||
/>
|
||||
<path
|
||||
v-if="arrowTransform"
|
||||
:transform="arrowTransform"
|
||||
:d="arrowPath"
|
||||
class="arrow visible"
|
||||
:class="{ dimmed: targetEndIsDimmed }"
|
||||
:style="baseStyle"
|
||||
/>
|
||||
<polygon
|
||||
v-if="backwardEdgeArrowTransform"
|
||||
:transform="backwardEdgeArrowTransform"
|
||||
@ -608,13 +377,6 @@ const sourceHoverAnimationStyle = computed(() => {
|
||||
:data-source-node-id="sourceNode"
|
||||
:data-target-node-id="targetNode"
|
||||
/>
|
||||
<path
|
||||
v-if="arrowTransform"
|
||||
:transform="arrowTransform"
|
||||
:d="arrowPath"
|
||||
:class="{ arrow: true, visible: true, dimmed: targetEndIsDimmed }"
|
||||
:style="baseStyle"
|
||||
/>
|
||||
</g>
|
||||
</template>
|
||||
</template>
|
||||
|
@ -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
|
||||
}
|
@ -24,8 +24,6 @@ export interface UnconnectedSource extends AnyUnconnectedEdge {
|
||||
export interface UnconnectedTarget extends AnyUnconnectedEdge {
|
||||
source: AstId
|
||||
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. */
|
||||
suggestion?: boolean
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user