From 832ae395962b70aacc236e86b18414ca8b3b1d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20van=20Ziel?= Date: Mon, 13 Jul 2026 14:21:46 +0200 Subject: [PATCH 1/2] Add per-type label font size to node and edge styling Font size was a hardcoded global constant applied only through the generic node/edge Cytoscape selectors, so every type rendered labels at the same size. Add fontSize and minZoomedFontSize to the vertex/edge style cascade (with the existing 7/6 defaults), emit them from the per-type selectors, and expose them as inputs in the node and edge style dialogs. The fields flow through the existing styling import/export as additive optional fields, no export version bump needed. Also drop the node badge's fixed maxWidth truncation, which assumed the old fixed font size and would clip short labels once a larger custom size is set. --- docs/features/graph-view.md | 2 + .../utils/canvas/drawBoxWithAdornment.test.ts | 71 +++++++++++++++++++ .../core/StateProvider/graphStyles.test.ts | 54 ++++++++++++++ .../src/core/StateProvider/graphStyles.ts | 14 ++++ .../src/core/styling/roundTrip.test.ts | 70 ++++++++++++++++++ .../src/core/styling/stylingParser.ts | 4 ++ .../modules/EdgesStyling/EdgeStyleDialog.tsx | 22 ++++++ .../GraphViewer/useGraphStyles.test.tsx | 2 + .../src/modules/GraphViewer/useGraphStyles.ts | 4 ++ .../src/modules/GraphViewer/useNodeBadges.ts | 1 - .../modules/NodesStyling/NodeStyleDialog.tsx | 22 ++++++ 11 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 packages/graph-explorer/src/components/utils/canvas/drawBoxWithAdornment.test.ts 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..95225ddc1 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, }); 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.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 }) + } + /> + +
From cd036a35c7dbdbafe21f4ff9cb156e2dbbd8cdfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20van=20Ziel?= Date: Mon, 13 Jul 2026 14:38:06 +0200 Subject: [PATCH 2/2] Cover the edge-side font-size selector and the badge maxWidth removal useGraphStyles.test.tsx asserted font-size/min-zoomed-font-size only for vertex selectors; add the same coverage on the edge side. Add a small useNodeBadges test asserting the label badge no longer carries the fixed maxWidth that used to clip labels at the old, non-configurable font size. --- .../GraphViewer/useGraphStyles.test.tsx | 4 +++ .../modules/GraphViewer/useNodeBadges.test.ts | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 packages/graph-explorer/src/modules/GraphViewer/useNodeBadges.test.ts diff --git a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx index 95225ddc1..5348cbf50 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx @@ -88,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); @@ -109,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/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(); + }); +});