xref: /aosp_15_r20/external/perfetto/ui/src/base/canvas/bezier_arrow.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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