Skip to content
37 changes: 0 additions & 37 deletions packages/graph-explorer/src/components/VertexIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { CSSProperties } from "react";

import DOMPurify from "dompurify";
import { DynamicIcon } from "lucide-react/dynamic";
import SVG from "react-inlinesvg";
Expand All @@ -8,8 +6,6 @@ import { useVertexStyle, type VertexStyle, type VertexType } from "@/core";
import { cn } from "@/utils";
import { getLucideName, isValidLucideIconName } from "@/utils/lucideIcons";

import { SearchResultSymbol } from "./SearchResult";

function sanitizeSvg(svg: string): string {
return DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
Expand Down Expand Up @@ -75,37 +71,4 @@ export function VertexIconByType({
return <VertexIcon vertexStyle={vertexStyle} className={className} />;
}

export function VertexSymbol({
vertexStyle,
className,
}: {
vertexStyle: VertexStyle;
className?: string;
}) {
return (
<SearchResultSymbol
className={cn("text-primary-main bg-(--bg-color)", className)}
style={
{
// Defines the background color with the configured amount of transparency added
"--bg-color": `color-mix(in srgb, ${vertexStyle.color} ${vertexStyle.backgroundOpacity * 100}%, transparent)`,
} as CSSProperties
}
>
<VertexIcon vertexStyle={vertexStyle} className="size-full" />
</SearchResultSymbol>
);
}

export function VertexSymbolByType({
vertexType,
className,
}: {
vertexType: VertexType;
className?: string;
}) {
const vertexStyle = useVertexStyle(vertexType);
return <VertexSymbol vertexStyle={vertexStyle} className={className} />;
}

export default VertexIcon;
116 changes: 116 additions & 0 deletions packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useId } from "react";

import { useVertexStyle, type VertexStyle, type VertexType } from "@/core";
import { cn } from "@/utils";

import { resolveShapeGeometry } from "./nodeShapes";
import { useIconDataUrl } from "./useIconDataUrl";

const VIEWBOX = 96;
const ICON_RATIO = 0.6;
const CANVAS_NODE_SIZE = 24;
const BORDER_SCALE = VIEWBOX / CANVAS_NODE_SIZE;

interface Props {
vertexStyle: VertexStyle;
className?: string;
}

export function VertexSymbol({ vertexStyle, className }: Props) {
const iconDataUrl = useIconDataUrl(vertexStyle);
// SVG url(#...) references reject the colons in React's raw useId format.
const clipId = `vs-${useId().replace(/:/g, "")}`;
const strokeWidth = vertexStyle.borderWidth * BORDER_SCALE;
const insetSize = Math.max(1, VIEWBOX - strokeWidth * 2);
const geometry = resolveShapeGeometry(vertexStyle.shape, insetSize);

const iconSize = VIEWBOX * ICON_RATIO;
const iconOffset = (VIEWBOX - iconSize) / 2;

// The shape is rendered twice: once filled/stroked, once as the icon's
// clipPath. clipPath children must be shape elements directly — a wrapping
// <g> is ignored by the spec and yields an empty clip — so the inset
// transform is carried by the shape element itself.
const transform = `translate(${strokeWidth} ${strokeWidth})`;
const shapeEl = renderShape(geometry, insetSize, transform);

return (
<svg
viewBox={`0 0 ${VIEWBOX} ${VIEWBOX}`}
className={cn("size-9 shrink-0", className)}
role="img"
aria-label={`${vertexStyle.displayLabel ?? vertexStyle.type} symbol`}
>
<defs>
<clipPath id={clipId}>{shapeEl}</clipPath>
</defs>
<g
fill={vertexStyle.color}
fillOpacity={vertexStyle.backgroundOpacity}
stroke={strokeWidth > 0 ? vertexStyle.borderColor : "none"}
strokeWidth={strokeWidth}
strokeDasharray={strokeDash(vertexStyle.borderStyle)}
>
{shapeEl}
</g>
{iconDataUrl ? (
<image
href={iconDataUrl}
x={iconOffset}
y={iconOffset}
width={iconSize}
height={iconSize}
clipPath={`url(#${clipId})`}
preserveAspectRatio="xMidYMid meet"
/>
) : null}
</svg>
);
}

export function VertexSymbolByType({
vertexType,
className,
}: {
vertexType: VertexType;
className?: string;
}) {
const vertexStyle = useVertexStyle(vertexType);
return <VertexSymbol vertexStyle={vertexStyle} className={className} />;
}

function strokeDash(style: string): string | undefined {
if (style === "dashed") return "6 4";
if (style === "dotted") return "2 3";
return undefined;
}

function renderShape(
geometry: ReturnType<typeof resolveShapeGeometry>,
size: number,
transform: string,
) {
switch (geometry.type) {
case "polygon":
return <polygon points={geometry.points} transform={transform} />;
case "round-polygon":
case "round-rectangle":
case "cut-rectangle":
case "barrel":
return <path d={geometry.path} transform={transform} />;
case "ellipse":
return (
<ellipse
cx={size / 2}
cy={size / 2}
rx={size / 2}
ry={size / 2}
transform={transform}
/>
);
default: {
const _exhaustive: never = geometry;
return _exhaustive;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { VertexSymbol, VertexSymbolByType } from "./VertexSymbol";
128 changes: 128 additions & 0 deletions packages/graph-explorer/src/components/VertexSymbol/nodeShapes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";

import { resolveShapeGeometry } from "./nodeShapes";

describe("resolveShapeGeometry", () => {
const SIZE = 96;

describe("sharp polygons", () => {
it.each([
"triangle",
"rectangle",
"square",
"pentagon",
"hexagon",
"heptagon",
"octagon",
"diamond",
"star",
"vee",
"rhomboid",
"tag",
"concave-hexagon",
])("%s resolves to a polygon with valid points", shape => {
const result = resolveShapeGeometry(shape, SIZE);
expect.assert(result.type === "polygon");
expect(result.points.length).toBeGreaterThan(0);
const points = result.points;
const coords = points.split(" ").map(p => p.split(",").map(Number));
for (const [x, y] of coords) {
expect(x).toBeGreaterThanOrEqual(-0.01);
expect(x).toBeLessThanOrEqual(SIZE + 0.01);
expect(y).toBeGreaterThanOrEqual(-0.01);
expect(y).toBeLessThanOrEqual(SIZE + 0.01);
}
});
});

describe("round polygons", () => {
it.each([
"round-triangle",
"round-pentagon",
"round-hexagon",
"round-heptagon",
"round-octagon",
"round-diamond",
"round-tag",
])("%s resolves to a round-polygon with arcs", shape => {
const result = resolveShapeGeometry(shape, SIZE);
expect.assert(result.type === "round-polygon");
expect(result.path).toMatch(/^M /);
expect(result.path).toMatch(/ Z$/);
expect(result.path).toMatch(/ A /);
});
});

describe("round-rectangle", () => {
it.each(["round-rectangle", "roundrectangle"])(
"%s resolves to round-rectangle",
shape => {
const result = resolveShapeGeometry(shape, SIZE);
expect.assert(result.type === "round-rectangle");
expect(result.path).toMatch(/^M /);
expect(result.path).toMatch(/ A /);
},
);
});

describe("cut-rectangle", () => {
it("resolves with chamfered corners", () => {
const result = resolveShapeGeometry("cut-rectangle", SIZE);
expect.assert(result.type === "cut-rectangle");
expect(result.path).toMatch(/^M /);
expect(result.path).toMatch(/ Z$/);
expect(result.path).not.toMatch(/ A /);
});
});

describe("barrel", () => {
it("resolves with quadratic curves", () => {
const result = resolveShapeGeometry("barrel", SIZE);
expect.assert(result.type === "barrel");
expect(result.path).toMatch(/^M /);
expect(result.path).toMatch(/ Q /);
});
});

describe("ellipse", () => {
it("resolves for ellipse and unknown shapes", () => {
expect(resolveShapeGeometry("ellipse", SIZE).type).toBe("ellipse");
expect(resolveShapeGeometry("unknown-shape", SIZE).type).toBe("ellipse");
});
});

describe("all SHAPE_STYLES values produce geometry", () => {
const ALL_SHAPES = [
"rectangle",
"roundrectangle",
"ellipse",
"triangle",
"pentagon",
"hexagon",
"heptagon",
"octagon",
"star",
"barrel",
"diamond",
"vee",
"rhomboid",
"tag",
"round-rectangle",
"round-triangle",
"round-diamond",
"round-pentagon",
"round-hexagon",
"round-heptagon",
"round-octagon",
"round-tag",
"cut-rectangle",
"concave-hexagon",
];

it.each(ALL_SHAPES)("%s resolves without error", shape => {
const result = resolveShapeGeometry(shape, SIZE);
expect(result).toBeDefined();
expect(result.type).toBeTruthy();
});
});
});
Loading
Loading