diff --git a/packages/graph-explorer/src/components/EdgePreview/ArrowPrimitiveShape.tsx b/packages/graph-explorer/src/components/EdgePreview/ArrowPrimitiveShape.tsx
new file mode 100644
index 0000000000..9fa21988c1
--- /dev/null
+++ b/packages/graph-explorer/src/components/EdgePreview/ArrowPrimitiveShape.tsx
@@ -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 ;
+ }
+ if (primitive.kind === "path") {
+ return ;
+ }
+ const points = primitive.points.map(([x, y]) => `${x},${y}`).join(" ");
+ return ;
+}
diff --git a/packages/graph-explorer/src/components/EdgePreview/ArrowStyleIcon.test.tsx b/packages/graph-explorer/src/components/EdgePreview/ArrowStyleIcon.test.tsx
new file mode 100644
index 0000000000..5e44a58c8c
--- /dev/null
+++ b/packages/graph-explorer/src/components/EdgePreview/ArrowStyleIcon.test.tsx
@@ -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();
+ 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);
+ });
+});
diff --git a/packages/graph-explorer/src/components/EdgePreview/ArrowStyleIcon.tsx b/packages/graph-explorer/src/components/EdgePreview/ArrowStyleIcon.tsx
new file mode 100644
index 0000000000..d0a6e34bba
--- /dev/null
+++ b/packages/graph-explorer/src/components/EdgePreview/ArrowStyleIcon.tsx
@@ -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;
+
+/**
+ * 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 (
+
+ );
+}
diff --git a/packages/graph-explorer/src/components/EdgePreview/EdgePreview.tsx b/packages/graph-explorer/src/components/EdgePreview/EdgePreview.tsx
index f1b6715669..660b6ec199 100644
--- a/packages/graph-explorer/src/components/EdgePreview/EdgePreview.tsx
+++ b/packages/graph-explorer/src/components/EdgePreview/EdgePreview.tsx
@@ -7,9 +7,9 @@ import { cn } from "@/utils";
import { LabelPreview } from "../LabelPreview";
import { edgePreviewLabel } from "../previewLabels";
+import { ArrowPrimitiveShape } from "./ArrowPrimitiveShape";
import {
type ArrowGeometry,
- type ArrowPrimitive,
getArrowWidth,
resolveArrowGeometry,
} from "./arrowShapes";
@@ -233,14 +233,3 @@ function ArrowHead({
);
}
-
-function ArrowPrimitiveShape({ primitive }: { primitive: ArrowPrimitive }) {
- if (primitive.kind === "circle") {
- return ;
- }
- if (primitive.kind === "path") {
- return ;
- }
- const points = primitive.points.map(([x, y]) => `${x},${y}`).join(" ");
- return ;
-}
diff --git a/packages/graph-explorer/src/components/EdgePreview/index.ts b/packages/graph-explorer/src/components/EdgePreview/index.ts
index 22e1b1b57c..76b0073d60 100644
--- a/packages/graph-explorer/src/components/EdgePreview/index.ts
+++ b/packages/graph-explorer/src/components/EdgePreview/index.ts
@@ -1 +1,2 @@
export { EdgePreview } from "./EdgePreview";
+export { ArrowStyleIcon } from "./ArrowStyleIcon";
diff --git a/packages/graph-explorer/src/components/icons/ArrowCircle.tsx b/packages/graph-explorer/src/components/icons/ArrowCircle.tsx
deleted file mode 100644
index 6e0f7f4ac7..0000000000
--- a/packages/graph-explorer/src/components/icons/ArrowCircle.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { IconBaseProps } from "./IconBase";
-
-import IconBase from "./IconBase";
-
-export const ArrowCircle = (props: IconBaseProps) => {
- return (
-
-
-
-
- );
-};
-
-export default ArrowCircle;
diff --git a/packages/graph-explorer/src/components/icons/ArrowDiamond.tsx b/packages/graph-explorer/src/components/icons/ArrowDiamond.tsx
deleted file mode 100644
index 0a3c40c96f..0000000000
--- a/packages/graph-explorer/src/components/icons/ArrowDiamond.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { IconBaseProps } from "./IconBase";
-
-import IconBase from "./IconBase";
-
-export const ArrowDiamond = (props: IconBaseProps) => {
- return (
-
-
-
-
- );
-};
-
-export default ArrowDiamond;
diff --git a/packages/graph-explorer/src/components/icons/ArrowNone.tsx b/packages/graph-explorer/src/components/icons/ArrowNone.tsx
deleted file mode 100644
index 08183f9f1d..0000000000
--- a/packages/graph-explorer/src/components/icons/ArrowNone.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { IconBaseProps } from "./IconBase";
-
-import IconBase from "./IconBase";
-
-export const ArrowNone = (props: IconBaseProps) => {
- return (
-
-
-
- );
-};
-
-export default ArrowNone;
diff --git a/packages/graph-explorer/src/components/icons/ArrowSquare.tsx b/packages/graph-explorer/src/components/icons/ArrowSquare.tsx
deleted file mode 100644
index 2f59499bc0..0000000000
--- a/packages/graph-explorer/src/components/icons/ArrowSquare.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { IconBaseProps } from "./IconBase";
-
-import IconBase from "./IconBase";
-
-export const ArrowSquare = (props: IconBaseProps) => {
- return (
-
-
-
-
- );
-};
-
-export default ArrowSquare;
diff --git a/packages/graph-explorer/src/components/icons/ArrowTee.tsx b/packages/graph-explorer/src/components/icons/ArrowTee.tsx
deleted file mode 100644
index c8351d28e3..0000000000
--- a/packages/graph-explorer/src/components/icons/ArrowTee.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { IconBaseProps } from "./IconBase";
-
-import IconBase from "./IconBase";
-
-export const ArrowTee = (props: IconBaseProps) => {
- return (
-
-
-
-
- );
-};
-
-export default ArrowTee;
diff --git a/packages/graph-explorer/src/components/icons/ArrowTriangle.tsx b/packages/graph-explorer/src/components/icons/ArrowTriangle.tsx
deleted file mode 100644
index cb8cba4d16..0000000000
--- a/packages/graph-explorer/src/components/icons/ArrowTriangle.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { IconBaseProps } from "./IconBase";
-
-import IconBase from "./IconBase";
-
-export const ArrowTriangle = (props: IconBaseProps) => {
- return (
-
-
-
-
- );
-};
-
-export default ArrowTriangle;
diff --git a/packages/graph-explorer/src/components/icons/ArrowTriangleBackCurve.tsx b/packages/graph-explorer/src/components/icons/ArrowTriangleBackCurve.tsx
deleted file mode 100644
index 3c876f5b31..0000000000
--- a/packages/graph-explorer/src/components/icons/ArrowTriangleBackCurve.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { IconBaseProps } from "./IconBase";
-
-import IconBase from "./IconBase";
-
-export const ArrowTriangleBackCurve = (props: IconBaseProps) => {
- return (
-
-
-
-
- );
-};
-
-export default ArrowTriangleBackCurve;
diff --git a/packages/graph-explorer/src/components/icons/ArrowTriangleCircle.tsx b/packages/graph-explorer/src/components/icons/ArrowTriangleCircle.tsx
deleted file mode 100644
index d5412ff8a0..0000000000
--- a/packages/graph-explorer/src/components/icons/ArrowTriangleCircle.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { IconBaseProps } from "./IconBase";
-
-import IconBase from "./IconBase";
-
-export const ArrowTriangleCircle = (props: IconBaseProps) => {
- return (
-
-
-
-
-
- );
-};
-
-export default ArrowTriangleCircle;
diff --git a/packages/graph-explorer/src/components/icons/ArrowTriangleCross.tsx b/packages/graph-explorer/src/components/icons/ArrowTriangleCross.tsx
deleted file mode 100644
index 374b205a04..0000000000
--- a/packages/graph-explorer/src/components/icons/ArrowTriangleCross.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { IconBaseProps } from "./IconBase";
-
-import IconBase from "./IconBase";
-
-export const ArrowTriangleCross = (props: IconBaseProps) => {
- return (
-
-
-
-
-
- );
-};
-
-export default ArrowTriangleCross;
diff --git a/packages/graph-explorer/src/components/icons/ArrowTriangleTee.tsx b/packages/graph-explorer/src/components/icons/ArrowTriangleTee.tsx
deleted file mode 100644
index 41fdcf2e3c..0000000000
--- a/packages/graph-explorer/src/components/icons/ArrowTriangleTee.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { IconBaseProps } from "./IconBase";
-
-import IconBase from "./IconBase";
-
-export const ArrowTriangleTee = (props: IconBaseProps) => {
- return (
-
-
-
-
-
- );
-};
-
-export default ArrowTriangleTee;
diff --git a/packages/graph-explorer/src/components/icons/ArrowVee.tsx b/packages/graph-explorer/src/components/icons/ArrowVee.tsx
deleted file mode 100644
index 61a2ab0408..0000000000
--- a/packages/graph-explorer/src/components/icons/ArrowVee.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { IconBaseProps } from "./IconBase";
-
-import IconBase from "./IconBase";
-
-export const ArrowVee = (props: IconBaseProps) => {
- return (
-
-
-
-
- );
-};
-
-export default ArrowVee;
diff --git a/packages/graph-explorer/src/components/icons/index.ts b/packages/graph-explorer/src/components/icons/index.ts
index 18ae242911..4a9fdc22d2 100644
--- a/packages/graph-explorer/src/components/icons/index.ts
+++ b/packages/graph-explorer/src/components/icons/index.ts
@@ -1,15 +1,4 @@
export { default as AddIcon } from "./AddIcon";
-export { default as ArrowCircle } from "./ArrowCircle";
-export { default as ArrowDiamond } from "./ArrowDiamond";
-export { default as ArrowNone } from "./ArrowNone";
-export { default as ArrowSquare } from "./ArrowSquare";
-export { default as ArrowTee } from "./ArrowTee";
-export { default as ArrowTriangle } from "./ArrowTriangle";
-export { default as ArrowTriangleBackCurve } from "./ArrowTriangleBackCurve";
-export { default as ArrowTriangleCircle } from "./ArrowTriangleCircle";
-export { default as ArrowTriangleCross } from "./ArrowTriangleCross";
-export { default as ArrowTriangleTee } from "./ArrowTriangleTee";
-export { default as ArrowVee } from "./ArrowVee";
export { default as AutoFitLeftIcon } from "./AutoFitLeftIcon";
export { default as CenterGraphIcon } from "./CenterGraphIcon";
export { default as CheckIcon } from "./CheckIcon";
diff --git a/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx b/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx
index c360144073..6aecb761e0 100644
--- a/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx
+++ b/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx
@@ -1,6 +1,7 @@
import { atom, useAtom, useSetAtom } from "jotai";
import {
+ ArrowStyleIcon,
Button,
ColorPopover,
EdgePreview,
@@ -275,8 +276,11 @@ function Content({ edgeType }: { edgeType: EdgeType }) {
{ARROW_STYLE_OPTIONS.map(option => (
))}
@@ -298,8 +302,11 @@ function Content({ edgeType }: { edgeType: EdgeType }) {
{ARROW_STYLE_OPTIONS.map(option => (
))}
diff --git a/packages/graph-explorer/src/modules/EdgesStyling/arrowsStyling.tsx b/packages/graph-explorer/src/modules/EdgesStyling/arrowsStyling.tsx
index 7b6ba06168..3a31e8a2ce 100644
--- a/packages/graph-explorer/src/modules/EdgesStyling/arrowsStyling.tsx
+++ b/packages/graph-explorer/src/modules/EdgesStyling/arrowsStyling.tsx
@@ -1,71 +1,15 @@
-import {
- ArrowCircle,
- ArrowDiamond,
- ArrowNone,
- ArrowSquare,
- ArrowTee,
- ArrowTriangle,
- ArrowTriangleBackCurve,
- ArrowTriangleCircle,
- ArrowTriangleCross,
- ArrowTriangleTee,
- ArrowVee,
-} from "@/components";
+import type { ArrowStyle } from "@/core";
-export const ARROW_STYLE_OPTIONS = [
- {
- label: "Triangle",
- value: "triangle",
- Icon: ArrowTriangle,
- },
- {
- label: "Triangle Tee",
- value: "triangle-tee",
- Icon: ArrowTriangleTee,
- },
- {
- label: "Triangle Circle",
- value: "circle-triangle",
- Icon: ArrowTriangleCircle,
- },
- {
- label: "Triangle Cross",
- value: "triangle-cross",
- Icon: ArrowTriangleCross,
- },
- {
- label: "Triangle Backcurve",
- value: "triangle-backcurve",
- Icon: ArrowTriangleBackCurve,
- },
- {
- label: "Tee",
- value: "tee",
- Icon: ArrowTee,
- },
- {
- label: "Vee",
- value: "vee",
- Icon: ArrowVee,
- },
- {
- label: "Square",
- value: "square",
- Icon: ArrowSquare,
- },
- {
- label: "Circle",
- value: "circle",
- Icon: ArrowCircle,
- },
- {
- label: "Diamond",
- value: "diamond",
- Icon: ArrowDiamond,
- },
- {
- label: "None",
- value: "none",
- Icon: ArrowNone,
- },
+export const ARROW_STYLE_OPTIONS: { label: string; value: ArrowStyle }[] = [
+ { label: "Triangle", value: "triangle" },
+ { label: "Triangle Tee", value: "triangle-tee" },
+ { label: "Triangle Circle", value: "circle-triangle" },
+ { label: "Triangle Cross", value: "triangle-cross" },
+ { label: "Triangle Backcurve", value: "triangle-backcurve" },
+ { label: "Tee", value: "tee" },
+ { label: "Vee", value: "vee" },
+ { label: "Square", value: "square" },
+ { label: "Circle", value: "circle" },
+ { label: "Diamond", value: "diamond" },
+ { label: "None", value: "none" },
];
diff --git a/packages/graph-explorer/src/modules/EntityDetails/EdgeDetail.tsx b/packages/graph-explorer/src/modules/EntityDetails/EdgeDetail.tsx
index 1d85c6425d..dec46cc397 100644
--- a/packages/graph-explorer/src/modules/EntityDetails/EdgeDetail.tsx
+++ b/packages/graph-explorer/src/modules/EntityDetails/EdgeDetail.tsx
@@ -1,24 +1,7 @@
import type { CSSProperties } from "react";
-import type { IconBaseProps } from "@/components/icons/IconBase";
-
-import {
- ArrowCircle,
- ArrowDiamond,
- ArrowNone,
- ArrowSquare,
- ArrowTee,
- ArrowTriangle,
- ArrowTriangleBackCurve,
- ArrowTriangleCircle,
- ArrowTriangleCross,
- ArrowTriangleTee,
- ArrowVee,
- EdgeRow,
- VertexRow,
-} from "@/components";
+import { ArrowStyleIcon, EdgeRow, VertexRow } from "@/components";
import {
- type ArrowStyle,
type DisplayAttribute,
type DisplayEdge,
type LineStyle,
@@ -96,16 +79,16 @@ const EdgeDetail = ({ edge }: EdgeDetailProps) => {
className="row-span-2 flex flex-col items-center"
style={{ color: style.lineColor || DEFAULT_LINE_COLOR }}
>
-
-
@@ -157,23 +140,4 @@ function styleForLineStyle(style: LineStyle): CSSProperties {
return {};
}
-/** Renders the right SVG icon for the given arrow style. */
-function Arrow({ kind, ...props }: { kind: ArrowStyle } & IconBaseProps) {
- return (
- <>
- {kind === "triangle" && }
- {kind === "triangle-tee" && }
- {kind === "circle-triangle" && }
- {kind === "triangle-cross" && }
- {kind === "triangle-backcurve" && }
- {kind === "tee" && }
- {kind === "vee" && }
- {kind === "square" && }
- {kind === "circle" && }
- {kind === "diamond" && }
- {kind === "none" && }
- >
- );
-}
-
export default EdgeDetail;