1// Copyright (C) 2024 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import {Point2D, Vector2D} from '../geom'; 16import {assertUnreachable} from '../logging'; 17 18export type CardinalDirection = 'north' | 'south' | 'east' | 'west'; 19 20export type ArrowHeadOrientation = 21 | CardinalDirection 22 | 'auto_vertical' // Either north or south depending on the location of the other end of the arrow 23 | 'auto_horizontal' // Either east or west depending on the location of the other end of the arrow 24 | 'auto'; // Choose the closest cardinal direction depending on the location of the other end of the arrow 25 26export type ArrowHeadShape = 'none' | 'triangle' | 'circle'; 27 28export interface ArrowHeadStyle { 29 orientation: ArrowHeadOrientation; 30 shape: ArrowHeadShape; 31 size?: number; 32} 33 34/** 35 * Renders an curved arrow using a bezier curve. 36 * 37 * This arrow is comprised of a line and the arrow caps are filled shapes, so 38 * the arrow's colour and width will be dictated by the current canvas 39 * strokeStyle, lineWidth, and fillStyle, so adjust these accordingly before 40 * calling this function. 41 * 42 * @param ctx - The canvas to draw on. 43 * @param start - Start point of the arrow. 44 * @param end - End point of the arrow. 45 * @param controlPointOffset - The distance in pixels of the control points from 46 * the start and end points, in the direction of the start and end orientation 47 * values above. 48 * @param startStyle - The style of the start of the arrow. 49 * @param endStyle - The style of the end of the arrow. 50 */ 51export function drawBezierArrow( 52 ctx: CanvasRenderingContext2D, 53 start: Point2D, 54 end: Point2D, 55 controlPointOffset: number = 30, 56 startStyle: ArrowHeadStyle = { 57 shape: 'none', 58 orientation: 'auto', 59 }, 60 endStyle: ArrowHeadStyle = { 61 shape: 'none', 62 orientation: 'auto', 63 }, 64): void { 65 const startOri = getOri(start, end, startStyle.orientation); 66 const endOri = getOri(end, start, endStyle.orientation); 67 68 const startRetreat = drawArrowEnd(ctx, start, startOri, startStyle); 69 const endRetreat = drawArrowEnd(ctx, end, endOri, endStyle); 70 71 const startRetreatVec = orientationToUnitVector(startOri).scale(startRetreat); 72 const endRetreatVec = orientationToUnitVector(endOri).scale(endRetreat); 73 74 const startVec = new Vector2D(start).add(startRetreatVec); 75 const endVec = new Vector2D(end).add(endRetreatVec); 76 77 const startOffset = 78 orientationToUnitVector(startOri).scale(controlPointOffset); 79 const endOffset = orientationToUnitVector(endOri).scale(controlPointOffset); 80 81 const cp1 = startVec.add(startOffset); 82 const cp2 = endVec.add(endOffset); 83 84 ctx.beginPath(); 85 ctx.moveTo(start.x, start.y); 86 ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y); 87 ctx.stroke(); 88} 89 90function getOri( 91 pos: Point2D, 92 other: Point2D, 93 ori: ArrowHeadOrientation, 94): CardinalDirection { 95 switch (ori) { 96 case 'auto_vertical': 97 return other.y > pos.y ? 'south' : 'north'; 98 case 'auto_horizontal': 99 return other.x > pos.x ? 'east' : 'west'; 100 case 'auto': 101 const verticalDelta = Math.abs(other.y - pos.y); 102 const horizontalDelta = Math.abs(other.x - pos.x); 103 if (verticalDelta > horizontalDelta) { 104 return other.y > pos.y ? 'south' : 'north'; 105 } else { 106 return other.x > pos.x ? 'east' : 'west'; 107 } 108 default: 109 return ori; 110 } 111} 112 113function drawArrowEnd( 114 ctx: CanvasRenderingContext2D, 115 pos: Point2D, 116 orientation: CardinalDirection, 117 style: ArrowHeadStyle, 118): number { 119 switch (style.shape) { 120 case 'triangle': 121 const size = style.size ?? 5; 122 drawTriangle(ctx, pos, orientation, size); 123 return size; 124 case 'circle': 125 drawCircle(ctx, pos, style.size ?? 3); 126 return 0; 127 case 'none': 128 return 0; 129 default: 130 assertUnreachable(style.shape); 131 } 132} 133 134function orientationToAngle(orientation: CardinalDirection): number { 135 switch (orientation) { 136 case 'north': 137 return 0; 138 case 'east': 139 return Math.PI / 2; 140 case 'south': 141 return Math.PI; 142 case 'west': 143 return (3 * Math.PI) / 2; 144 default: 145 assertUnreachable(orientation); 146 } 147} 148 149function orientationToUnitVector(orientation: CardinalDirection): Vector2D { 150 switch (orientation) { 151 case 'north': 152 return new Vector2D({x: 0, y: -1}); 153 case 'east': 154 return new Vector2D({x: 1, y: 0}); 155 case 'south': 156 return new Vector2D({x: 0, y: 1}); 157 case 'west': 158 return new Vector2D({x: -1, y: 0}); 159 default: 160 assertUnreachable(orientation); 161 } 162} 163 164function drawTriangle( 165 ctx: CanvasRenderingContext2D, 166 pos: Point2D, 167 orientation: CardinalDirection, 168 size: number, 169) { 170 // Calculate the transformed coordinates directly 171 const angle = orientationToAngle(orientation); 172 const cosAngle = Math.cos(angle); 173 const sinAngle = Math.sin(angle); 174 175 const transformedPoints = [ 176 {x: 0, y: 0}, 177 {x: -1, y: -1}, 178 {x: 1, y: -1}, 179 ].map((point) => { 180 const scaledX = point.x * size; 181 const scaledY = point.y * size; 182 const rotatedX = scaledX * cosAngle - scaledY * sinAngle; 183 const rotatedY = scaledX * sinAngle + scaledY * cosAngle; 184 return { 185 x: rotatedX + pos.x, 186 y: rotatedY + pos.y, 187 }; 188 }); 189 190 ctx.beginPath(); 191 ctx.moveTo(transformedPoints[0].x, transformedPoints[0].y); 192 ctx.lineTo(transformedPoints[1].x, transformedPoints[1].y); 193 ctx.lineTo(transformedPoints[2].x, transformedPoints[2].y); 194 ctx.closePath(); 195 ctx.fill(); 196} 197 198function drawCircle( 199 ctx: CanvasRenderingContext2D, 200 pos: Point2D, 201 radius: number, 202) { 203 ctx.beginPath(); 204 ctx.arc(pos.x, pos.y, radius, 0, 2 * Math.PI); 205 ctx.closePath(); 206 ctx.fill(); 207} 208