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 d3d67151c3..72416885cd 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 0000000000..9f95620bdd --- /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 0957a2df8d..d1629713c7 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 3ce7522469..bae7f04218 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 a2e46372b6..3cb8869c07 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 b38e2348db..b5b9365d7e 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 77c83ba9cb..f9aa8d867c 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 15e72bc0b0..95225ddc1a 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 0333616c94..ecd81d61d3 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 48692e8794..d9d3d5e3a0 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 e80c90f1ad..e1eb06c45c 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 95225ddc1a..5348cbf50b 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 0000000000..f872a47cb6 --- /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(); + }); +});