blob: c5f0a60679cfcb3a8a7eae9577f349c9b7622f0d [file] [log] [blame]
// 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();
}