| // Copyright (C) 2024 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import {Point2D, Vector2D} from '../geom'; |
| import {assertUnreachable} from '../logging'; |
| |
| export type CardinalDirection = 'north' | 'south' | 'east' | 'west'; |
| |
| export type ArrowHeadOrientation = |
| | CardinalDirection |
| | 'auto_vertical' // Either north or south depending on the location of the other end of the arrow |
| | 'auto_horizontal' // Either east or west depending on the location of the other end of the arrow |
| | 'auto'; // Choose the closest cardinal direction depending on the location of the other end of the arrow |
| |
| export type ArrowHeadShape = 'none' | 'triangle' | 'circle'; |
| |
| export interface ArrowHeadStyle { |
| orientation: ArrowHeadOrientation; |
| shape: ArrowHeadShape; |
| size?: number; |
| } |
| |
| /** |
| * Renders an curved arrow using a bezier curve. |
| * |
| * This arrow is comprised of a line and the arrow caps are filled shapes, so |
| * the arrow's colour and width will be dictated by the current canvas |
| * strokeStyle, lineWidth, and fillStyle, so adjust these accordingly before |
| * calling this function. |
| * |
| * @param ctx - The canvas to draw on. |
| * @param start - Start point of the arrow. |
| * @param end - End point of the arrow. |
| * @param controlPointOffset - The distance in pixels of the control points from |
| * the start and end points, in the direction of the start and end orientation |
| * values above. |
| * @param startStyle - The style of the start of the arrow. |
| * @param endStyle - The style of the end of the arrow. |
| */ |
| export function drawBezierArrow( |
| ctx: CanvasRenderingContext2D, |
| start: Point2D, |
| end: Point2D, |
| controlPointOffset: number = 30, |
| startStyle: ArrowHeadStyle = { |
| shape: 'none', |
| orientation: 'auto', |
| }, |
| endStyle: ArrowHeadStyle = { |
| shape: 'none', |
| orientation: 'auto', |
| }, |
| ): void { |
| const startOri = getOri(start, end, startStyle.orientation); |
| const endOri = getOri(end, start, endStyle.orientation); |
| |
| const startRetreat = drawArrowEnd(ctx, start, startOri, startStyle); |
| const endRetreat = drawArrowEnd(ctx, end, endOri, endStyle); |
| |
| const startRetreatVec = orientationToUnitVector(startOri).scale(startRetreat); |
| const endRetreatVec = orientationToUnitVector(endOri).scale(endRetreat); |
| |
| const startVec = new Vector2D(start).add(startRetreatVec); |
| const endVec = new Vector2D(end).add(endRetreatVec); |
| |
| const startOffset = |
| orientationToUnitVector(startOri).scale(controlPointOffset); |
| const endOffset = orientationToUnitVector(endOri).scale(controlPointOffset); |
| |
| const cp1 = startVec.add(startOffset); |
| const cp2 = endVec.add(endOffset); |
| |
| ctx.beginPath(); |
| ctx.moveTo(start.x, start.y); |
| ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y); |
| ctx.stroke(); |
| } |
| |
| function getOri( |
| pos: Point2D, |
| other: Point2D, |
| ori: ArrowHeadOrientation, |
| ): CardinalDirection { |
| switch (ori) { |
| case 'auto_vertical': |
| return other.y > pos.y ? 'south' : 'north'; |
| case 'auto_horizontal': |
| return other.x > pos.x ? 'east' : 'west'; |
| case 'auto': |
| const verticalDelta = Math.abs(other.y - pos.y); |
| const horizontalDelta = Math.abs(other.x - pos.x); |
| if (verticalDelta > horizontalDelta) { |
| return other.y > pos.y ? 'south' : 'north'; |
| } else { |
| return other.x > pos.x ? 'east' : 'west'; |
| } |
| default: |
| return ori; |
| } |
| } |
| |
| function drawArrowEnd( |
| ctx: CanvasRenderingContext2D, |
| pos: Point2D, |
| orientation: CardinalDirection, |
| style: ArrowHeadStyle, |
| ): number { |
| switch (style.shape) { |
| case 'triangle': |
| const size = style.size ?? 5; |
| drawTriangle(ctx, pos, orientation, size); |
| return size; |
| case 'circle': |
| drawCircle(ctx, pos, style.size ?? 3); |
| return 0; |
| case 'none': |
| return 0; |
| default: |
| assertUnreachable(style.shape); |
| } |
| } |
| |
| function orientationToAngle(orientation: CardinalDirection): number { |
| switch (orientation) { |
| case 'north': |
| return 0; |
| case 'east': |
| return Math.PI / 2; |
| case 'south': |
| return Math.PI; |
| case 'west': |
| return (3 * Math.PI) / 2; |
| default: |
| assertUnreachable(orientation); |
| } |
| } |
| |
| function orientationToUnitVector(orientation: CardinalDirection): Vector2D { |
| switch (orientation) { |
| case 'north': |
| return new Vector2D({x: 0, y: -1}); |
| case 'east': |
| return new Vector2D({x: 1, y: 0}); |
| case 'south': |
| return new Vector2D({x: 0, y: 1}); |
| case 'west': |
| return new Vector2D({x: -1, y: 0}); |
| default: |
| assertUnreachable(orientation); |
| } |
| } |
| |
| function drawTriangle( |
| ctx: CanvasRenderingContext2D, |
| pos: Point2D, |
| orientation: CardinalDirection, |
| size: number, |
| ) { |
| // Calculate the transformed coordinates directly |
| const angle = orientationToAngle(orientation); |
| const cosAngle = Math.cos(angle); |
| const sinAngle = Math.sin(angle); |
| |
| const transformedPoints = [ |
| {x: 0, y: 0}, |
| {x: -1, y: -1}, |
| {x: 1, y: -1}, |
| ].map((point) => { |
| const scaledX = point.x * size; |
| const scaledY = point.y * size; |
| const rotatedX = scaledX * cosAngle - scaledY * sinAngle; |
| const rotatedY = scaledX * sinAngle + scaledY * cosAngle; |
| return { |
| x: rotatedX + pos.x, |
| y: rotatedY + pos.y, |
| }; |
| }); |
| |
| ctx.beginPath(); |
| ctx.moveTo(transformedPoints[0].x, transformedPoints[0].y); |
| ctx.lineTo(transformedPoints[1].x, transformedPoints[1].y); |
| ctx.lineTo(transformedPoints[2].x, transformedPoints[2].y); |
| ctx.closePath(); |
| ctx.fill(); |
| } |
| |
| function drawCircle( |
| ctx: CanvasRenderingContext2D, |
| pos: Point2D, |
| radius: number, |
| ) { |
| ctx.beginPath(); |
| ctx.arc(pos.x, pos.y, radius, 0, 2 * Math.PI); |
| ctx.closePath(); |
| ctx.fill(); |
| } |