From 39a7c532cb0e279a7c6483f329bf3aa1debe4042 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 2 Jul 2026 16:30:07 -0500 Subject: [PATCH 1/8] Spike: SVG node preview proving cytoscape geometry parity --- docs/spikes/node-preview-spike.md | 44 ++++ .../modules/NodesStyling/NodeStyleDialog.tsx | 2 + .../modules/NodesStyling/SpikeNodePreview.tsx | 209 ++++++++++++++++++ .../modules/NodesStyling/spikeNodeShapes.ts | 115 ++++++++++ 4 files changed, 370 insertions(+) create mode 100644 docs/spikes/node-preview-spike.md create mode 100644 packages/graph-explorer/src/modules/NodesStyling/SpikeNodePreview.tsx create mode 100644 packages/graph-explorer/src/modules/NodesStyling/spikeNodeShapes.ts diff --git a/docs/spikes/node-preview-spike.md b/docs/spikes/node-preview-spike.md new file mode 100644 index 000000000..f51e54f95 --- /dev/null +++ b/docs/spikes/node-preview-spike.md @@ -0,0 +1,44 @@ +# Spike — DOM/SVG node preview vs cytoscape canvas + +**Branch:** `spike/node-preview` (throwaway; files prefixed `Spike`/`spike`) +**Question:** Can a DOM/SVG preview render a vertex style faithfully enough to +match cytoscape's canvas node, for use in the style dialogs, search results, +legend, node details, etc.? + +## Verdict: GO (Approach A) + +Rendering shapes as SVG using **cytoscape's own point geometry** works. The +prior failed attempts hand-authored shape paths by eye; the unlock is porting +cytoscape 3.34's `generateUnitNgonPoints` + `fitPolygonToSquare` (and its +hardcoded special-shape point lists: star, vee, rhomboid, tag, concave-hexagon) +verbatim, so the SVG `` uses the identical points cytoscape rasterizes. +See `packages/graph-explorer/src/modules/NodesStyling/spikeNodeShapes.ts`. + +Confirmed matching against canvas: sharp polygons (triangle…octagon), diamond, +star, ellipse, rectangles. + +## Key mechanism learned: icon clipping, not sizing + +Cytoscape sizes the icon to fill the node box and relies on +`background-clip: 'node'` to clip the overflow to the shape outline. The preview +must do the same: render the shape into an SVG `` and clip the icon +`` (sized to the full box) to it. Sizing the icon smaller ("contain") is +wrong — it looks nothing like the canvas. + +## Open product findings (decide when building the real NodePreview) + +1. **Round-\* shapes.** `generateRoundPolygon` (rounded-corner variants of the + sharp polygons) was not ported in the spike — round-octagon/pentagon/etc. + fall back to an ellipse. Portable to fix. BUT: verify in shipping code + whether the canvas even renders these variants distinctly and whether users + pick them. If low value, **curate `NODE_SHAPE`** down instead of building + preview geometry for shapes nobody uses. +2. **Icon overflow is faithful, but is it desired?** A tall icon fills the box + and gets clipped to the shape — so on a diamond the icon corners are cut off. + The preview reproduces this exactly. Whether the _graph itself_ should inset + icons is a separate, out-of-scope product question. + +## Fidelity gate + +Judged by eyeballing both preview cells against the live canvas node in the +style dialog — no pixel-diff harness. Sufficient for go/no-go. diff --git a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx index e80c90f1a..9414fda4d 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx @@ -45,6 +45,7 @@ import { createDisplayError } from "@/utils/createDisplayError"; import { LINE_STYLE_OPTIONS } from "./lineStyling"; import { NODE_SHAPE } from "./nodeShape"; +import { SpikeNodePreview } from "./SpikeNodePreview"; const customizeNodeTypeAtom = atom(undefined); @@ -145,6 +146,7 @@ function Content({ vertexType }: { vertexType: VertexType }) { +
diff --git a/packages/graph-explorer/src/modules/NodesStyling/SpikeNodePreview.tsx b/packages/graph-explorer/src/modules/NodesStyling/SpikeNodePreview.tsx new file mode 100644 index 000000000..6589024a6 --- /dev/null +++ b/packages/graph-explorer/src/modules/NodesStyling/SpikeNodePreview.tsx @@ -0,0 +1,209 @@ +/** + * SPIKE — throwaway. Renders the same vertex style two ways, side by side: + * - left: an SVG preview driven by cytoscape's own shape geometry + * - right: a real cytoscape instance rendering one node + * Mounted at the top of the node style dialog so every style permutation can be + * eyeballed against ground truth. Delete with the rest of the spike. + */ +import { useQueryClient } from "@tanstack/react-query"; +import cytoscape, { type Core } from "cytoscape"; +import { useEffect, useRef, useState } from "react"; + +import type { VertexStyle } from "@/core"; + +import { renderNode } from "@/modules/GraphViewer/renderNode"; + +import { getPolygonPoints, toSvgPoints } from "./spikeNodeShapes"; + +const SIZE = 96; + +export function SpikeNodePreview({ style }: { style: VertexStyle }) { + return ( +
+ + + + + + +
+ ); +} + +function PreviewCell({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
+ {children} +
+ {label} +
+ ); +} + +function SvgNodePreview({ style }: { style: VertexStyle }) { + const iconDataUrl = useStyledIconDataUrl(style); + const polygon = getPolygonPoints(style.shape); + const strokeDash = + style.borderStyle === "dashed" + ? "6 4" + : style.borderStyle === "dotted" + ? "2 3" + : undefined; + + const fill = style.color; + const fillOpacity = style.backgroundOpacity; + const stroke = style.borderWidth > 0 ? style.borderColor : "none"; + const strokeWidth = style.borderWidth; + const clipId = `spike-clip-${style.type}`; + + // The shape element, rendered twice: once as the filled/stroked background, + // once (fill only) as the clip path so the icon is clipped to the outline — + // matching cytoscape's default `background-clip: 'node'`. + const shapeEl = polygon ? ( + + ) : isRoundRectangle(style.shape) ? ( + + ) : ( + + ); + + return ( + + + {shapeEl} + + {/* Background fill + border */} + + {shapeEl} + + {/* Icon fills the node box, clipped to the shape (as cytoscape does) */} + {iconDataUrl ? ( + + ) : null} + + ); +} + +function isRoundRectangle(shape: string) { + return ( + shape === "roundrectangle" || + shape === "round-rectangle" || + shape === "barrel" || + shape === "cut-rectangle" + ); +} + +function CytoscapeNodePreview({ style }: { style: VertexStyle }) { + const containerRef = useRef(null); + const cyRef = useRef(null); + const iconDataUrl = useStyledIconDataUrl(style); + + useEffect(() => { + if (!containerRef.current) return; + const cy = cytoscape({ + container: containerRef.current, + style: [], + userZoomingEnabled: false, + userPanningEnabled: false, + autoungrabify: true, + }); + cy.add({ group: "nodes", data: { id: "n", type: "Preview" } }); + cyRef.current = cy; + return () => cy.destroy(); + }, []); + + useEffect(() => { + const cy = cyRef.current; + if (!cy) return; + cy.style([ + { + selector: "node", + style: { + "background-image": iconDataUrl ?? undefined, + "background-color": style.color, + "background-opacity": style.backgroundOpacity, + // In the graph the icon (24px) fills the node (24px). Reproduce that + // "fill the node" ratio at this larger preview size so the cell is + // faithful ground truth rather than a 24px icon lost on a 60px node. + "background-width": "100%", + "background-height": "100%", + "border-color": style.borderColor, + "border-width": style.borderWidth, + "border-opacity": style.borderWidth > 0 ? 1 : 0, + "border-style": style.borderStyle, + shape: style.shape as never, + width: 60, + height: 60, + }, + }, + ] as never); + cy.center(); + cy.zoom(1); + cy.center(); + }, [style, iconDataUrl]); + + return
; +} + +/** Runs the production icon pipeline (fetch → sanitize → recolor → data URL). */ +function useStyledIconDataUrl(style: VertexStyle): string | null { + const client = useQueryClient(); + const [dataUrl, setDataUrl] = useState(null); + useEffect(() => { + let active = true; + renderNode(client, { + type: style.type, + iconUrl: style.iconUrl, + iconImageType: style.iconImageType, + color: style.color, + }) + .then(result => { + if (active) setDataUrl(result); + }) + .catch(() => { + if (active) setDataUrl(null); + }); + return () => { + active = false; + }; + }, [client, style.iconUrl, style.iconImageType, style.color, style.type]); + return dataUrl; +} diff --git a/packages/graph-explorer/src/modules/NodesStyling/spikeNodeShapes.ts b/packages/graph-explorer/src/modules/NodesStyling/spikeNodeShapes.ts new file mode 100644 index 000000000..2131c1c5f --- /dev/null +++ b/packages/graph-explorer/src/modules/NodesStyling/spikeNodeShapes.ts @@ -0,0 +1,115 @@ +/** + * SPIKE — throwaway. Verbatim port of cytoscape 3.34.0's node-shape point + * generation (`generateUnitNgonPoints` / `fitPolygonToSquare`) plus its + * hardcoded special-shape point lists, so an SVG `` draws the exact + * same geometry cytoscape rasterizes to canvas. Points live in a [-1,1] box + * (x right, y down in SVG terms — cytoscape uses y-down too). + */ + +type Points = number[]; + +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); +} + +/** Polygon point lists keyed by cytoscape shape name. Non-polygon shapes + * (ellipse, the round-/cut-/barrel rectangles) are handled separately. */ +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; +} From 35514de0909f450a3c5806c1d88e66dfae0e24f6 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 10 Jul 2026 11:23:41 -0500 Subject: [PATCH 2/8] Port cytoscape round-polygon geometry into the spike SVG preview --- .../modules/NodesStyling/SpikeNodePreview.tsx | 27 ++- .../src/modules/NodesStyling/nodeShape.ts | 6 +- .../modules/NodesStyling/spikeNodeShapes.ts | 169 ++++++++++++++++++ 3 files changed, 196 insertions(+), 6 deletions(-) diff --git a/packages/graph-explorer/src/modules/NodesStyling/SpikeNodePreview.tsx b/packages/graph-explorer/src/modules/NodesStyling/SpikeNodePreview.tsx index 6589024a6..fbe70f9ab 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/SpikeNodePreview.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/SpikeNodePreview.tsx @@ -13,7 +13,11 @@ import type { VertexStyle } from "@/core"; import { renderNode } from "@/modules/GraphViewer/renderNode"; -import { getPolygonPoints, toSvgPoints } from "./spikeNodeShapes"; +import { + getPolygonPoints, + getRoundPolygonPath, + toSvgPoints, +} from "./spikeNodeShapes"; const SIZE = 96; @@ -66,14 +70,27 @@ function SvgNodePreview({ style }: { style: VertexStyle }) { const strokeWidth = style.borderWidth; const clipId = `spike-clip-${style.type}`; + const iconPadding = 38; + const iconSize = SIZE - iconPadding; + const iconPosition = iconPadding / 2; + // The shape element, rendered twice: once as the filled/stroked background, // once (fill only) as the clip path so the icon is clipped to the outline — // matching cytoscape's default `background-clip: 'node'`. + const roundPolygonPath = getRoundPolygonPath( + style.shape, + SIZE - strokeWidth * 2, + ); const shapeEl = polygon ? ( + ) : roundPolygonPath ? ( + ) : isRoundRectangle(style.shape) ? ( 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" }, diff --git a/packages/graph-explorer/src/modules/NodesStyling/spikeNodeShapes.ts b/packages/graph-explorer/src/modules/NodesStyling/spikeNodeShapes.ts index 2131c1c5f..f2173fb45 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/spikeNodeShapes.ts +++ b/packages/graph-explorer/src/modules/NodesStyling/spikeNodeShapes.ts @@ -113,3 +113,172 @@ export function toSvgPoints(points: Points, size: number): string { export function getPolygonPoints(shape: string): Points | null { return POLYGON_POINTS[shape] ?? null; } + +// --- Round polygons --- + +/** + * The round-cornered polygon shapes. Each reuses its sharp counterpart's point + * list (cytoscape's `round-diamond` shares `diamond`'s points) and rounds the + * corners at draw time, so the name maps to the base shape by dropping the + * `round-` prefix. `roundrectangle` is not here — it's an SVG ``. + */ +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 }; +} + +/** + * Verbatim port of cytoscape 3.34's corner rounding (`round.mjs` + * `getRoundCorner` with its default `isArcRadius = true`), for one polygon + * vertex given its neighbours. Returns the arc that replaces the sharp corner: + * a circle center, radius, the arc's start/stop points, and its winding. + */ +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 formatPoint(x: number, y: number): string { + return `${x.toFixed(3)},${y.toFixed(3)}`; +} + +/** + * SVG path (`d`) for a round-cornered polygon in a [0,size] box, or null if the + * shape isn't a round polygon. The corner radius mirrors cytoscape's + * `getRoundPolygonRadius` (`size/10`) but drops its absolute 8px cap so the + * preview stays proportional when drawn larger than the graph's real node. + */ +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"} ${formatPoint(corner.startX, corner.startY)}`, + ); + if (corner.radius === 0) { + segments.push(`L ${formatPoint(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} ${formatPoint(corner.stopX, corner.stopY)}`, + ); + } + } + segments.push("Z"); + return segments.join(" "); +} From aebbfd7574b1fc33e5ff2efe1060b2d04bf81438 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 10 Jul 2026 13:28:56 -0500 Subject: [PATCH 3/8] Replace VertexSymbol with shape-faithful SVG renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fixed-circle VertexSymbol with an SVG renderer that draws the actual cytoscape node shape, fill, border, and icon — matching the canvas appearance across all 24 SHAPE_STYLES values. - Port cytoscape 3.34's geometry verbatim: sharp polygons, round polygons, round-rectangle, cut-rectangle, and barrel - Icon loaded through the production pipeline via TanStack Query (same cache as the canvas), clipped to the shape at 60% sizing - Proportional viewBox (96) with CSS-driven sizing via className - New deep module: components/VertexSymbol/ (VertexSymbol.tsx, nodeShapes.ts, useIconDataUrl.ts, nodeShapes.test.ts) - Delete spike scaffolding (SpikeNodePreview, spikeNodeShapes, spike doc) --- docs/spikes/node-preview-spike.md | 44 ---- .../src/components/VertexIcon.tsx | 37 --- .../components/VertexSymbol/VertexSymbol.tsx | 100 ++++++++ .../src/components/VertexSymbol/index.ts | 1 + .../VertexSymbol/nodeShapes.test.ts | 132 ++++++++++ .../VertexSymbol/nodeShapes.ts} | 158 +++++++++--- .../components/VertexSymbol/useIconDataUrl.ts | 22 ++ .../graph-explorer/src/components/index.ts | 2 + .../modules/NodesStyling/NodeStyleDialog.tsx | 2 - .../modules/NodesStyling/SpikeNodePreview.tsx | 226 ------------------ 10 files changed, 388 insertions(+), 336 deletions(-) delete mode 100644 docs/spikes/node-preview-spike.md create mode 100644 packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx create mode 100644 packages/graph-explorer/src/components/VertexSymbol/index.ts create mode 100644 packages/graph-explorer/src/components/VertexSymbol/nodeShapes.test.ts rename packages/graph-explorer/src/{modules/NodesStyling/spikeNodeShapes.ts => components/VertexSymbol/nodeShapes.ts} (58%) create mode 100644 packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts delete mode 100644 packages/graph-explorer/src/modules/NodesStyling/SpikeNodePreview.tsx diff --git a/docs/spikes/node-preview-spike.md b/docs/spikes/node-preview-spike.md deleted file mode 100644 index f51e54f95..000000000 --- a/docs/spikes/node-preview-spike.md +++ /dev/null @@ -1,44 +0,0 @@ -# Spike — DOM/SVG node preview vs cytoscape canvas - -**Branch:** `spike/node-preview` (throwaway; files prefixed `Spike`/`spike`) -**Question:** Can a DOM/SVG preview render a vertex style faithfully enough to -match cytoscape's canvas node, for use in the style dialogs, search results, -legend, node details, etc.? - -## Verdict: GO (Approach A) - -Rendering shapes as SVG using **cytoscape's own point geometry** works. The -prior failed attempts hand-authored shape paths by eye; the unlock is porting -cytoscape 3.34's `generateUnitNgonPoints` + `fitPolygonToSquare` (and its -hardcoded special-shape point lists: star, vee, rhomboid, tag, concave-hexagon) -verbatim, so the SVG `` uses the identical points cytoscape rasterizes. -See `packages/graph-explorer/src/modules/NodesStyling/spikeNodeShapes.ts`. - -Confirmed matching against canvas: sharp polygons (triangle…octagon), diamond, -star, ellipse, rectangles. - -## Key mechanism learned: icon clipping, not sizing - -Cytoscape sizes the icon to fill the node box and relies on -`background-clip: 'node'` to clip the overflow to the shape outline. The preview -must do the same: render the shape into an SVG `` and clip the icon -`` (sized to the full box) to it. Sizing the icon smaller ("contain") is -wrong — it looks nothing like the canvas. - -## Open product findings (decide when building the real NodePreview) - -1. **Round-\* shapes.** `generateRoundPolygon` (rounded-corner variants of the - sharp polygons) was not ported in the spike — round-octagon/pentagon/etc. - fall back to an ellipse. Portable to fix. BUT: verify in shipping code - whether the canvas even renders these variants distinctly and whether users - pick them. If low value, **curate `NODE_SHAPE`** down instead of building - preview geometry for shapes nobody uses. -2. **Icon overflow is faithful, but is it desired?** A tall icon fills the box - and gets clipped to the shape — so on a diamond the icon corners are cut off. - The preview reproduces this exactly. Whether the _graph itself_ should inset - icons is a separate, out-of-scope product question. - -## Fidelity gate - -Judged by eyeballing both preview cells against the live canvas node in the -style dialog — no pixel-diff harness. Sufficient for go/no-go. 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..32bc7b432 --- /dev/null +++ b/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx @@ -0,0 +1,100 @@ +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; + +interface Props { + vertexStyle: VertexStyle; + className?: string; +} + +export function VertexSymbol({ vertexStyle, className }: Props) { + const iconDataUrl = useIconDataUrl(vertexStyle); + const strokeWidth = vertexStyle.borderWidth; + const insetSize = VIEWBOX - strokeWidth * 2; + const geometry = resolveShapeGeometry(vertexStyle.shape, insetSize); + const clipId = `vs-clip-${vertexStyle.type}`; + + const iconSize = VIEWBOX * ICON_RATIO; + const iconOffset = (VIEWBOX - iconSize) / 2; + + const shapeEl = renderShape(geometry, insetSize); + + 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, +) { + switch (geometry.type) { + case "polygon": + return ; + case "round-polygon": + case "round-rectangle": + case "cut-rectangle": + case "barrel": + return ; + case "ellipse": + return ( + + ); + } +} 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..cd3e72f0e --- /dev/null +++ b/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.test.ts @@ -0,0 +1,132 @@ +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(result.type).toBe("polygon"); + expect("points" in result && result.points.length).toBeGreaterThan(0); + const points = (result as { points: string }).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(result.type).toBe("round-polygon"); + const path = (result as { path: string }).path; + expect(path).toMatch(/^M /); + expect(path).toMatch(/ Z$/); + expect(path).toMatch(/ A /); + }); + }); + + describe("round-rectangle", () => { + it.each(["round-rectangle", "roundrectangle"])( + "%s resolves to round-rectangle", + shape => { + const result = resolveShapeGeometry(shape, SIZE); + expect(result.type).toBe("round-rectangle"); + const path = (result as { path: string }).path; + expect(path).toMatch(/^M /); + expect(path).toMatch(/ A /); + }, + ); + }); + + describe("cut-rectangle", () => { + it("resolves with chamfered corners", () => { + const result = resolveShapeGeometry("cut-rectangle", SIZE); + expect(result.type).toBe("cut-rectangle"); + const path = (result as { path: string }).path; + expect(path).toMatch(/^M /); + expect(path).toMatch(/ Z$/); + expect(path).not.toMatch(/ A /); + }); + }); + + describe("barrel", () => { + it("resolves with quadratic curves", () => { + const result = resolveShapeGeometry("barrel", SIZE); + expect(result.type).toBe("barrel"); + const path = (result as { path: string }).path; + expect(path).toMatch(/^M /); + expect(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/modules/NodesStyling/spikeNodeShapes.ts b/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.ts similarity index 58% rename from packages/graph-explorer/src/modules/NodesStyling/spikeNodeShapes.ts rename to packages/graph-explorer/src/components/VertexSymbol/nodeShapes.ts index f2173fb45..b7ad7e82c 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/spikeNodeShapes.ts +++ b/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.ts @@ -1,13 +1,18 @@ /** - * SPIKE — throwaway. Verbatim port of cytoscape 3.34.0's node-shape point - * generation (`generateUnitNgonPoints` / `fitPolygonToSquare`) plus its - * hardcoded special-shape point lists, so an SVG `` draws the exact - * same geometry cytoscape rasterizes to canvas. Points live in a [-1,1] box - * (x right, y down in SVG terms — cytoscape uses y-down too). + * 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, @@ -78,8 +83,8 @@ function star5(): Points { return fitPolygonToSquare(pts); } -/** Polygon point lists keyed by cytoscape shape name. Non-polygon shapes - * (ellipse, the round-/cut-/barrel rectangles) are handled separately. */ +// --- Sharp polygon point lists --- + const POLYGON_POINTS: Record = { triangle: ngon(3, 0), rectangle: ngon(4, 0), @@ -116,12 +121,6 @@ export function getPolygonPoints(shape: string): Points | null { // --- Round polygons --- -/** - * The round-cornered polygon shapes. Each reuses its sharp counterpart's point - * list (cytoscape's `round-diamond` shares `diamond`'s points) and rounds the - * corners at draw time, so the name maps to the base shape by dropping the - * `round-` prefix. `roundrectangle` is not here — it's an SVG ``. - */ const ROUND_POLYGON_SHAPES = new Set([ "round-triangle", "round-pentagon", @@ -154,12 +153,6 @@ function unitVector(from: Point, to: Point): Vector { return { len, nx: x / len, ny: y / len }; } -/** - * Verbatim port of cytoscape 3.34's corner rounding (`round.mjs` - * `getRoundCorner` with its default `isArcRadius = true`), for one polygon - * vertex given its neighbours. Returns the arc that replaces the sharp corner: - * a circle center, radius, the arc's start/stop points, and its winding. - */ function getRoundCorner( previous: Point, current: Point, @@ -226,15 +219,14 @@ function getRoundCorner( }; } -function formatPoint(x: number, y: number): string { +function formatCoord(x: number, y: number): string { return `${x.toFixed(3)},${y.toFixed(3)}`; } /** - * SVG path (`d`) for a round-cornered polygon in a [0,size] box, or null if the - * shape isn't a round polygon. The corner radius mirrors cytoscape's - * `getRoundPolygonRadius` (`size/10`) but drops its absolute 8px cap so the - * preview stays proportional when drawn larger than the graph's real node. + * 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, @@ -268,17 +260,129 @@ export function getRoundPolygonPath( cornerRadius, ); segments.push( - `${i === 0 ? "M" : "L"} ${formatPoint(corner.startX, corner.startY)}`, + `${i === 0 ? "M" : "L"} ${formatCoord(corner.startX, corner.startY)}`, ); if (corner.radius === 0) { - segments.push(`L ${formatPoint(corner.stopX, corner.stopY)}`); + 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} ${formatPoint(corner.stopX, corner.stopY)}`, + `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..0744a42f5 --- /dev/null +++ b/packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts @@ -0,0 +1,22 @@ +import { useQuery, useQueryClient } 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 client = useQueryClient(); + const { type, iconUrl, iconImageType, color } = style; + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- client is a stable ref from useQueryClient + const { data } = useQuery({ + queryKey: ["vertex-symbol-icon", type, iconUrl, iconImageType, color], + queryFn: () => renderNode(client, { type, iconUrl, iconImageType, color }), + staleTime: Infinity, + }); + 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/NodeStyleDialog.tsx b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx index 9414fda4d..e80c90f1a 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx @@ -45,7 +45,6 @@ import { createDisplayError } from "@/utils/createDisplayError"; import { LINE_STYLE_OPTIONS } from "./lineStyling"; import { NODE_SHAPE } from "./nodeShape"; -import { SpikeNodePreview } from "./SpikeNodePreview"; const customizeNodeTypeAtom = atom(undefined); @@ -146,7 +145,6 @@ function Content({ vertexType }: { vertexType: VertexType }) { -
diff --git a/packages/graph-explorer/src/modules/NodesStyling/SpikeNodePreview.tsx b/packages/graph-explorer/src/modules/NodesStyling/SpikeNodePreview.tsx deleted file mode 100644 index fbe70f9ab..000000000 --- a/packages/graph-explorer/src/modules/NodesStyling/SpikeNodePreview.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/** - * SPIKE — throwaway. Renders the same vertex style two ways, side by side: - * - left: an SVG preview driven by cytoscape's own shape geometry - * - right: a real cytoscape instance rendering one node - * Mounted at the top of the node style dialog so every style permutation can be - * eyeballed against ground truth. Delete with the rest of the spike. - */ -import { useQueryClient } from "@tanstack/react-query"; -import cytoscape, { type Core } from "cytoscape"; -import { useEffect, useRef, useState } from "react"; - -import type { VertexStyle } from "@/core"; - -import { renderNode } from "@/modules/GraphViewer/renderNode"; - -import { - getPolygonPoints, - getRoundPolygonPath, - toSvgPoints, -} from "./spikeNodeShapes"; - -const SIZE = 96; - -export function SpikeNodePreview({ style }: { style: VertexStyle }) { - return ( -
- - - - - - -
- ); -} - -function PreviewCell({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) { - return ( -
-
- {children} -
- {label} -
- ); -} - -function SvgNodePreview({ style }: { style: VertexStyle }) { - const iconDataUrl = useStyledIconDataUrl(style); - const polygon = getPolygonPoints(style.shape); - const strokeDash = - style.borderStyle === "dashed" - ? "6 4" - : style.borderStyle === "dotted" - ? "2 3" - : undefined; - - const fill = style.color; - const fillOpacity = style.backgroundOpacity; - const stroke = style.borderWidth > 0 ? style.borderColor : "none"; - const strokeWidth = style.borderWidth; - const clipId = `spike-clip-${style.type}`; - - const iconPadding = 38; - const iconSize = SIZE - iconPadding; - const iconPosition = iconPadding / 2; - - // The shape element, rendered twice: once as the filled/stroked background, - // once (fill only) as the clip path so the icon is clipped to the outline — - // matching cytoscape's default `background-clip: 'node'`. - const roundPolygonPath = getRoundPolygonPath( - style.shape, - SIZE - strokeWidth * 2, - ); - const shapeEl = polygon ? ( - - ) : roundPolygonPath ? ( - - ) : isRoundRectangle(style.shape) ? ( - - ) : ( - - ); - - return ( - - - {shapeEl} - - {/* Background fill + border */} - - {shapeEl} - - {/* Icon fills the node box, clipped to the shape (as cytoscape does) */} - {iconDataUrl ? ( - - ) : null} - - ); -} - -function isRoundRectangle(shape: string) { - return ( - shape === "roundrectangle" || - shape === "round-rectangle" || - shape === "barrel" || - shape === "cut-rectangle" - ); -} - -function CytoscapeNodePreview({ style }: { style: VertexStyle }) { - const containerRef = useRef(null); - const cyRef = useRef(null); - const iconDataUrl = useStyledIconDataUrl(style); - - useEffect(() => { - if (!containerRef.current) return; - const cy = cytoscape({ - container: containerRef.current, - style: [], - userZoomingEnabled: false, - userPanningEnabled: false, - autoungrabify: true, - }); - cy.add({ group: "nodes", data: { id: "n", type: "Preview" } }); - cyRef.current = cy; - return () => cy.destroy(); - }, []); - - useEffect(() => { - const cy = cyRef.current; - if (!cy) return; - cy.style([ - { - selector: "node", - style: { - "background-image": iconDataUrl ?? undefined, - "background-color": style.color, - "background-opacity": style.backgroundOpacity, - // In the graph the icon (24px) fills the node (24px). Reproduce that - // "fill the node" ratio at this larger preview size so the cell is - // faithful ground truth rather than a 24px icon lost on a 60px node. - "background-width": "100%", - "background-height": "100%", - "border-color": style.borderColor, - "border-width": style.borderWidth, - "border-opacity": style.borderWidth > 0 ? 1 : 0, - "border-style": style.borderStyle, - shape: style.shape as never, - width: 60, - height: 60, - }, - }, - ] as never); - cy.center(); - cy.zoom(1); - cy.center(); - }, [style, iconDataUrl]); - - return
; -} - -/** Runs the production icon pipeline (fetch → sanitize → recolor → data URL). */ -function useStyledIconDataUrl(style: VertexStyle): string | null { - const client = useQueryClient(); - const [dataUrl, setDataUrl] = useState(null); - useEffect(() => { - let active = true; - renderNode(client, { - type: style.type, - iconUrl: style.iconUrl, - iconImageType: style.iconImageType, - color: style.color, - }) - .then(result => { - if (active) setDataUrl(result); - }) - .catch(() => { - if (active) setDataUrl(null); - }); - return () => { - active = false; - }; - }, [client, style.iconUrl, style.iconImageType, style.color, style.type]); - return dataUrl; -} From c933c57130ceba5626a5224a9a97b521fb2d58f9 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 10 Jul 2026 13:53:07 -0500 Subject: [PATCH 4/8] Fix VertexSymbol icon clipping across multiple instances Use React's useId() for the SVG clipPath ID instead of a type-derived string. The old approach (vs-clip-${type}) collided when the same vertex type rendered in multiple places (Graph View legend + Schema View sidebar), causing the second instance's icon to clip against the wrong element. --- .../components/VertexSymbol/VertexSymbol.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx b/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx index 32bc7b432..5b2238d0a 100644 --- a/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx +++ b/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx @@ -1,3 +1,5 @@ +import { useId } from "react"; + import { useVertexStyle, type VertexStyle, type VertexType } from "@/core"; import { cn } from "@/utils"; @@ -14,15 +16,21 @@ interface Props { 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; const insetSize = VIEWBOX - strokeWidth * 2; const geometry = resolveShapeGeometry(vertexStyle.shape, insetSize); - const clipId = `vs-clip-${vertexStyle.type}`; const iconSize = VIEWBOX * ICON_RATIO; const iconOffset = (VIEWBOX - iconSize) / 2; - const shapeEl = renderShape(geometry, insetSize); + // 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} - - + {shapeEl} 0 ? vertexStyle.borderColor : "none"} @@ -83,18 +86,25 @@ function strokeDash(style: string): string | undefined { function renderShape( geometry: ReturnType, size: number, + transform: string, ) { switch (geometry.type) { case "polygon": - return ; + return ; case "round-polygon": case "round-rectangle": case "cut-rectangle": case "barrel": - return ; + return ; case "ellipse": return ( - + ); } } From 6599b7fb56997139a66c3f0bc04e5b8633c3b196 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 10 Jul 2026 17:32:03 -0500 Subject: [PATCH 5/8] Scale border width to match canvas proportions in VertexSymbol Border width from VertexStyle is in canvas pixels (relative to a 24px node). The SVG viewBox is 96 units, so a raw value of 1 rendered as sub-pixel (0.375px at 36px display). Scale by viewBox/canvasNode (4x) so a 1px canvas border appears proportionally correct in the preview. --- .../src/components/VertexSymbol/VertexSymbol.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx b/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx index 5b2238d0a..02c2f0b35 100644 --- a/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx +++ b/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx @@ -8,6 +8,8 @@ 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; @@ -18,7 +20,7 @@ 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; + const strokeWidth = vertexStyle.borderWidth * BORDER_SCALE; const insetSize = VIEWBOX - strokeWidth * 2; const geometry = resolveShapeGeometry(vertexStyle.shape, insetSize); From 0e057ef9ee27ece4c7d31cc1dec4a41e077c5749 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 10 Jul 2026 17:42:00 -0500 Subject: [PATCH 6/8] Harden VertexSymbol against edge cases - Clamp insetSize to minimum 1 so extreme borderWidth doesn't produce degenerate geometry - Add exhaustive default to renderShape switch for type safety - Skip icon query when iconUrl is empty (no pointless cache entries) --- .../src/components/VertexSymbol/VertexSymbol.tsx | 6 +++++- .../src/components/VertexSymbol/useIconDataUrl.ts | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx b/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx index 02c2f0b35..2803c26ca 100644 --- a/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx +++ b/packages/graph-explorer/src/components/VertexSymbol/VertexSymbol.tsx @@ -21,7 +21,7 @@ export function VertexSymbol({ vertexStyle, className }: Props) { // 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 = VIEWBOX - strokeWidth * 2; + const insetSize = Math.max(1, VIEWBOX - strokeWidth * 2); const geometry = resolveShapeGeometry(vertexStyle.shape, insetSize); const iconSize = VIEWBOX * ICON_RATIO; @@ -108,5 +108,9 @@ function renderShape( transform={transform} /> ); + default: { + const _exhaustive: never = geometry; + return _exhaustive; + } } } diff --git a/packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts b/packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts index 0744a42f5..a0a290f80 100644 --- a/packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts +++ b/packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts @@ -17,6 +17,7 @@ export function useIconDataUrl(style: VertexStyle): string | null { queryKey: ["vertex-symbol-icon", type, iconUrl, iconImageType, color], queryFn: () => renderNode(client, { type, iconUrl, iconImageType, color }), staleTime: Infinity, + enabled: !!iconUrl, }); return data ?? null; } From bba8425f67c095df9037513776d6ea47a61e97cd Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 10 Jul 2026 17:59:58 -0500 Subject: [PATCH 7/8] Use queryFn context client instead of closing over useQueryClient The queryFn receives the QueryClient as a parameter, eliminating the need for useQueryClient and the eslint-disable comment. Matches the pattern used in useBackgroundImageMap. --- .../src/components/VertexSymbol/useIconDataUrl.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts b/packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts index a0a290f80..d552b5eae 100644 --- a/packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts +++ b/packages/graph-explorer/src/components/VertexSymbol/useIconDataUrl.ts @@ -1,4 +1,4 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import type { VertexStyle } from "@/core"; @@ -10,12 +10,11 @@ import { renderNode } from "@/modules/GraphViewer/renderNode"; * load instantly after the first fetch. */ export function useIconDataUrl(style: VertexStyle): string | null { - const client = useQueryClient(); const { type, iconUrl, iconImageType, color } = style; - // eslint-disable-next-line @tanstack/query/exhaustive-deps -- client is a stable ref from useQueryClient const { data } = useQuery({ queryKey: ["vertex-symbol-icon", type, iconUrl, iconImageType, color], - queryFn: () => renderNode(client, { type, iconUrl, iconImageType, color }), + queryFn: ({ client }) => + renderNode(client, { type, iconUrl, iconImageType, color }), staleTime: Infinity, enabled: !!iconUrl, }); From 6f72e3ca25513feb71453e5bfbb1fff9fb6ec639 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 13 Jul 2026 09:09:58 -0500 Subject: [PATCH 8/8] Narrow test geometry types with expect.assert instead of casts --- .../VertexSymbol/nodeShapes.test.ts | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.test.ts b/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.test.ts index cd3e72f0e..76baeabdc 100644 --- a/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.test.ts +++ b/packages/graph-explorer/src/components/VertexSymbol/nodeShapes.test.ts @@ -22,9 +22,9 @@ describe("resolveShapeGeometry", () => { "concave-hexagon", ])("%s resolves to a polygon with valid points", shape => { const result = resolveShapeGeometry(shape, SIZE); - expect(result.type).toBe("polygon"); - expect("points" in result && result.points.length).toBeGreaterThan(0); - const points = (result as { points: string }).points; + 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); @@ -46,11 +46,10 @@ describe("resolveShapeGeometry", () => { "round-tag", ])("%s resolves to a round-polygon with arcs", shape => { const result = resolveShapeGeometry(shape, SIZE); - expect(result.type).toBe("round-polygon"); - const path = (result as { path: string }).path; - expect(path).toMatch(/^M /); - expect(path).toMatch(/ Z$/); - expect(path).toMatch(/ A /); + expect.assert(result.type === "round-polygon"); + expect(result.path).toMatch(/^M /); + expect(result.path).toMatch(/ Z$/); + expect(result.path).toMatch(/ A /); }); }); @@ -59,10 +58,9 @@ describe("resolveShapeGeometry", () => { "%s resolves to round-rectangle", shape => { const result = resolveShapeGeometry(shape, SIZE); - expect(result.type).toBe("round-rectangle"); - const path = (result as { path: string }).path; - expect(path).toMatch(/^M /); - expect(path).toMatch(/ A /); + expect.assert(result.type === "round-rectangle"); + expect(result.path).toMatch(/^M /); + expect(result.path).toMatch(/ A /); }, ); }); @@ -70,21 +68,19 @@ describe("resolveShapeGeometry", () => { describe("cut-rectangle", () => { it("resolves with chamfered corners", () => { const result = resolveShapeGeometry("cut-rectangle", SIZE); - expect(result.type).toBe("cut-rectangle"); - const path = (result as { path: string }).path; - expect(path).toMatch(/^M /); - expect(path).toMatch(/ Z$/); - expect(path).not.toMatch(/ A /); + 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(result.type).toBe("barrel"); - const path = (result as { path: string }).path; - expect(path).toMatch(/^M /); - expect(path).toMatch(/ Q /); + expect.assert(result.type === "barrel"); + expect(result.path).toMatch(/^M /); + expect(result.path).toMatch(/ Q /); }); });