diff --git a/docs/features/graph-view.md b/docs/features/graph-view.md index d3d67151c..72416885c 100644 --- a/docs/features/graph-view.md +++ b/docs/features/graph-view.md @@ -83,6 +83,7 @@ On the **Nodes** tab, each node type can be customized in a variety of ways. - **Display description attribute** allows you to choose the attribute on the node that is used to describe the node in search - **Icon** can be picked from the built-in Lucide library via the **Browse** button, or uploaded as a custom SVG/raster image. - **Colors and borders** can be customized to visually distinguish from other node types +- **Label font size** and **minimum zoomed font size** control how large the node's label renders and how small it can shrink before it hides while zooming out On the **Edges** tab, each edge type can be customized in a variety of ways. @@ -91,6 +92,7 @@ On the **Edges** tab, each edge type can be customized in a variety of ways. - **Arrow symbol** can be chosen for both source and target variations - **Colors and borders** can be customized for the edge label and the line - **Line style** can be solid, dotted, or dashed +- **Label font size** and **minimum zoomed font size** control how large the edge's label renders and how small it can shrink before it hides while zooming out ### Namespace Panel diff --git a/packages/graph-explorer/src/components/utils/canvas/drawBoxWithAdornment.test.ts b/packages/graph-explorer/src/components/utils/canvas/drawBoxWithAdornment.test.ts new file mode 100644 index 000000000..9f95620bd --- /dev/null +++ b/packages/graph-explorer/src/components/utils/canvas/drawBoxWithAdornment.test.ts @@ -0,0 +1,71 @@ +import { vi } from "vitest"; + +import type { AutoBoundingBox } from "./types"; + +import drawBoxWithAdornment from "./drawBoxWithAdornment"; + +/** A stub 2D context measuring text at a fixed width per character. */ +function createFakeContext() { + return { + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + closePath: vi.fn(), + moveTo: vi.fn(), + arcTo: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + setLineDash: vi.fn(), + fillText: vi.fn(), + measureText: (text: string) => ({ width: text.length * 6 }), + font: "", + fillStyle: "", + strokeStyle: "", + lineWidth: 0, + textAlign: "", + textBaseline: "", + } as unknown as CanvasRenderingContext2D; +} + +const boundingBox: AutoBoundingBox = { + x: 0, + y: 0, + width: "auto", + height: "auto", +}; + +describe("drawBoxWithAdornment", () => { + it("draws the full text without truncation when no maxWidth is given", () => { + const context = createFakeContext(); + const longText = "a-very-long-vertex-display-name-value"; + + drawBoxWithAdornment(context, boundingBox, { text: longText }); + + expect(context.fillText).toHaveBeenCalledWith( + longText, + expect.any(Number), + expect.any(Number), + ); + }); + + it("truncates the text with an ellipsis once it exceeds maxWidth", () => { + const context = createFakeContext(); + const longText = "a-very-long-vertex-display-name-value"; + + drawBoxWithAdornment(context, boundingBox, { + text: longText, + maxWidth: 20, + }); + + expect(context.fillText).toHaveBeenCalledWith( + expect.stringMatching(/\.\.\.$/), + expect.any(Number), + expect.any(Number), + ); + expect(context.fillText).not.toHaveBeenCalledWith( + longText, + expect.any(Number), + expect.any(Number), + ); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts b/packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts index 0957a2df8..d1629713c 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts @@ -428,6 +428,33 @@ describe("vertexStyleAtom", () => { createExpectedVertex({ type: vertexType }), ); }); + + it("should default font size fields to 7/6", () => { + const dbState = new DbState(); + const vertexType = createVertexType("Person"); + + const { result } = renderHookWithState( + () => useAtomValue(vertexStyleAtom), + dbState, + ); + + const style = result.current.get(vertexType); + expect(style.fontSize).toBe(7); + expect(style.minZoomedFontSize).toBe(6); + }); + + it("should let a user override the font size", () => { + const dbState = new DbState(); + const vertexType = createVertexType("Person"); + dbState.addVertexStyle(vertexType, { fontSize: 12 }); + + const { result } = renderHookWithState( + () => useAtomValue(vertexStyleAtom), + dbState, + ); + + expect(result.current.get(vertexType).fontSize).toBe(12); + }); }); describe("edgeStyleAtom", () => { @@ -459,4 +486,31 @@ describe("edgeStyleAtom", () => { createExpectedEdge({ type: edgeType }), ); }); + + it("should default font size fields to 7/6", () => { + const dbState = new DbState(); + const edgeType = createEdgeType("KNOWS"); + + const { result } = renderHookWithState( + () => useAtomValue(edgeStyleAtom), + dbState, + ); + + const style = result.current.get(edgeType); + expect(style.fontSize).toBe(7); + expect(style.minZoomedFontSize).toBe(6); + }); + + it("should let a user override the font size", () => { + const dbState = new DbState(); + const edgeType = createEdgeType("KNOWS"); + dbState.addEdgeStyle(edgeType, { fontSize: 10 }); + + const { result } = renderHookWithState( + () => useAtomValue(edgeStyleAtom), + dbState, + ); + + expect(result.current.get(edgeType).fontSize).toBe(10); + }); }); diff --git a/packages/graph-explorer/src/core/StateProvider/graphStyles.ts b/packages/graph-explorer/src/core/StateProvider/graphStyles.ts index 3ce752246..bae7f0421 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphStyles.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphStyles.ts @@ -80,6 +80,10 @@ export type VertexVisualStyle = { borderWidth: number; borderColor: string; borderStyle: LineStyle; + /** Label font size in pixels. */ + fontSize: number; + /** Minimum on-screen font size before labels are hidden while zooming. */ + minZoomedFontSize: number; }; /** @@ -108,6 +112,10 @@ export type EdgeVisualStyle = { lineStyle: LineStyle; sourceArrowStyle: ArrowStyle; targetArrowStyle: ArrowStyle; + /** Label font size in pixels. */ + fontSize: number; + /** Minimum on-screen font size before labels are hidden while zooming. */ + minZoomedFontSize: number; }; /** The type-specific fields of an edge style. */ @@ -148,6 +156,9 @@ export const appDefaultVertexStyle = { borderWidth: 0, borderColor: "#128EE5", borderStyle: "solid", + // Keep in sync with `components/Graph/styles/defaultNodeStyle.ts`. + fontSize: 7, + minZoomedFontSize: 6, } as const satisfies Omit; /** The default values to use when no user provided value is given. */ @@ -163,6 +174,9 @@ export const appDefaultEdgeStyle = { lineStyle: "solid", sourceArrowStyle: "none", targetArrowStyle: "triangle", + // Keep in sync with `components/Graph/styles/defaultEdgeStyle.ts`. + fontSize: 7, + minZoomedFontSize: 6, } as const satisfies Omit; /** diff --git a/packages/graph-explorer/src/core/styling/roundTrip.test.ts b/packages/graph-explorer/src/core/styling/roundTrip.test.ts index a2e46372b..3cb8869c0 100644 --- a/packages/graph-explorer/src/core/styling/roundTrip.test.ts +++ b/packages/graph-explorer/src/core/styling/roundTrip.test.ts @@ -585,6 +585,76 @@ describe("round-trip: export then import", () => { lineColor: "#0c4a6e", }); }); + + test("font size fields survive round-trip", async () => { + const store = getAppStore(); + store.set( + userVertexStylesAtom, + new Map([ + [ + createVertexType("airport"), + { + type: createVertexType("airport"), + fontSize: 14, + minZoomedFontSize: 8, + }, + ], + ]), + ); + store.set( + userEdgeStylesAtom, + new Map([ + [ + createEdgeType("route"), + { + type: createEdgeType("route"), + fontSize: 11, + minZoomedFontSize: 5, + }, + ], + ]), + ); + + const { result: exportResult } = renderHookWithJotai(() => + useExportStylingFile(), + ); + const payload = exportResult.current.getExportPayload(); + + expect(payload.vertices["airport"]).toStrictEqual({ + fontSize: 14, + minZoomedFontSize: 8, + }); + expect(payload.edges["route"]).toStrictEqual({ + fontSize: 11, + minZoomedFontSize: 5, + }); + + const file = envelopeToFile(payload); + + store.set(userVertexStylesAtom, new Map()); + store.set(userEdgeStylesAtom, new Map()); + + const { result: importResult } = renderHookWithJotai(() => + useApplyStylingImport(), + ); + const parseOut = await parseStylingFile(file); + importResult.current(parseOut); + + expect( + store.get(sharedVertexStylesAtom).get(createVertexType("airport")), + ).toStrictEqual({ + type: createVertexType("airport"), + fontSize: 14, + minZoomedFontSize: 8, + }); + expect( + store.get(sharedEdgeStylesAtom).get(createEdgeType("route")), + ).toStrictEqual({ + type: createEdgeType("route"), + fontSize: 11, + minZoomedFontSize: 5, + }); + }); }); function envelopeToFile(payload: unknown): File { diff --git a/packages/graph-explorer/src/core/styling/stylingParser.ts b/packages/graph-explorer/src/core/styling/stylingParser.ts index b38e2348d..b5b9365d7 100644 --- a/packages/graph-explorer/src/core/styling/stylingParser.ts +++ b/packages/graph-explorer/src/core/styling/stylingParser.ts @@ -138,6 +138,8 @@ const vertexEntrySchema = z borderWidth: z.number().optional(), borderColor: z.string().optional(), borderStyle: z.enum(LINE_STYLES).optional(), + fontSize: z.number().optional(), + minZoomedFontSize: z.number().optional(), }) .transform( ({ icon, ...rest }): Omit => @@ -157,6 +159,8 @@ const edgeEntrySchema = z.object({ lineStyle: z.enum(LINE_STYLES).optional(), sourceArrowStyle: z.enum(ARROW_STYLES).optional(), targetArrowStyle: z.enum(ARROW_STYLES).optional(), + fontSize: z.number().optional(), + minZoomedFontSize: z.number().optional(), }); // --- File-format types --- diff --git a/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx b/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx index 77c83ba9c..f9aa8d867 100644 --- a/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx @@ -197,6 +197,28 @@ function Content({ edgeType }: { edgeType: EdgeType }) { + + + Label Font Size + setEdgeStyle({ fontSize })} + /> + + + Min Zoomed Font Size + + setEdgeStyle({ minZoomedFontSize }) + } + /> + +
Line Styling diff --git a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx index 15e72bc0b..5348cbf50 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx @@ -66,6 +66,8 @@ describe("useGraphStyles", () => { "border-opacity": 1, "border-style": "solid", shape: "ellipse", + "font-size": 7, + "min-zoomed-font-size": 6, width: 24, height: 24, }); @@ -86,6 +88,8 @@ describe("useGraphStyles", () => { labelBorderWidth: 1, labelBorderColor: "#000000", labelBorderStyle: "solid" as const, + fontSize: 12, + minZoomedFontSize: 7, }; dbState.activeSchema.edges = [edgeConfig]; dbState.addEdgeStyle(edgeConfig.type, edgeConfig); @@ -107,6 +111,8 @@ describe("useGraphStyles", () => { "text-border-width": 1, "text-border-color": "#000000", "text-border-style": "solid", + "font-size": 12, + "min-zoomed-font-size": 7, width: 2, "source-distance-from-node": 0, "target-distance-from-node": 0, diff --git a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts index 0333616c9..ecd81d61d 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts @@ -56,6 +56,8 @@ function createGraphStyles( "border-opacity": vtConfig.borderWidth > 0 ? 1 : 0, "border-style": vtConfig.borderStyle, shape: vtConfig.shape, + "font-size": vtConfig.fontSize, + "min-zoomed-font-size": vtConfig.minZoomedFontSize, width: 24, height: 24, }; @@ -84,6 +86,8 @@ function createGraphStyles( "text-border-width": etConfig?.labelBorderWidth, "text-border-color": etConfig?.labelBorderColor, "text-border-style": etConfig?.labelBorderStyle, + "font-size": etConfig.fontSize, + "min-zoomed-font-size": etConfig.minZoomedFontSize, width: etConfig.lineThickness, "source-distance-from-node": 0, "target-distance-from-node": 0, diff --git a/packages/graph-explorer/src/modules/GraphViewer/useNodeBadges.test.ts b/packages/graph-explorer/src/modules/GraphViewer/useNodeBadges.test.ts new file mode 100644 index 000000000..f872a47cb --- /dev/null +++ b/packages/graph-explorer/src/modules/GraphViewer/useNodeBadges.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment happy-dom +import { renderHook } from "@testing-library/react"; + +import { + createRenderedVertexId, + createVertexId, + createVertexType, + type RenderedVertex, +} from "@/core"; + +import useNodeBadges from "./useNodeBadges"; + +function renderBadges(zoomLevel: "small" | "medium" | "large") { + const { result } = renderHook(() => useNodeBadges()); + const getNodeBadges = result.current(new Set()); + const nodeData: RenderedVertex["data"] = { + id: createRenderedVertexId(createVertexId("1")), + type: createVertexType("Person"), + vertexId: createVertexId("1"), + displayName: "a-very-long-vertex-display-name-value", + displayTypes: "Person", + neighborCount: 0, + }; + const boundingBox = { x: 0, y: 0, width: 24, height: 24 }; + const context = {} as CanvasRenderingContext2D; + + return getNodeBadges(nodeData, boundingBox, { context, zoomLevel }); +} + +describe("useNodeBadges", () => { + it("does not cap the label badge's width, so long labels are not truncated", () => { + const [labelBadge] = renderBadges("medium"); + + expect(labelBadge?.maxWidth).toBeUndefined(); + }); +}); diff --git a/packages/graph-explorer/src/modules/GraphViewer/useNodeBadges.ts b/packages/graph-explorer/src/modules/GraphViewer/useNodeBadges.ts index 48692e879..d9d3d5e3a 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useNodeBadges.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/useNodeBadges.ts @@ -12,7 +12,6 @@ const useNodeBadges = () => { text: nodeData.displayName, hidden: zoomLevel === "small" || outOfFocusIds.has(nodeData.id), title: zoomLevel === "large" ? nodeData.displayTypes : undefined, - maxWidth: zoomLevel === "large" ? 80 : 50, anchor: "center", fontSize: 7, borderRadius: 2, diff --git a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx index e80c90f1a..e1eb06c45 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx @@ -303,6 +303,28 @@ function Content({ vertexType }: { vertexType: VertexType }) { +
+ + Label Font Size + setVertexStyle({ fontSize })} + /> + + + Min Zoomed Font Size + + setVertexStyle({ minZoomedFontSize }) + } + /> + +