From 7e47a65b72bf2671e216ba567e073dccb97f1a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grabarz?= Date: Mon, 9 Dec 2024 16:29:35 +0100 Subject: [PATCH] Color edges according to source node. (#11810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #11536 image Also fixed incorrect edge offset when dragging an target-connected edge. image (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. --- .prettierignore | 1 + CHANGELOG.md | 2 + .../components/ComponentBrowser.vue | 4 - .../components/GraphEditor/GraphEdge.vue | 324 +++--------------- .../GraphEditor/GraphEdge/layout.ts | 250 ++++++++++++++ .../stores/graph/unconnectedEdges.ts | 2 - 6 files changed, 296 insertions(+), 287 deletions(-) create mode 100644 app/gui/src/project-view/components/GraphEditor/GraphEdge/layout.ts diff --git a/.prettierignore b/.prettierignore index 04a47d3184..f84e714076 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 468eb6e9ba..9a60d9b109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/gui/src/project-view/components/ComponentBrowser.vue b/app/gui/src/project-view/components/ComponentBrowser.vue index c5c4c46b9f..2355948580 100644 --- a/app/gui/src/project-view/components/ComponentBrowser.vue +++ b/app/gui/src/project-view/components/ComponentBrowser.vue @@ -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 diff --git a/app/gui/src/project-view/components/GraphEditor/GraphEdge.vue b/app/gui/src/project-view/components/GraphEditor/GraphEdge.vue index 6539afffd8..dac052bf9e 100644 --- a/app/gui/src/project-view/components/GraphEditor/GraphEdge.vue +++ b/app/gui/src/project-view/components/GraphEditor/GraphEdge.vue @@ -1,4 +1,5 @@ diff --git a/app/gui/src/project-view/components/GraphEditor/GraphEdge/layout.ts b/app/gui/src/project-view/components/GraphEditor/GraphEdge/layout.ts new file mode 100644 index 0000000000..fdd4fd7d71 --- /dev/null +++ b/app/gui/src/project-view/components/GraphEditor/GraphEdge/layout.ts @@ -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 +} diff --git a/app/gui/src/project-view/stores/graph/unconnectedEdges.ts b/app/gui/src/project-view/stores/graph/unconnectedEdges.ts index 8ea16e06f8..54b575e20d 100644 --- a/app/gui/src/project-view/stores/graph/unconnectedEdges.ts +++ b/app/gui/src/project-view/stores/graph/unconnectedEdges.ts @@ -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 }