diff --git a/packages/graph-explorer/src/components/EdgePreview/EdgeLinePreview.test.tsx b/packages/graph-explorer/src/components/EdgePreview/EdgeLinePreview.test.tsx new file mode 100644 index 000000000..b3fa06866 --- /dev/null +++ b/packages/graph-explorer/src/components/EdgePreview/EdgeLinePreview.test.tsx @@ -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 { + 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( + , + ); + 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( + , + ); + 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); + }); +}); diff --git a/packages/graph-explorer/src/components/EdgePreview/EdgeLinePreview.tsx b/packages/graph-explorer/src/components/EdgePreview/EdgeLinePreview.tsx new file mode 100644 index 000000000..daff41e4a --- /dev/null +++ b/packages/graph-explorer/src/components/EdgePreview/EdgeLinePreview.tsx @@ -0,0 +1,284 @@ +import type { CSSProperties } from "react"; + +import type { EdgeStyle } from "@/core"; + +import { LabelPreview } from "../LabelPreview"; +import { ArrowPrimitiveShape } from "./ArrowPrimitiveShape"; +import { + type ArrowGeometry, + getArrowWidth, + resolveArrowGeometry, +} from "./arrowShapes"; + +/** + * Pixels per cytoscape model unit for edge previews (mirrors the vertex + * preview's `PREVIEW_SCALE`). Cytoscape's model is ~1px per unit, too small to + * read, so the geometry and label are drawn at this scale. A call site sizes + * the whole preview to its surface separately (e.g. a `zoom-*` class), which + * stays crisp because everything here is vector. + */ +const EDGE_PREVIEW_SCALE = 2; + +export type EdgeLineOrientation = "horizontal" | "vertical"; + +/** + * The styled edge itself — a line with a source and target arrow head — drawn + * from the same cytoscape geometry the canvas uses, so a preview matches what + * renders. Fills its container along the main axis (width when horizontal, + * height when vertical); the arrows pin to the ends and the line spans between, + * inset by cytoscape's per-arrow gap. Shared by the edge style preview + * (horizontal, with a text label overlaid by the caller) and the edge details + * panel (vertical). + * + * The arrows convey direction and style visually only, so pass an `aria-label` + * describing the edge to expose that to assistive tech (as `VertexSymbol` + * does); omit it when a wrapping element already provides the accessible name. + * + * A `label` renders a centered badge over the line in either orientation. In + * horizontal previews it truncates so it never touches the left/right arrow + * heads; in vertical previews the heads sit above and below, so it simply + * centers on the line. + */ +export function EdgeLinePreview({ + edgeStyle, + orientation, + label, + className, + style, + "aria-label": ariaLabel, +}: { + edgeStyle: EdgeStyle; + orientation: EdgeLineOrientation; + label?: string; + className?: string; + style?: CSSProperties; + "aria-label"?: string; +}) { + const lineWidthPx = edgeStyle.lineThickness * EDGE_PREVIEW_SCALE; + const arrowUnit = getArrowWidth(edgeStyle.lineThickness) * EDGE_PREVIEW_SCALE; + + const sourceArrow = resolveArrowGeometry( + edgeStyle.sourceArrowStyle, + arrowUnit, + lineWidthPx, + ); + const targetArrow = resolveArrowGeometry( + edgeStyle.targetArrowStyle, + arrowUnit, + lineWidthPx, + ); + + const horizontal = orientation === "horizontal"; + + // Cytoscape stops the line short of each node boundary by the arrow's `gap`. + // Here the "boundary" is each end of this container, so inset the line by gap. + const startGap = sourceArrow?.gap ?? 0; + const endGap = targetArrow?.gap ?? 0; + + // Cross-axis size: the deepest arrow (or the line) so the arrows never clip. + const crossSize = Math.max( + lineWidthPx, + arrowCrossSize(sourceArrow), + arrowCrossSize(targetArrow), + ); + + // Space to keep clear on each side so a centered label truncates before it + // touches either arrow head: the deepest reach, mirrored, plus a small gap. + const labelInset = + 2 * Math.max(arrowReach(sourceArrow), arrowReach(targetArrow)) + 8; + + const lineStyle: CSSProperties = horizontal + ? { + left: startGap, + right: endGap, + top: "50%", + transform: "translateY(-50%)", + borderTopWidth: lineWidthPx, + borderTopStyle: edgeStyle.lineStyle, + borderTopColor: edgeStyle.lineColor, + } + : { + top: startGap, + bottom: endGap, + left: "50%", + transform: "translateX(-50%)", + borderLeftWidth: lineWidthPx, + borderLeftStyle: edgeStyle.lineStyle, + borderLeftColor: edgeStyle.lineColor, + }; + + return ( +
+
+ {sourceArrow && ( + + )} + {targetArrow && ( + + )} + {label !== undefined && ( + + {label} + + )} +
+ ); +} + +/** How far an arrow head reaches inward from the boundary (spacing + depth). */ +function arrowReach(geometry: ArrowGeometry | null): number { + if (!geometry) { + return 0; + } + return geometry.spacing + (geometry.bbox.maxX - geometry.bbox.minX); +} + +/** + * The arrow head's extent across the edge axis (its visual thickness). The + * geometry points along X and is symmetric about it, so its thickness is the + * Y-extent — and the head is rotated to face along the edge, so this holds for + * either orientation. + */ +function arrowCrossSize(geometry: ArrowGeometry | null): number { + if (!geometry) { + return 0; + } + return geometry.bbox.maxY - geometry.bbox.minY; +} + +type Bbox = { minX: number; minY: number; maxX: number; maxY: number }; + +/** Axis-aligned bounds of `bbox` after rotating by `degrees` about the origin. */ +export function rotatedBbox(bbox: Bbox, degrees: number): Bbox { + const radians = (degrees * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const corners = [ + [bbox.minX, bbox.minY], + [bbox.maxX, bbox.minY], + [bbox.maxX, bbox.maxY], + [bbox.minX, bbox.maxY], + ]; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [x, y] of corners) { + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + minX = Math.min(minX, rx); + minY = Math.min(minY, ry); + maxX = Math.max(maxX, rx); + maxY = Math.max(maxY, ry); + } + return { minX, minY, maxX, maxY }; +} + +/** How far the head points toward its node: source points back, target forward. */ +function arrowAngle( + side: "source" | "target", + orientation: EdgeLineOrientation, +): number { + if (orientation === "horizontal") { + return side === "source" ? 180 : 0; + } + return side === "source" ? -90 : 90; +} + +/** + * A single arrow head, rotated to point along the edge toward its node and + * positioned so its tip (the cytoscape frame origin) sits `spacing` in from the + * container end. The rotation is baked into the SVG geometry and the viewBox is + * sized to the rotated bounds, so the element's layout box equals its visual + * box — letting the tip be pinned the same way in either orientation. + */ +function ArrowHead({ + geometry, + color, + side, + orientation, +}: { + geometry: ArrowGeometry; + color: string; + side: "source" | "target"; + orientation: EdgeLineOrientation; +}) { + const { bbox, spacing } = geometry; + const angle = arrowAngle(side, orientation); + const rb = rotatedBbox(bbox, angle); + const width = rb.maxX - rb.minX; + const height = rb.maxY - rb.minY; + + // Center on the cross axis (`margin` = the rotated bbox's negative start), and + // pin the tip along the main axis: the source anchors from the start, the + // target from the end at `spacing - max`, mirroring how cytoscape places the + // tip `spacing` in from the node boundary (matches the original preview). + const placement: CSSProperties = + orientation === "horizontal" + ? { + top: "50%", + marginTop: rb.minY, + ...(side === "source" + ? { left: spacing + rb.minX } + : { right: spacing - rb.maxX }), + } + : { + left: "50%", + marginLeft: rb.minX, + ...(side === "source" + ? { top: spacing + rb.minY } + : { bottom: spacing - rb.maxY }), + }; + + return ( + + + {geometry.primitives.map((primitive, i) => ( + + ))} + + + ); +} diff --git a/packages/graph-explorer/src/components/EdgePreview/EdgePreview.tsx b/packages/graph-explorer/src/components/EdgePreview/EdgePreview.tsx index 660b6ec19..dc00f547c 100644 --- a/packages/graph-explorer/src/components/EdgePreview/EdgePreview.tsx +++ b/packages/graph-explorer/src/components/EdgePreview/EdgePreview.tsx @@ -5,22 +5,8 @@ import type { EdgeStyle } from "@/core"; import { identityTransform, type TextTransformer } from "@/hooks"; import { cn } from "@/utils"; -import { LabelPreview } from "../LabelPreview"; import { edgePreviewLabel } from "../previewLabels"; -import { ArrowPrimitiveShape } from "./ArrowPrimitiveShape"; -import { - type ArrowGeometry, - getArrowWidth, - resolveArrowGeometry, -} from "./arrowShapes"; - -/** - * Pixels per cytoscape unit. Cytoscape renders 1:1 (1 unit = 1px at zoom 1), - * which is small for a dialog preview, so we scale up by default to land near a - * comfortable viewing size. Everything (SVG geometry and label) scales - * uniformly, so callers can still CSS-`zoom` from here without losing crispness. - */ -const DEFAULT_ZOOM = 2; +import { EdgeLinePreview } from "./EdgeLinePreview"; /** A neutral placeholder node representing an endpoint of the edge. */ function VertexPlaceholder({ @@ -51,8 +37,10 @@ interface EdgePreviewProps { } /** - * Two placeholder nodes connected by the styled edge. Geometry is ported - * verbatim from cytoscape so the preview matches the canvas. + * Full edge preview: two placeholder nodes connected by the styled edge with + * the given label. The edge itself (line + arrow heads + label) is drawn by + * {@link EdgeLinePreview}, ported verbatim from cytoscape so the preview + * matches the canvas. Apply CSS `zoom` via className to scale. */ export function EdgePreview({ edgeStyle, @@ -60,19 +48,6 @@ export function EdgePreview({ className, }: EdgePreviewProps) { const label = edgePreviewLabel(edgeStyle, transform); - const lineWidthPx = edgeStyle.lineThickness * DEFAULT_ZOOM; - const arrowUnit = getArrowWidth(edgeStyle.lineThickness) * DEFAULT_ZOOM; - - const sourceArrow = resolveArrowGeometry( - edgeStyle.sourceArrowStyle, - arrowUnit, - lineWidthPx, - ); - const targetArrow = resolveArrowGeometry( - edgeStyle.targetArrowStyle, - arrowUnit, - lineWidthPx, - ); return (
-
@@ -96,140 +69,3 @@ export function EdgePreview({
); } - -// --- Edge line with arrows and label --- - -function EdgeLine({ - edgeStyle, - labelText, - lineWidthPx, - sourceArrow, - targetArrow, -}: { - edgeStyle: EdgeStyle; - labelText: string; - lineWidthPx: number; - sourceArrow: ArrowGeometry | null; - targetArrow: ArrowGeometry | null; -}) { - // Cytoscape stops the line short of the node boundary by `gap` and places the - // arrow tip short by `spacing`. Here "the boundary" is each node's inner edge - // (the container's left/right). We inset the line by `gap` and anchor each - // arrow tip `spacing` in from the boundary. - const lineInsetLeft = sourceArrow?.gap ?? 0; - const lineInsetRight = targetArrow?.gap ?? 0; - - // The container needs a height since every child is absolutely positioned. - // Use the tallest arrow (or the line thickness) so vertical centering works. - const containerHeight = Math.max( - lineWidthPx, - arrowHeight(sourceArrow), - arrowHeight(targetArrow), - ); - - // The label is centered, so reserving space on the wider-reaching side must - // be mirrored on both. Reserve twice the deepest arrow reach (plus a small - // gap) so the centered label truncates before it touches either arrow head. - const labelInset = - 2 * Math.max(arrowReach(sourceArrow), arrowReach(targetArrow)) + 8; - - return ( -
- {/* Line — spans the full width, inset to each arrow's gap */} -
- - {sourceArrow && ( - - )} - {targetArrow && ( - - )} - - - {labelText} - -
- ); -} - -function arrowHeight(geometry: ArrowGeometry | null): number { - if (!geometry) return 0; - return geometry.bbox.maxY - geometry.bbox.minY; -} - -/** - * How far an arrow head reaches inward from the node boundary: its tip sits - * `spacing` in, and its body extends the bbox width further toward the center. - */ -function arrowReach(geometry: ArrowGeometry | null): number { - if (!geometry) return 0; - return geometry.spacing + (geometry.bbox.maxX - geometry.bbox.minX); -} - -// --- Arrow head --- - -/** - * Renders a resolved arrow geometry as an absolutely-positioned SVG. The - * arrow's tip is at cytoscape-frame origin; we position the SVG so the tip - * lands `spacing` px in from the node boundary (the container edge). - */ -function ArrowHead({ - geometry, - color, - side, -}: { - geometry: ArrowGeometry; - color: string; - side: "source" | "target"; -}) { - const { bbox, spacing } = geometry; - const width = bbox.maxX - bbox.minX; - const height = bbox.maxY - bbox.minY; - - // The arrow tip is at frame origin (viewBox x=0), which sits `bbox.maxX` from - // the SVG's right edge. To land the tip `spacing` in from the node boundary, - // offset the SVG edge by `spacing - bbox.maxX`. (maxX is 0 for most shapes; - // for circle/circle-triangle the shape pokes past the tip so maxX > 0.) - const edgeOffset = spacing - bbox.maxX; - return ( - - {geometry.primitives.map((primitive, i) => ( - - ))} - - ); -} diff --git a/packages/graph-explorer/src/components/EdgePreview/index.ts b/packages/graph-explorer/src/components/EdgePreview/index.ts index 76b0073d6..472dabdc1 100644 --- a/packages/graph-explorer/src/components/EdgePreview/index.ts +++ b/packages/graph-explorer/src/components/EdgePreview/index.ts @@ -1,2 +1,3 @@ export { EdgePreview } from "./EdgePreview"; +export { EdgeLinePreview } from "./EdgeLinePreview"; export { ArrowStyleIcon } from "./ArrowStyleIcon"; diff --git a/packages/graph-explorer/src/modules/EntityDetails/EdgeDetail.tsx b/packages/graph-explorer/src/modules/EntityDetails/EdgeDetail.tsx index dec46cc39..bb1cd930a 100644 --- a/packages/graph-explorer/src/modules/EntityDetails/EdgeDetail.tsx +++ b/packages/graph-explorer/src/modules/EntityDetails/EdgeDetail.tsx @@ -1,10 +1,7 @@ -import type { CSSProperties } from "react"; - -import { ArrowStyleIcon, EdgeRow, VertexRow } from "@/components"; +import { EdgeLinePreview, EdgeRow, VertexRow } from "@/components"; import { type DisplayAttribute, type DisplayEdge, - type LineStyle, useDisplayVertex, useEdgeStyle, } from "@/core"; @@ -75,21 +72,12 @@ const EdgeDetail = ({ edge }: EdgeDetailProps) => { target={targetVertex} className="col-span-2" /> -
- -
- +
@@ -107,37 +95,4 @@ const EdgeDetail = ({ edge }: EdgeDetailProps) => { ); }; -const DEFAULT_LINE_COLOR = "#b3b3b3"; - -/** Maps the line style to the CSS properties for the line representation. */ -function styleForLineStyle(style: LineStyle): CSSProperties { - if (style === "solid") { - return { - background: "currentColor", - }; - } - - if (style === "dashed") { - return { - backgroundImage: - "linear-gradient(currentColor 70%, rgba(255, 255, 255, 0) 0%)", - backgroundPosition: "right", - backgroundSize: "2px 10px", - backgroundRepeat: "repeat-y", - }; - } - - if (style === "dotted") { - return { - backgroundImage: - "linear-gradient(currentColor 50%, rgba(255, 255, 255, 0) 0%)", - backgroundPosition: "right", - backgroundSize: "2px 6px", - backgroundRepeat: "repeat-y", - }; - } - - return {}; -} - export default EdgeDetail;