Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ArrowPrimitive } from "./arrowShapes";

/**
* Renders a single resolved arrow primitive (polygon, path, or circle) as an
* SVG child. Shared by the edge preview and the arrow-style icon so both draw
* cytoscape's arrow geometry identically.
*/
export function ArrowPrimitiveShape({
primitive,
}: {
primitive: ArrowPrimitive;
}) {
if (primitive.kind === "circle") {
return <circle cx={primitive.cx} cy={primitive.cy} r={primitive.r} />;
}
if (primitive.kind === "path") {
return <path d={primitive.d} />;
}
const points = primitive.points.map(([x, y]) => `${x},${y}`).join(" ");
return <polygon points={points} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";

import { ARROW_STYLES } from "@/core";

import { ArrowStyleIcon, type ArrowStyleIconProps } from "./ArrowStyleIcon";

function renderIcon(props: ArrowStyleIconProps) {
const { container } = render(<ArrowStyleIcon {...props} />);
const svg = container.querySelector("svg");
if (!svg) {
throw new Error("expected an svg element");
}
return svg;
}

describe("ArrowStyleIcon", () => {
it.each(ARROW_STYLES)("renders %s with a shared viewBox", arrowStyle => {
const svg = renderIcon({ arrowStyle });
expect(svg.getAttribute("viewBox")).toBe(
renderIcon({ arrowStyle: "triangle" }).getAttribute("viewBox"),
);
});

it("always draws the edge shaft", () => {
for (const arrowStyle of ARROW_STYLES) {
const svg = renderIcon({ arrowStyle });
expect(svg.querySelector("rect")).not.toBeNull();
}
});

it("draws a head for every style except none", () => {
for (const arrowStyle of ARROW_STYLES) {
const svg = renderIcon({ arrowStyle });
const hasHead = svg.querySelector("polygon, path, circle") !== null;
expect(hasHead).toBe(arrowStyle !== "none");
}
});

it("draws two head primitives for compound styles", () => {
for (const arrowStyle of [
"triangle-tee",
"triangle-cross",
"circle-triangle",
] as const) {
const svg = renderIcon({ arrowStyle });
expect(svg.querySelectorAll("polygon, path, circle")).toHaveLength(2);
}
});

it("starts every shaft at the same left edge", () => {
const lefts = ARROW_STYLES.map(arrowStyle =>
renderIcon({ arrowStyle }).querySelector("rect")!.getAttribute("x"),
);
expect(new Set(lefts).size).toBe(1);
});

it("forwards svg attributes such as className", () => {
const svg = renderIcon({ arrowStyle: "triangle", className: "rotate-180" });
expect(svg.getAttribute("class")).toBe("rotate-180");
});

it("does not shift a tip-at-origin head like triangle", () => {
// triangle's tip sits at the frame origin (bbox.maxX = 0), so its head
// needs no shift to reach the shared right anchor.
const svg = renderIcon({ arrowStyle: "triangle" });
expect(svg.querySelector("g")!.getAttribute("transform")).toBe(
"translate(0 0)",
);
});

it("shifts a head that pokes past the origin like circle onto the anchor", () => {
// circle straddles the origin (bbox.maxX > 0), so it is translated left by
// that overshoot to land its trailing edge on the shared right anchor.
const svg = renderIcon({ arrowStyle: "circle" });
const transform = svg.querySelector("g")!.getAttribute("transform")!;
const shift = Number(transform.match(/translate\(([-\d.]+) 0\)/)![1]);
expect(shift).toBeLessThan(0);
});
});
106 changes: 106 additions & 0 deletions packages/graph-explorer/src/components/EdgePreview/ArrowStyleIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { SVGAttributes } from "react";

import { ARROW_STYLES, type ArrowStyle } from "@/core";

import { ArrowPrimitiveShape } from "./ArrowPrimitiveShape";
import {
type ArrowGeometry,
getArrowWidth,
resolveArrowGeometry,
} from "./arrowShapes";

// --- Icon geometry ---
//
// An arrow-style icon is a short horizontal edge ending in the arrow head, all
// drawn from the same cytoscape geometry the canvas and edge preview use. The
// three visual invariants across every style:
// 1. the edge line STARTS at a shared left edge,
// 2. the head's TRAILING edge lands on a shared right anchor (x=0), and
// 3. every icon shares one viewBox, so line thickness and zoom are identical.
// Line LENGTH therefore varies with how deep the head is.

/** Conceptual edge width the icon geometry is drawn at. */
const LINE_THICKNESS = 2;
/** Pixels per cytoscape unit — the multiplier applied to normalized points. */
const UNIT = getArrowWidth(LINE_THICKNESS);
/** Shaft length beyond the leftmost head body, as a fraction of the unit. */
const SHAFT_FRACTION = 0.12;
/** Padding around the drawn content, in cytoscape units. */
const PADDING = 3;

/** Shift that lands a head's rightmost point on the shared right anchor (0). */
function headShift(geometry: ArrowGeometry | null): number {
return geometry ? -geometry.bbox.maxX : 0;
}

/**
* The viewBox and shaft-start shared by every arrow-style icon, computed once
* from the union of all head geometries so the icons form a consistent set.
*/
function computeSharedFrame() {
let bodyMinX = 0;
let yExtent = LINE_THICKNESS / 2;
for (const style of ARROW_STYLES) {
const geometry = resolveArrowGeometry(style, UNIT, LINE_THICKNESS);
if (!geometry) {
continue;
}
const shift = headShift(geometry);
bodyMinX = Math.min(bodyMinX, geometry.bbox.minX + shift);
yExtent = Math.max(yExtent, -geometry.bbox.minY, geometry.bbox.maxY);
}
const shaftLeft = bodyMinX - UNIT * SHAFT_FRACTION;
const minX = shaftLeft - PADDING;
const maxX = PADDING;
const minY = -(yExtent + PADDING);
const height = 2 * (yExtent + PADDING);
return {
shaftLeft,
viewBox: `${minX} ${minY} ${maxX - minX} ${height}`,
};
}

const sharedFrame = computeSharedFrame();

export type ArrowStyleIconProps = {
arrowStyle: ArrowStyle;
} & SVGAttributes<SVGSVGElement>;

/**
* Icon for an edge arrow style: a short edge line ending in the arrow head,
* drawn from the same cytoscape geometry as the canvas so the picker and edge
* details always match what renders. The head points right; rotate with CSS to
* reorient. Fills with `currentColor`, so it takes the surrounding text color.
*/
export function ArrowStyleIcon({ arrowStyle, ...props }: ArrowStyleIconProps) {
const geometry = resolveArrowGeometry(arrowStyle, UNIT, LINE_THICKNESS);
const shift = headShift(geometry);
// The shaft meets the head at cytoscape's (spacing - gap) point, in the
// shifted frame; with no head it runs to the shared right anchor at x=0.
const shaftRight = geometry ? geometry.spacing - geometry.gap + shift : 0;

return (
<svg
width="1em"
height="1em"
viewBox={sharedFrame.viewBox}
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect
x={sharedFrame.shaftLeft}
y={-LINE_THICKNESS / 2}
width={shaftRight - sharedFrame.shaftLeft}
height={LINE_THICKNESS}
/>
{geometry && (
<g transform={`translate(${shift} 0)`}>
{geometry.primitives.map((primitive, i) => (
<ArrowPrimitiveShape key={i} primitive={primitive} />
))}
</g>
)}
</svg>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";

import {
appDefaultEdgeStyle,
ARROW_STYLES,
createEdgeType,
type EdgeStyle,
} from "@/core";

import {
type EdgeLineOrientation,
EdgeLinePreview,
rotatedBbox,
} from "./EdgeLinePreview";

function edgeStyle(overrides?: Partial<EdgeStyle>): EdgeStyle {
return {
...appDefaultEdgeStyle,
type: createEdgeType("KNOWS"),
...overrides,
};
}

const ORIENTATIONS: EdgeLineOrientation[] = ["horizontal", "vertical"];

describe("EdgeLinePreview", () => {
// Every source/target arrow combination, in both orientations, must produce
// finite geometry — a NaN/Infinity would surface as an invalid viewBox.
it.each(ORIENTATIONS)("renders every arrow pair when %s", orientation => {
for (const sourceArrowStyle of ARROW_STYLES) {
for (const targetArrowStyle of ARROW_STYLES) {
const { container } = render(
<EdgeLinePreview
edgeStyle={edgeStyle({ sourceArrowStyle, targetArrowStyle })}
orientation={orientation}
/>,
);
for (const svg of container.querySelectorAll("svg")) {
expect(svg.getAttribute("viewBox")).not.toMatch(/NaN|Infinity/);
}
}
}
});

it.each(ORIENTATIONS)("renders the label overlay when %s", orientation => {
render(
<EdgeLinePreview
edgeStyle={edgeStyle()}
orientation={orientation}
label="knows"
/>,
);
expect(screen.getByText("knows")).toBeInTheDocument();
});
});

describe("rotatedBbox", () => {
const box = { minX: 1, minY: 2, maxX: 5, maxY: 4 };

it("is the identity at 0 degrees", () => {
expect(rotatedBbox(box, 0)).toStrictEqual(box);
});

it("swaps and flips axes at 90 degrees", () => {
// (x,y) -> (-y, x): x in [1,5], y in [2,4] -> X in [-4,-2], Y in [1,5]
const rotated = rotatedBbox(box, 90);
expect(rotated.minX).toBeCloseTo(-4);
expect(rotated.maxX).toBeCloseTo(-2);
expect(rotated.minY).toBeCloseTo(1);
expect(rotated.maxY).toBeCloseTo(5);
});

it("negates both axes at 180 degrees", () => {
const rotated = rotatedBbox(box, 180);
expect(rotated.minX).toBeCloseTo(-5);
expect(rotated.maxX).toBeCloseTo(-1);
expect(rotated.minY).toBeCloseTo(-4);
expect(rotated.maxY).toBeCloseTo(-2);
});

it("swaps and flips axes at -90 degrees", () => {
// (x,y) -> (y, -x): x in [1,5], y in [2,4] -> X in [2,4], Y in [-5,-1]
const rotated = rotatedBbox(box, -90);
expect(rotated.minX).toBeCloseTo(2);
expect(rotated.maxX).toBeCloseTo(4);
expect(rotated.minY).toBeCloseTo(-5);
expect(rotated.maxY).toBeCloseTo(-1);
});
});
Loading