Skip to content

Commit

Permalink
Add Line widget arrow config, update uuid and update tests
Browse files Browse the repository at this point in the history
  • Loading branch information
abigailalexander committed Feb 1, 2024
1 parent 94af4ba commit 6f998e0
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 17 deletions.
3 changes: 2 additions & 1 deletion src/ui/widgets/EmbeddedDisplay/opiParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,8 @@ export const OPI_SIMPLE_PARSERS: ParserDict = {
cornerWidth: ["corner_width", opiParseString],
cornerHeight: ["corner_height", opiParseString],
arrows: ["arrows", opiParseNumber],
arrowLength: ["arrow_length", opiParseNumber]
arrowLength: ["arrow_length", opiParseNumber],
fillArrow: ["fill_arrow", opiParseBoolean]
};

/**
Expand Down
81 changes: 74 additions & 7 deletions src/ui/widgets/Line/line.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("<LineComponent />", (): void => {
expect(lines[0].props.points).toEqual("1,10 15,20 4,15 ");
});

test("props override default properties", (): void => {
test("props override default properties, no arrows", (): void => {
const lineProps = {
width: 30,
height: 20,
Expand All @@ -75,8 +75,39 @@ describe("<LineComponent />", (): void => {
{ x: 4, y: 15 }
]
},
arrows: 0
};

const svg = LineRenderer(lineProps);
expect(svg.props.viewBox).toEqual("0 0 30 20");

const lines = svg.children as Array<ReactTestRendererJSON>;

expect(lines[0].props.stroke).toEqual("rgba(0,0,0,0)");
expect(lines[0].props.strokeWidth).toEqual(15);
expect(lines[0].props.transform).toEqual("rotation(45,0,0)");
expect(lines[0].props.points).toEqual("16,4 25,10 4,15 ");
});

test("line with filled arrowhead", (): void => {
const lineProps = {
width: 30,
height: 20,
lineWidth: 15,
backgroundColor: Color.fromRgba(0, 254, 250),
transparent: true,
rotationAngle: 45,
visible: true,
points: {
values: [
{ x: 16, y: 5 },
{ x: 26, y: 10 },
{ x: 6, y: 15 }
]
},
arrows: 3,
arrowLength: 10
arrowLength: 2,
fillArrow: true
};

const svg = LineRenderer(lineProps);
Expand All @@ -86,16 +117,52 @@ describe("<LineComponent />", (): void => {
const marker = lines[0].children as Array<ReactTestRendererJSON>;

// Check arrowhead definitions were created
expect(marker[0].props.markerWidth).toEqual("10");
expect(marker[0].props.markerHeight).toEqual("10");
expect(marker[0].props.markerWidth).toEqual("2");
expect(marker[0].props.markerHeight).toEqual("2");
expect(marker[0].props.orient).toEqual("auto-start-reverse");
expect(marker[0].props.markerUnits).toEqual("userSpaceOnUse");

expect(lines[1].props).toHaveProperty("markerStart");
expect(lines[1].props).toHaveProperty("markerEnd");
expect(lines[1].props.points).toEqual("18,6 26,10 8,15 ");
});

test("line with unfilled arrowhead", (): void => {
const lineProps = {
width: 30,
height: 20,
lineWidth: 2,
backgroundColor: Color.fromRgba(0, 254, 250),
transparent: true,
rotationAngle: 45,
visible: true,
points: {
values: [
{ x: 40, y: 10 },
{ x: 40, y: 20 },
{ x: 20, y: 20 }
]
},
arrows: 1,
arrowLength: 2,
fillArrow: false
};

const svg = LineRenderer(lineProps);
expect(svg.props.viewBox).toEqual("0 0 30 20");

const lines = svg.children as Array<ReactTestRendererJSON>;
const marker = lines[0].children as Array<ReactTestRendererJSON>;

// Check arrowhead definitions were created
expect(marker[0].props.markerWidth).toEqual("1");
expect(marker[0].props.markerHeight).toEqual("1");
expect(marker[0].props.orient).toEqual("auto-start-reverse");
expect(marker[0].props.markerUnits).toEqual("strokeWidth");

expect(lines[1].props).toHaveProperty("markerStart");
expect(lines[1].props.stroke).toEqual("rgba(0,0,0,0)");
expect(lines[1].props.strokeWidth).toEqual(15);
expect(lines[1].props.transform).toEqual("rotation(45,0,0)");
expect(lines[1].props.points).toEqual("16,4 25,10 4,15 ");
expect(lines[1].props.points).toEqual("40,10 40,20 20,20 ");
});

test("line component not created if no points to plot", (): void => {
Expand Down
100 changes: 91 additions & 9 deletions src/ui/widgets/Line/line.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from "react";
import { v4 as uuidv4 } from "uuid";
import { Widget } from "../widget";
import { PVWidgetPropType, PVComponent } from "../widgetProps";
import {
Expand All @@ -23,7 +24,8 @@ const LineProps = {
transparent: BoolPropOpt,
rotationAngle: FloatPropOpt,
arrows: FloatPropOpt,
arrowLength: FloatPropOpt
arrowLength: FloatPropOpt,
fillArrow: BoolPropOpt
};

export type LineComponentProps = InferWidgetProps<typeof LineProps> &
Expand All @@ -40,57 +42,100 @@ export const LineComponent = (props: LineComponentProps): JSX.Element => {
lineWidth = 1,
points,
arrowLength = 2,
arrows = 0
arrows = 0,
fillArrow
} = props;

const transform = `rotation(${rotationAngle},0,0)`;

// Each marker definition needs a unique ID or colours overlap
// Get random decimal number, take decimal part and truncate
const uid = String(Math.random()).split(".")[1].substring(0, 6);
const uid = uuidv4();

// Create a marker if arrows set
let arrowSize = "";
let arrowConfig = {};
let markerConfig = <></>;
const linePoints = points ? points.values : [];
if (arrows) {
arrowSize = `M 0 0 L ${arrowLength} ${arrowLength / 4} L 0 ${
arrowLength / 2
} z`;
} ${fillArrow ? "z" : ""}`; // add z to close path if filling
switch (arrows) {
// Arrow from
case 1:
arrowConfig = { markerStart: `url(#arrow${uid})` };
// If filled, shorten line length to prevent overlap
if (fillArrow) {
linePoints[0] = recalculateLineLength(
linePoints[1],
linePoints[0],
arrowLength
);
}
break;

// Arrow to
case 2:
arrowConfig = { markerEnd: `url(#arrow${uid})` };
// If filled, shorten line length to prevent overlap
if (fillArrow) {
linePoints[linePoints.length - 1] = recalculateLineLength(
linePoints[linePoints.length - 2],
linePoints[linePoints.length - 1],
arrowLength
);
}
break;

// Arrow both
case 3:
arrowConfig = {
markerStart: `url(#arrow${uid})`,
markerEnd: `url(#arrow${uid})`
};
if (fillArrow) {
linePoints[linePoints.length - 1] = recalculateLineLength(
linePoints[linePoints.length - 2],
linePoints[linePoints.length - 1],
arrowLength
);
linePoints[0] = recalculateLineLength(
linePoints[1],
linePoints[0],
arrowLength
);
}
break;
}
markerConfig = (
<defs>
<marker
id={`arrow${uid}`}
viewBox={`0 0 ${arrowLength} ${arrowLength}`}
refX={`${arrowLength - 2}`}
refX={fillArrow ? 0 : arrowLength}
refY={`${arrowLength / 4}`}
markerWidth={`${arrowLength}`}
markerHeight={`${arrowLength}`}
markerWidth={
fillArrow ? `${arrowLength}` : `${arrowLength / lineWidth}`
}
markerHeight={
fillArrow ? `${arrowLength}` : `${arrowLength / lineWidth}`
}
orient="auto-start-reverse"
markerUnits={props.fillArrow ? "userSpaceOnUse" : "strokeWidth"}
overflow={"visible"}
>
<path
d={arrowSize}
stroke={backgroundColor?.toString()}
fill={backgroundColor?.toString()}
fill={fillArrow ? backgroundColor?.toString() : "none"}
strokeWidth={fillArrow ? 2 : lineWidth}
overflow={"visible"}
/>
</marker>
</defs>
);
}
// 0 is none, 1 is from (marker start), 2 is to (marker end), 3 is both

let coordinates = "";
if (points !== undefined && visible) {
Expand Down Expand Up @@ -124,6 +169,43 @@ export const LineComponent = (props: LineComponentProps): JSX.Element => {
}
};

/**
* Recalculate the length of the line when a filled arrow
* is present. This prevents the line from obscuring the
* arrow. SVG does not include any easy way to layer an arrow
* on top of a line without having the line show if it is thicker
* than the arrow width, so we shorten the line to prevent this.
* Calculate hypoteneuse of line and shorten by arrow length, then map
* this change to the x, y coordinates
* @param startPoint the first set of x,y coordinates of line segment
* @param endPoint the second set of x, y coordinates of line segment
*/
function recalculateLineLength(
startPoint: Point,
endPoint: Point,
arrowLen: number
): Point {
// Determine x and y distance between coordinate sets
let xLen = endPoint.x - startPoint.x;
let yLen = endPoint.y - startPoint.y;
// Calculate hypoteneuse length
const lineLen = Math.hypot(xLen, yLen);
// Calculate new length by subtracting arrowlength
const newLineLen = lineLen - arrowLen;
// If arrowLen longer than lineLen, make line short as possible
// Ideally shouldn't be used this way as arrow should be shorter
// Determine what fraction smaller new length is
const frac = (arrowLen >= lineLen ? 2 : newLineLen) / lineLen;
// Multiply lengths by fraction to get new lengths
xLen *= frac;
yLen *= frac;
// Calculate new final x y coordinates
endPoint.x = startPoint.x + Math.round(xLen);
endPoint.y = startPoint.y + Math.round(yLen);
// Return newly calculated final coordinates
return endPoint;
}

const LineWidgetProps = {
...LineProps,
...PVWidgetPropType
Expand Down

0 comments on commit 6f998e0

Please sign in to comment.