diff --git a/packages/graph-explorer/src/components/VertexIcon.tsx b/packages/graph-explorer/src/components/VertexIcon.tsx index 6165d70f9..bea91d8a2 100644 --- a/packages/graph-explorer/src/components/VertexIcon.tsx +++ b/packages/graph-explorer/src/components/VertexIcon.tsx @@ -1,5 +1,3 @@ -import type { CSSProperties } from "react"; - import DOMPurify from "dompurify"; import { DynamicIcon } from "lucide-react/dynamic"; import SVG from "react-inlinesvg"; @@ -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 }, @@ -75,37 +71,4 @@ export function VertexIconByType({ return ; } -export function VertexSymbol({ - vertexStyle, - className, -}: { - vertexStyle: VertexStyle; - className?: string; -}) { - return ( - - - - ); -} - -export function VertexSymbolByType({ - vertexType, - className, -}: { - vertexType: VertexType; - className?: string; -}) { - const vertexStyle = useVertexStyle(vertexType); - return ; -} - export default VertexIcon; diff --git a/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx b/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx new file mode 100644 index 000000000..2803c26ca --- /dev/null +++ b/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx @@ -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 + // 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 ( + + + {shapeEl} + + 0 ? vertexStyle.borderColor : "none"} + strokeWidth={strokeWidth} + strokeDasharray={strokeDash(vertexStyle.borderStyle)} + > + {shapeEl} + + {iconDataUrl ? ( + + ) : null} + + ); +} + +export function VertexSymbolByType({ + vertexType, + className, +}: { + vertexType: VertexType; + className?: string; +}) { + const vertexStyle = useVertexStyle(vertexType); + return ; +} + +function strokeDash(style: string): string | undefined { + if (style === "dashed") return "6 4"; + if (style === "dotted") return "2 3"; + return undefined; +} + +function renderShape( + geometry: ReturnType, + size: number, + transform: string, +) { + switch (geometry.type) { + case "polygon": + return ; + case "round-polygon": + case "round-rectangle": + case "cut-rectangle": + case "barrel": + return ; + case "ellipse": + return ( + + ); + default: { + const _exhaustive: never = geometry; + return _exhaustive; + } + } +} diff --git a/packages/graph-explorer/src/components/VertexSymbol/index.ts b/packages/graph-explorer/src/components/VertexSymbol/index.ts new file mode 100644 index 000000000..e1f2e105e --- /dev/null +++ b/packages/graph-explorer/src/components/VertexSymbol/index.ts @@ -0,0 +1 @@ +export { VertexSymbol, VertexSymbolByType } from "./VertexSymbol"; diff --git a/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.test.ts b/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.test.ts new file mode 100644 index 000000000..76baeabdc --- /dev/null +++ b/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.ts b/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.ts new file mode 100644 index 000000000..b7ad7e82c --- /dev/null +++ b/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.ts @@ -0,0 +1,388 @@ +/** + * Verbatim port of cytoscape 3.34.0's node-shape geometry so an SVG preview + * draws the exact same outlines cytoscape rasterizes to canvas. All shapes in + * SHAPE_STYLES are covered: sharp polygons, round polygons, round-rectangle, + * cut-rectangle, barrel, and ellipse (which needs no geometry — it's native SVG). + * + * Points live in a [-1,1] unit box; consumers map to display coordinates via + * `toSvgPoints` or the path builders (`getRoundPolygonPath`, `getBarrelPath`, + * `getCutRectanglePath`). + */ + +type Points = number[]; + +// --- Unit polygon generation (from cytoscape math.mjs) --- + +function generateUnitNgonPoints( + sides: number, + rotationRadians: number, +): Points { + const increment = (1.0 / sides) * 2 * Math.PI; + let startAngle = + sides % 2 === 0 ? Math.PI / 2.0 + increment / 2.0 : Math.PI / 2.0; + startAngle += rotationRadians; + const points: number[] = Array.from({ length: sides * 2 }); + for (let i = 0; i < sides; i++) { + const currentAngle = i * increment + startAngle; + points[2 * i] = Math.cos(currentAngle); + points[2 * i + 1] = Math.sin(-currentAngle); + } + return points; +} + +function fitPolygonToSquare(points: Points): Points { + const sides = points.length / 2; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (let i = 0; i < sides; i++) { + const x = points[2 * i]; + const y = points[2 * i + 1]; + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + const sx = 2 / (maxX - minX); + const sy = 2 / (maxY - minY); + minY = Infinity; + for (let i = 0; i < sides; i++) { + points[2 * i] = points[2 * i] * sx; + points[2 * i + 1] = points[2 * i + 1] * sy; + minY = Math.min(minY, points[2 * i + 1]); + } + if (minY < -1) { + for (let i = 0; i < sides; i++) { + points[2 * i + 1] = points[2 * i + 1] + (-1 - minY); + } + } + return points; +} + +function ngon(sides: number, rotation = 0): Points { + return fitPolygonToSquare(generateUnitNgonPoints(sides, rotation)); +} + +function star5(): Points { + const outer = generateUnitNgonPoints(5, 0); + const inner = generateUnitNgonPoints(5, Math.PI / 5); + let innerRadius = 0.5 * (3 - Math.sqrt(5)); + innerRadius *= 1.57; + for (let i = 0; i < inner.length / 2; i++) { + inner[i * 2] *= innerRadius; + inner[i * 2 + 1] *= innerRadius; + } + const pts: number[] = Array.from({ length: 20 }); + for (let i = 0; i < 20 / 4; i++) { + pts[i * 4] = outer[i * 2]; + pts[i * 4 + 1] = outer[i * 2 + 1]; + pts[i * 4 + 2] = inner[i * 2]; + pts[i * 4 + 3] = inner[i * 2 + 1]; + } + return fitPolygonToSquare(pts); +} + +// --- Sharp polygon point lists --- + +const POLYGON_POINTS: Record = { + triangle: ngon(3, 0), + rectangle: ngon(4, 0), + square: ngon(4, 0), + pentagon: ngon(5, 0), + hexagon: ngon(6, 0), + heptagon: ngon(7, 0), + octagon: ngon(8, 0), + diamond: [0, -1, 1, 0, 0, 1, -1, 0], + star: star5(), + vee: fitPolygonToSquare([-1, -1, 0, -0.333, 1, -1, 0, 1]), + rhomboid: fitPolygonToSquare([-1, -1, 0.333, -1, 1, 1, -0.333, 1]), + tag: fitPolygonToSquare([-1, -1, 0.25, -1, 1, 0, 0.25, 1, -1, 1]), + "concave-hexagon": fitPolygonToSquare([ + -1, -0.95, -0.75, 0, -1, 0.95, 1, 0.95, 0.75, 0, 1, -0.95, + ]), +}; + +/** Maps a [-1,1] point list to an SVG `points` string in a [0,size] box. */ +export function toSvgPoints(points: Points, size: number): string { + const half = size / 2; + const parts: string[] = []; + for (let i = 0; i < points.length / 2; i++) { + const x = half + points[2 * i] * half; + const y = half + points[2 * i + 1] * half; + parts.push(`${x.toFixed(3)},${y.toFixed(3)}`); + } + return parts.join(" "); +} + +export function getPolygonPoints(shape: string): Points | null { + return POLYGON_POINTS[shape] ?? null; +} + +// --- Round polygons --- + +const ROUND_POLYGON_SHAPES = new Set([ + "round-triangle", + "round-pentagon", + "round-hexagon", + "round-heptagon", + "round-octagon", + "round-diamond", + "round-tag", +]); + +type Point = { x: number; y: number }; + +type RoundCorner = { + cx: number; + cy: number; + radius: number; + startX: number; + startY: number; + stopX: number; + stopY: number; + counterClockwise: boolean; +}; + +type Vector = { len: number; nx: number; ny: number }; + +function unitVector(from: Point, to: Point): Vector { + const x = to.x - from.x; + const y = to.y - from.y; + const len = Math.sqrt(x * x + y * y); + return { len, nx: x / len, ny: y / len }; +} + +function getRoundCorner( + previous: Point, + current: Point, + next: Point, + radiusMax: number, +): RoundCorner { + const v1 = unitVector(current, previous); + const v2 = unitVector(current, next); + const sinA = v1.nx * v2.ny - v1.ny * v2.nx; + const sinA90 = v1.nx * v2.nx - v1.ny * -v2.ny; + let angle = Math.asin(Math.max(-1, Math.min(1, sinA))); + if (Math.abs(angle) < 1e-6) { + return { + cx: current.x, + cy: current.y, + radius: 0, + startX: current.x, + startY: current.y, + stopX: current.x, + stopY: current.y, + counterClockwise: false, + }; + } + + let radDirection = 1; + let drawDirection = false; + if (sinA90 < 0) { + if (angle < 0) { + angle = Math.PI + angle; + } else { + angle = Math.PI - angle; + radDirection = -1; + drawDirection = true; + } + } else if (angle > 0) { + radDirection = -1; + drawDirection = true; + } + + const halfAngle = angle / 2; + const limit = Math.min(v1.len / 2, v2.len / 2); + let lenOut = Math.abs( + (Math.cos(halfAngle) * radiusMax) / Math.sin(halfAngle), + ); + let cRadius: number; + if (lenOut > limit) { + lenOut = limit; + cRadius = Math.abs((lenOut * Math.sin(halfAngle)) / Math.cos(halfAngle)); + } else { + cRadius = radiusMax; + } + + const stopX = current.x + v2.nx * lenOut; + const stopY = current.y + v2.ny * lenOut; + return { + cx: stopX - v2.ny * cRadius * radDirection, + cy: stopY + v2.nx * cRadius * radDirection, + radius: cRadius, + startX: current.x + v1.nx * lenOut, + startY: current.y + v1.ny * lenOut, + stopX, + stopY, + counterClockwise: drawDirection, + }; +} + +function formatCoord(x: number, y: number): string { + return `${x.toFixed(3)},${y.toFixed(3)}`; +} + +/** + * SVG path for a round-cornered polygon in a [0,size] box. Corner radius + * mirrors cytoscape's `getRoundPolygonRadius` (size/10), without the absolute + * 8px cap so the preview stays proportional at larger display sizes. + */ +export function getRoundPolygonPath( + shape: string, + size: number, +): string | null { + if (!ROUND_POLYGON_SHAPES.has(shape)) { + return null; + } + const basePoints = POLYGON_POINTS[shape.slice("round-".length)]; + if (!basePoints) { + return null; + } + + const half = size / 2; + const cornerRadius = size / 10; + const vertices: Point[] = []; + for (let i = 0; i < basePoints.length / 2; i++) { + vertices.push({ + x: half + basePoints[2 * i] * half, + y: half + basePoints[2 * i + 1] * half, + }); + } + + const count = vertices.length; + const segments: string[] = []; + for (let i = 0; i < count; i++) { + const corner = getRoundCorner( + vertices[(i - 1 + count) % count], + vertices[i], + vertices[(i + 1) % count], + cornerRadius, + ); + segments.push( + `${i === 0 ? "M" : "L"} ${formatCoord(corner.startX, corner.startY)}`, + ); + if (corner.radius === 0) { + segments.push(`L ${formatCoord(corner.stopX, corner.stopY)}`); + } else { + const sweep = corner.counterClockwise ? 0 : 1; + segments.push( + `A ${corner.radius.toFixed(3)} ${corner.radius.toFixed(3)} 0 0 ${sweep} ${formatCoord(corner.stopX, corner.stopY)}`, + ); + } + } + segments.push("Z"); + return segments.join(" "); +} + +// --- Round rectangle --- + +/** + * SVG path for a round-rectangle in a [0,size] box. Corner radius matches + * cytoscape's `getRoundRectangleRadius`: min(w/4, h/4, 8). Since the viewBox + * is square and proportional, we drop the absolute 8px cap (it would shrink + * relative to the rendered box at large sizes). + */ +export function getRoundRectanglePath(size: number): string { + const r = size / 4; + return [ + `M ${formatCoord(r, 0)}`, + `L ${formatCoord(size - r, 0)}`, + `A ${r.toFixed(3)} ${r.toFixed(3)} 0 0 1 ${formatCoord(size, r)}`, + `L ${formatCoord(size, size - r)}`, + `A ${r.toFixed(3)} ${r.toFixed(3)} 0 0 1 ${formatCoord(size - r, size)}`, + `L ${formatCoord(r, size)}`, + `A ${r.toFixed(3)} ${r.toFixed(3)} 0 0 1 ${formatCoord(0, size - r)}`, + `L ${formatCoord(0, r)}`, + `A ${r.toFixed(3)} ${r.toFixed(3)} 0 0 1 ${formatCoord(r, 0)}`, + "Z", + ].join(" "); +} + +// --- Cut rectangle --- + +/** + * SVG path for a cut-rectangle (chamfered corners) in a [0,size] box. Corner + * length matches cytoscape's `getCutRectangleCornerLength()` = 8, scaled + * proportionally to the viewBox. + */ +export function getCutRectanglePath(size: number): string { + const cl = size * (8 / 24); + return [ + `M ${formatCoord(cl, 0)}`, + `L ${formatCoord(size - cl, 0)}`, + `L ${formatCoord(size, cl)}`, + `L ${formatCoord(size, size - cl)}`, + `L ${formatCoord(size - cl, size)}`, + `L ${formatCoord(cl, size)}`, + `L ${formatCoord(0, size - cl)}`, + `L ${formatCoord(0, cl)}`, + "Z", + ].join(" "); +} + +// --- Barrel --- + +/** + * SVG path for a barrel shape in a [0,size] box. Matches cytoscape's barrel + * draw: straight vertical sides with quadratic bezier curved top/bottom. + * Constants from `getBarrelCurveConstants(width, height)`. + */ +export function getBarrelPath(size: number): string { + const hOffset = Math.min(15, 0.05 * size); + const wOffset = Math.min(100, 0.25 * size); + const ctrlPtXOffset = 0.05 * wOffset; + + return [ + `M ${formatCoord(0, hOffset)}`, + `L ${formatCoord(0, size - hOffset)}`, + `Q ${formatCoord(ctrlPtXOffset, size)} ${formatCoord(wOffset, size)}`, + `L ${formatCoord(size - wOffset, size)}`, + `Q ${formatCoord(size - ctrlPtXOffset, size)} ${formatCoord(size, size - hOffset)}`, + `L ${formatCoord(size, hOffset)}`, + `Q ${formatCoord(size - ctrlPtXOffset, 0)} ${formatCoord(size - wOffset, 0)}`, + `L ${formatCoord(wOffset, 0)}`, + `Q ${formatCoord(ctrlPtXOffset, 0)} ${formatCoord(0, hOffset)}`, + "Z", + ].join(" "); +} + +// --- Shape classification --- + +export type ShapeKind = + | { type: "polygon"; points: string } + | { type: "round-polygon"; path: string } + | { type: "round-rectangle"; path: string } + | { type: "cut-rectangle"; path: string } + | { type: "barrel"; path: string } + | { type: "ellipse" }; + +/** + * Resolves a cytoscape shape name into its SVG rendering geometry at the given + * box size. Covers all 24 SHAPE_STYLES values. + */ +export function resolveShapeGeometry(shape: string, size: number): ShapeKind { + const polygon = getPolygonPoints(shape); + if (polygon) { + return { type: "polygon", points: toSvgPoints(polygon, size) }; + } + + const roundPath = getRoundPolygonPath(shape, size); + if (roundPath) { + return { type: "round-polygon", path: roundPath }; + } + + if (shape === "roundrectangle" || shape === "round-rectangle") { + return { type: "round-rectangle", path: getRoundRectanglePath(size) }; + } + + if (shape === "cut-rectangle") { + return { type: "cut-rectangle", path: getCutRectanglePath(size) }; + } + + if (shape === "barrel") { + return { type: "barrel", path: getBarrelPath(size) }; + } + + return { type: "ellipse" }; +} diff --git a/packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts b/packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts new file mode 100644 index 000000000..d552b5eae --- /dev/null +++ b/packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import type { VertexStyle } from "@/core"; + +import { renderNode } from "@/modules/GraphViewer/renderNode"; + +/** + * Runs the production icon pipeline (fetch → sanitize → recolor → data URL) + * via the same TanStack Query cache the canvas uses, so icons are deduped and + * load instantly after the first fetch. + */ +export function useIconDataUrl(style: VertexStyle): string | null { + const { type, iconUrl, iconImageType, color } = style; + const { data } = useQuery({ + queryKey: ["vertex-symbol-icon", type, iconUrl, iconImageType, color], + queryFn: ({ client }) => + renderNode(client, { type, iconUrl, iconImageType, color }), + staleTime: Infinity, + enabled: !!iconUrl, + }); + return data ?? null; +} diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index dd11d06e3..b6b9bddaa 100644 --- a/packages/graph-explorer/src/components/index.ts +++ b/packages/graph-explorer/src/components/index.ts @@ -96,6 +96,8 @@ export * from "./Typography"; export { default as VertexIcon } from "./VertexIcon"; export * from "./VertexIcon"; +export * from "./VertexSymbol"; + export * from "./VertexRow"; export * from "./Workspace"; diff --git a/packages/graph-explorer/src/modules/NodesStyling/nodeShape.ts b/packages/graph-explorer/src/modules/NodesStyling/nodeShape.ts index 36931a92a..47cddd165 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/nodeShape.ts +++ b/packages/graph-explorer/src/modules/NodesStyling/nodeShape.ts @@ -1,4 +1,8 @@ -export const NODE_SHAPE = [ +import type { ShapeStyle } from "@/core"; + +type LabeledShapeStyle = { label: string; value: ShapeStyle }; + +export const NODE_SHAPE: LabeledShapeStyle[] = [ { label: "Barrel", value: "barrel" }, { label: "Concave Hexagon", value: "concave-hexagon" }, { label: "Cut Rectangle", value: "cut-rectangle" },