From 9486ac902904407806a2c9c3a7d4d686f2feeefb Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 13 Aug 2026 15:15:03 -0500 Subject: [PATCH 1/2] Resolve per-type graph styles via data() mappers, not per-type selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema view emitted one Cytoscape selector per vertex/edge type (~20k contexts at 10k labels), making style application O(elements × contexts) and locking the main thread so the view never rendered. Precompute each element's resolved style values onto ele.data() (ge_* fields) at the element-construction seams and read them back through a single node rule + single edge rule using data() mappers, plus two gated rules (node[__iconUrl], edge[ge_lineDashPattern]). Context count is now O(1) in the number of types. A live 10k sync confirms the schema view renders with cytoscape style self-time dropping from 87.6% to ~0%. The schema view merges its label into the base node/edge rules rather than replacing them, so the ge_* mappers survive; label-text-color resolution falls back to the default color instead of throwing on an empty labelColor. useBackgroundImageMap moves to core/icons so the element-enrichment seam consumes it without a core-to-modules import. See docs/adr/20260813-element-data-style-mappers.md. Refs #2104. --- .../20260813-element-data-style-mappers.md | 23 ++ .../20260813-icon-registry-not-react-query.md | 2 +- .../graphElementStyleData.test.ts | 120 ++++++ .../StateProvider/graphElementStyleData.ts | 101 +++++ .../core/StateProvider/graphStyles.test.ts | 29 ++ .../src/core/StateProvider/index.ts | 1 + .../core/StateProvider/renderedEntities.ts | 37 +- .../src/core/icons/iconSurfaces.test.tsx | 2 +- .../graph-explorer/src/core/icons/index.ts | 1 + .../icons}/useBackgroundImageMap.test.ts | 0 .../icons}/useBackgroundImageMap.ts | 6 +- .../useGraphStyles.contextCount.test.tsx | 57 +++ .../GraphViewer/useGraphStyles.test.tsx | 388 ++---------------- .../src/modules/GraphViewer/useGraphStyles.ts | 137 +++---- .../SchemaGraph/useSchemaGraphData.test.tsx | 118 ++++++ .../modules/SchemaGraph/useSchemaGraphData.ts | 22 +- .../SchemaGraph/useSchemaGraphStyles.test.tsx | 34 ++ .../SchemaGraph/useSchemaGraphStyles.ts | 5 + 18 files changed, 639 insertions(+), 444 deletions(-) create mode 100644 docs/adr/20260813-element-data-style-mappers.md create mode 100644 packages/graph-explorer/src/core/StateProvider/graphElementStyleData.test.ts create mode 100644 packages/graph-explorer/src/core/StateProvider/graphElementStyleData.ts rename packages/graph-explorer/src/{modules/GraphViewer => core/icons}/useBackgroundImageMap.test.ts (100%) rename packages/graph-explorer/src/{modules/GraphViewer => core/icons}/useBackgroundImageMap.ts (93%) create mode 100644 packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.contextCount.test.tsx create mode 100644 packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.test.tsx create mode 100644 packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphStyles.test.tsx diff --git a/docs/adr/20260813-element-data-style-mappers.md b/docs/adr/20260813-element-data-style-mappers.md new file mode 100644 index 0000000000..203efcc67f --- /dev/null +++ b/docs/adr/20260813-element-data-style-mappers.md @@ -0,0 +1,23 @@ +# ADR — Resolve per-type graph styles through element-data mappers + +- **Status:** Accepted +- **Date:** 2026-08-13 +- **Related:** ADR `svg-geometry-ports-for-style-previews` and `coerce-retired-round-polygon-shapes` (other consumers of the same resolved style values). Issue #2104. + +## Context + +The graph canvas colours each vertex/edge by its type. The obvious Cytoscape idiom is one selector per type — `node[type="…"]`, `edge[type="…"]` — which is what `createGraphStyles` emitted. Cytoscape resolves an element's style by scanning every context, so a stylesheet with one selector per type makes style application O(elements × contexts). On a schema with ~10k vertex types + ~10k edge types this is ~20k contexts × ~20k elements; the Schema View pinned the main thread and never rendered (#2104), with ~88% of trace self-time in Cytoscape's `getPropertiesDiff`/`getContextStyle`. + +## Decision + +Precompute each element's resolved style values onto its Cytoscape `ele.data()` as `ge_*` fields (`vertexStyleData` / `edgeStyleData` in `core/StateProvider/graphElementStyleData.ts`) at the element-construction seams (`renderedEntities.ts` for the explorer graph, `useSchemaGraphData.ts` for the schema view), and read them back through a single `node` rule and single `edge` rule using Cytoscape `data(…)` mappers (`CANVAS_STYLES` in `useGraphStyles.ts`). The stylesheet is now O(1) in the number of types. + +Two structured/optional properties can't be a plain always-present mapper, so they keep a gated selector that applies only when the field exists: `node[__iconUrl]` (background image; the field is omitted when a type has no icon) and `edge[ge_lineDashPattern]` (omitted for solid lines). The dotted→dashed remap, the dash-pattern lookup, the border-opacity derivation, and the `isDark` label-text-colour pick are baked into the producer functions so the stylesheet stays pure `data(…)`. + +## Consequences + +- The lockup is gone: a live 10k sync confirms the Schema View renders and Cytoscape style self-time drops from ~88% to ~0%. The next scaling wall is the fcose layout, tracked separately. +- **Producer and consumer must stay in lockstep.** A new per-type style property must be added in two places together — the `ge_*` field + its producer in `graphElementStyleData.ts`, and the matching `data(…)` mapper in `useGraphStyles.ts` `CANVAS_STYLES`. Editing one side alone silently drops the style. +- **A stylesheet consumer must merge into the base `node`/`edge` rules, never replace them.** `useSchemaGraphStyles` adds a schema label by spreading `{ ...baseStyles.node, label: … }`; overwriting the `node`/`edge` keys wholesale would discard every `ge_*` mapper and render the graph unstyled. Guarded by `useSchemaGraphStyles.test.tsx`. +- Context-count regression guards (`useGraphStyles.contextCount.test.tsx`, `useSchemaGraphStyles.test.tsx`) assert the selector count stays O(1) regardless of type count, so the per-type-selector approach can't creep back in unnoticed. +- A future reader seeing `data(ge_*)` mappers and no per-type selectors should not "restore" per-type selectors — that is the exact regression this avoids. diff --git a/docs/adr/20260813-icon-registry-not-react-query.md b/docs/adr/20260813-icon-registry-not-react-query.md index 67bdb8a116..17508c08c1 100644 --- a/docs/adr/20260813-icon-registry-not-react-query.md +++ b/docs/adr/20260813-icon-registry-not-react-query.md @@ -2,7 +2,7 @@ - **Status:** Accepted - **Date:** 2026-08-13 -- **Related:** PR #2102; issues #2091, #2103, #2105, #2107. Supersedes the icon-resolution decisions in PR #1777. Affects `core/icons/`, `modules/GraphViewer/useBackgroundImageMap.ts`, `components/VertexSymbol/`. +- **Related:** PR #2102; issues #2091, #2103, #2105, #2107. Supersedes the icon-resolution decisions in PR #1777. Affects `core/icons/`, `components/VertexSymbol/`. ## Context diff --git a/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.test.ts b/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.test.ts new file mode 100644 index 0000000000..d27b189485 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; + +import { + appDefaultEdgeStyle, + appDefaultVertexStyle, + createEdgeType, + createVertexType, + type EdgeStyle, + type VertexStyle, +} from "@/core"; + +import { + edgeStyleData, + labelTextColorFor, + vertexStyleData, +} from "./graphElementStyleData"; + +const vertex = (overrides: Partial = {}) => + ({ + ...appDefaultVertexStyle, + type: createVertexType("Person"), + ...overrides, + }) satisfies VertexStyle; + +const edge = (overrides: Partial = {}) => + ({ + ...appDefaultEdgeStyle, + type: createEdgeType("knows"), + ...overrides, + }) satisfies EdgeStyle; + +describe("vertexStyleData", () => { + it("copies scalar fields verbatim", () => { + const style = vertex(); + const data = vertexStyleData(style, undefined); + expect(data.ge_color).toBe(style.color); + expect(data.ge_backgroundOpacity).toBe(style.backgroundOpacity); + expect(data.ge_shape).toBe(style.shape); + }); + + it("derives ge_borderOpacity from borderWidth", () => { + expect( + vertexStyleData(vertex({ borderWidth: 0 }), undefined).ge_borderOpacity, + ).toBe(0); + expect( + vertexStyleData(vertex({ borderWidth: 2 }), undefined).ge_borderOpacity, + ).toBe(1); + }); + + it("gates __iconUrl on backgroundImage presence", () => { + expect(vertexStyleData(vertex(), undefined).__iconUrl).toBeUndefined(); + expect(vertexStyleData(vertex(), "img").__iconUrl).toBe("img"); + }); +}); + +describe("edgeStyleData", () => { + it("copies scalar fields verbatim", () => { + const style = edge(); + const data = edgeStyleData(style); + expect(data.ge_lineColor).toBe(style.lineColor); + expect(data.ge_sourceArrowShape).toBe(style.sourceArrowStyle); + expect(data.ge_targetArrowShape).toBe(style.targetArrowStyle); + expect(data.ge_lineThickness).toBe(style.lineThickness); + }); + + it("remaps dotted line style to dashed for cytoscape rendering", () => { + // cytoscape renders "dotted" identically to solid at some widths; the app + // renders dotted via a dashed style + tight dash pattern (see LINE_PATTERN). + expect(edgeStyleData(edge({ lineStyle: "solid" })).ge_lineStyle).toBe( + "solid", + ); + expect(edgeStyleData(edge({ lineStyle: "dashed" })).ge_lineStyle).toBe( + "dashed", + ); + expect(edgeStyleData(edge({ lineStyle: "dotted" })).ge_lineStyle).toBe( + "dashed", + ); + }); + + it("emits ge_lineDashPattern only for non-solid lines", () => { + expect( + edgeStyleData(edge({ lineStyle: "solid" })).ge_lineDashPattern, + ).toBeUndefined(); + expect( + edgeStyleData(edge({ lineStyle: "dashed" })).ge_lineDashPattern, + ).toEqual([5, 6]); + expect( + edgeStyleData(edge({ lineStyle: "dotted" })).ge_lineDashPattern, + ).toEqual([1, 2]); + }); + + it("picks label text color for readability against the label background", () => { + expect( + edgeStyleData(edge({ labelColor: "#17457b" })).ge_labelTextColor, + ).toBe("#FFFFFF"); + expect( + edgeStyleData(edge({ labelColor: "#ffffff" })).ge_labelTextColor, + ).toBe("#000000"); + }); + + it("does not throw on an empty labelColor (reachable via style import)", () => { + // new Color("") throws; an imported style file can carry an empty labelColor. + expect(() => edgeStyleData(edge({ labelColor: "" }))).not.toThrow(); + expect(edgeStyleData(edge({ labelColor: "" })).ge_labelTextColor).toBe( + "#FFFFFF", + ); + }); +}); + +describe("labelTextColorFor", () => { + it("matches the readability pick used across previews and canvas", () => { + expect(labelTextColorFor("#000000")).toBe("#FFFFFF"); + expect(labelTextColorFor("#ffffff")).toBe("#000000"); + }); + + it("falls back to the default label color instead of throwing on empty", () => { + expect(() => labelTextColorFor("")).not.toThrow(); + expect(labelTextColorFor("")).toBe("#FFFFFF"); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.ts b/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.ts new file mode 100644 index 0000000000..602f90a5b5 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.ts @@ -0,0 +1,101 @@ +import Color from "color"; + +import type { EdgeStyle, LineStyle, VertexStyle } from "./graphStyles"; + +/** + * Per-element style data pushed onto cytoscape `ele.data()` so a single + * `node` / `edge` stylesheet rule can resolve per-type values via `data(...)` + * mappers — collapsing the ~20k per-type selectors that used to lock up the + * schema view (see #2104) to a fixed handful. Precomputes the two derived + * fields (`ge_borderOpacity`, `ge_labelTextColor`) and the dotted→dashed + + * dash-pattern remap so the style loop stays pure `data()`. + */ + +const LINE_PATTERN: Record = { + solid: undefined, + dashed: [5, 6], + dotted: [1, 2], +}; + +/** Data-mapper fields set on every rendered vertex. Feeds the single `node` rule. */ +export type VertexStyleData = { + ge_color: string; + ge_backgroundOpacity: number; + ge_borderColor: string; + ge_borderWidth: number; + ge_borderOpacity: 0 | 1; + ge_borderStyle: LineStyle; + ge_shape: VertexStyle["shape"]; + /** Absent when the type has no resolved icon; the `node[__iconUrl]` selector gates on it. */ + __iconUrl?: string; +}; + +/** Data-mapper fields set on every rendered edge. Feeds the single `edge` rule. */ +export type EdgeStyleData = { + ge_lineColor: string; + ge_lineStyle: LineStyle; + /** Absent for solid lines; the `edge[ge_lineDashPattern]` selector gates on it. */ + ge_lineDashPattern?: readonly number[]; + ge_sourceArrowShape: EdgeStyle["sourceArrowStyle"]; + ge_targetArrowShape: EdgeStyle["targetArrowStyle"]; + ge_labelTextColor: "#FFFFFF" | "#000000"; + ge_labelBackgroundOpacity: number; + ge_labelBackgroundColor: string; + ge_labelBorderWidth: number; + ge_labelBorderColor: string; + ge_labelBorderStyle: LineStyle; + ge_lineThickness: number; +}; + +/** + * Picks white-on-dark / black-on-light for a label against its background color. + * Falls back to the default label color when unset: an imported style file can + * carry an empty `labelColor`, and `new Color("")` throws. + */ +export function labelTextColorFor(labelColor: string): "#FFFFFF" | "#000000" { + return new Color(labelColor || "#17457b").isDark() ? "#FFFFFF" : "#000000"; +} + +/** Precomputed cytoscape data-mapper fields for a rendered vertex. */ +export function vertexStyleData( + style: VertexStyle, + backgroundImage: string | undefined, +): VertexStyleData { + const data: VertexStyleData = { + ge_color: style.color, + ge_backgroundOpacity: style.backgroundOpacity, + ge_borderColor: style.borderColor, + ge_borderWidth: style.borderWidth, + ge_borderOpacity: style.borderWidth > 0 ? 1 : 0, + ge_borderStyle: style.borderStyle, + ge_shape: style.shape, + }; + if (backgroundImage !== undefined) { + data.__iconUrl = backgroundImage; + } + return data; +} + +/** Precomputed cytoscape data-mapper fields for a rendered edge. */ +export function edgeStyleData(style: EdgeStyle): EdgeStyleData { + const lineStyle: LineStyle = + style.lineStyle === "dotted" ? "dashed" : style.lineStyle; + const dashPattern = LINE_PATTERN[style.lineStyle]; + const data: EdgeStyleData = { + ge_lineColor: style.lineColor, + ge_lineStyle: lineStyle, + ge_sourceArrowShape: style.sourceArrowStyle, + ge_targetArrowShape: style.targetArrowStyle, + ge_labelTextColor: labelTextColorFor(style.labelColor), + ge_labelBackgroundOpacity: style.labelBackgroundOpacity, + ge_labelBackgroundColor: style.labelColor, + ge_labelBorderWidth: style.labelBorderWidth, + ge_labelBorderColor: style.labelBorderColor, + ge_labelBorderStyle: style.labelBorderStyle, + ge_lineThickness: style.lineThickness, + }; + if (dashPattern !== undefined) { + data.ge_lineDashPattern = dashPattern; + } + return data; +} diff --git a/packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts b/packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts index 0957a2df8d..78e4f1b2ff 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts @@ -30,6 +30,35 @@ function createExpectedEdge(existing: EdgeStyleStorage) { }; } +// The single source of truth for the design defaults. Other tests consume +// `appDefaultVertexStyle` / `appDefaultEdgeStyle` (or the resolved styles) as +// a fixture rather than copying these values; this pins them so a change is +// deliberate and consumers stay trustworthy. +describe("app default styles", () => { + it("pins the default vertex style values", () => { + expect(appDefaultVertexStyle).toMatchObject({ + color: "#128EE5", + shape: "ellipse", + backgroundOpacity: 0.4, + borderWidth: 0, + borderColor: "#128EE5", + borderStyle: "solid", + }); + }); + + it("pins the default edge style values", () => { + expect(appDefaultEdgeStyle).toMatchObject({ + labelColor: "#17457b", + labelBackgroundOpacity: 0.7, + lineColor: "#b3b3b3", + lineThickness: 2, + lineStyle: "solid", + sourceArrowStyle: "none", + targetArrowStyle: "triangle", + }); + }); +}); + describe("useVertexStyling", () => { it("should return defaults when the style does not exist", () => { const dbState = new DbState(); diff --git a/packages/graph-explorer/src/core/StateProvider/index.ts b/packages/graph-explorer/src/core/StateProvider/index.ts index a215ca8c79..17ba9bb7bd 100644 --- a/packages/graph-explorer/src/core/StateProvider/index.ts +++ b/packages/graph-explorer/src/core/StateProvider/index.ts @@ -11,6 +11,7 @@ export * from "./neighbors"; export * from "./nodes"; export * from "./renderedEntities"; export * from "./graphStyles"; +export * from "./graphElementStyleData"; export * from "./schema"; export * from "./storageAtoms"; export * from "./graphSession"; diff --git a/packages/graph-explorer/src/core/StateProvider/renderedEntities.ts b/packages/graph-explorer/src/core/StateProvider/renderedEntities.ts index d08814f5e4..80ccea67a6 100644 --- a/packages/graph-explorer/src/core/StateProvider/renderedEntities.ts +++ b/packages/graph-explorer/src/core/StateProvider/renderedEntities.ts @@ -7,17 +7,28 @@ import { type DisplayVertex, edgesFilteredIdsAtom, edgesTypesFilteredAtom, + edgeStyleAtom, type EntityRawId, nodesFilteredIdsAtom, nodesTypesFilteredAtom, useAllNeighbors, + useAllVertexStyles, useDisplayEdgesInCanvas, useDisplayVerticesInCanvas, + vertexStyleAtom, type VertexId, } from "@/core"; +import { useBackgroundImageMap } from "@/core/icons"; import type { EdgeId } from "../entities/edge"; +import { + type EdgeStyleData, + edgeStyleData, + type VertexStyleData, + vertexStyleData, +} from "./graphElementStyleData"; + /** A string representation of a vertex ID that encodes the original type. Cytoscape requires IDs to be strings. */ export type RenderedVertexId = Branded; @@ -36,6 +47,8 @@ export function useRenderedVertices(): RenderedVertex[] { const filteredTypes = useAtomValue(nodesTypesFilteredAtom); const displayVerticesInGraph = useDisplayVerticesInCanvas(); const neighborCounts = useAllNeighbors(); + const vertexStyles = useAtomValue(vertexStyleAtom); + const backgroundImages = useBackgroundImageMap(useAllVertexStyles()); const result: RenderedVertex[] = []; @@ -56,7 +69,15 @@ export function useRenderedVertices(): RenderedVertex[] { if (hasFilteredType) continue; const neighborCount = neighborCounts.get(vertex.id)?.unfetched ?? 0; - result.push(createRenderedVertex(vertex, neighborCount)); + const style = vertexStyles.get(vertex.primaryType); + const backgroundImage = backgroundImages.get(vertex.primaryType); + result.push( + createRenderedVertex( + vertex, + neighborCount, + vertexStyleData(style, backgroundImage), + ), + ); } return result; @@ -68,6 +89,7 @@ export function useRenderedEdges(): RenderedEdge[] { const filteredEdgeIds = useAtomValue(edgesFilteredIdsAtom); const filteredEdgeTypes = useAtomValue(edgesTypesFilteredAtom); const vertices = useRenderedVertices(); + const edgeStyles = useAtomValue(edgeStyleAtom); // Get the IDs of the existing vertices const existingVertexIds = new Set(vertices.map(v => v.data.vertexId)); @@ -84,7 +106,8 @@ export function useRenderedEdges(): RenderedEdge[] { if (!existingVertexIds.has(edge.sourceId)) continue; if (!existingVertexIds.has(edge.targetId)) continue; - result.push(createRenderedEdge(edge)); + const style = edgeStyles.get(edge.type); + result.push(createRenderedEdge(edge, edgeStyleData(style))); } return result; @@ -166,7 +189,11 @@ function stripIdTypePrefix(id: string): string { * - The `id` property is a string * - There exists a `data` property where any custom data is stored */ -function createRenderedVertex(vertex: DisplayVertex, neighborCount: number) { +function createRenderedVertex( + vertex: DisplayVertex, + neighborCount: number, + styleData: VertexStyleData, +) { return { data: { id: createRenderedVertexId(vertex.id), @@ -175,6 +202,7 @@ function createRenderedVertex(vertex: DisplayVertex, neighborCount: number) { displayName: vertex.displayName, displayTypes: vertex.displayTypes, neighborCount, + ...styleData, }, }; } @@ -187,7 +215,7 @@ function createRenderedVertex(vertex: DisplayVertex, neighborCount: number) { * - The `source` and `target` properties are strings * - There exists a `data` property where any custom data is stored */ -function createRenderedEdge(edge: DisplayEdge) { +function createRenderedEdge(edge: DisplayEdge, styleData: EdgeStyleData) { return { data: { id: createRenderedEdgeId(edge.id), @@ -196,6 +224,7 @@ function createRenderedEdge(edge: DisplayEdge) { edgeId: edge.id, type: edge.type, displayName: edge.displayName, + ...styleData, }, }; } diff --git a/packages/graph-explorer/src/core/icons/iconSurfaces.test.tsx b/packages/graph-explorer/src/core/icons/iconSurfaces.test.tsx index 4db651a74a..324f2a8f3b 100644 --- a/packages/graph-explorer/src/core/icons/iconSurfaces.test.tsx +++ b/packages/graph-explorer/src/core/icons/iconSurfaces.test.tsx @@ -9,9 +9,9 @@ import { createVertexType, type VertexStyle, } from "@/core"; -import { useBackgroundImageMap } from "@/modules/GraphViewer/useBackgroundImageMap"; import { iconRegistry } from "./iconRegistry"; +import { useBackgroundImageMap } from "./useBackgroundImageMap"; const REMOTE_SVG = ``; const SHARED_ICON = "https://example.test/shared.svg"; diff --git a/packages/graph-explorer/src/core/icons/index.ts b/packages/graph-explorer/src/core/icons/index.ts index 5308889c1d..3e8fd950ee 100644 --- a/packages/graph-explorer/src/core/icons/index.ts +++ b/packages/graph-explorer/src/core/icons/index.ts @@ -1,4 +1,5 @@ export * from "./iconImageUrl"; export * from "./iconRegistry"; export * from "./iconSource"; +export * from "./useBackgroundImageMap"; export * from "./useResolvedIcons"; diff --git a/packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.test.ts b/packages/graph-explorer/src/core/icons/useBackgroundImageMap.test.ts similarity index 100% rename from packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.test.ts rename to packages/graph-explorer/src/core/icons/useBackgroundImageMap.test.ts diff --git a/packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.ts b/packages/graph-explorer/src/core/icons/useBackgroundImageMap.ts similarity index 93% rename from packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.ts rename to packages/graph-explorer/src/core/icons/useBackgroundImageMap.ts index 1a52a156e7..3289b3185b 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.ts +++ b/packages/graph-explorer/src/core/icons/useBackgroundImageMap.ts @@ -1,13 +1,13 @@ import type { VertexStyle, VertexType } from "@/core"; +import { toIconImageUrl } from "./iconImageUrl"; import { classifyIconSource, type IconSource, type IconSourceId, iconSourceId, - toIconImageUrl, - useResolvedIcons, -} from "@/core/icons"; +} from "./iconSource"; +import { useResolvedIcons } from "./useResolvedIcons"; /** * Maps each vertex type to its cytoscape `background-image`. diff --git a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.contextCount.test.tsx b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.contextCount.test.tsx new file mode 100644 index 0000000000..61e03af06e --- /dev/null +++ b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.contextCount.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment happy-dom +import { waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { createEdgeType, createVertexType } from "@/core"; +import { + createRandomEdgeTypeConfig, + createRandomVertexTypeConfig, + DbState, + renderHookWithState, +} from "@/utils/testing"; + +import useGraphStyles from "./useGraphStyles"; + +// Style-context count is what makes cytoscape style application O(elements × contexts). +// If this drifts to O(vertex-type + edge-type count), the schema view locks up at ~10k +// labels — see #2104. Assertion is O(1) selector count regardless of type count. + +function seedWithTypes(n: number) { + const dbState = new DbState(); + const vertices = Array.from({ length: n }, (_, i) => ({ + ...createRandomVertexTypeConfig(), + type: createVertexType(`vt${i}`), + })); + const edges = Array.from({ length: n }, (_, i) => ({ + ...createRandomEdgeTypeConfig(), + type: createEdgeType(`et${i}`), + })); + dbState.activeSchema.vertices = vertices; + dbState.activeSchema.edges = edges; + for (const v of vertices) dbState.addVertexStyle(v.type, v); + for (const e of edges) dbState.addEdgeStyle(e.type, e); + return dbState; +} + +async function selectorCountFor(n: number): Promise { + const { result } = renderHookWithState( + () => useGraphStyles(), + seedWithTypes(n), + ); + await waitFor(() => expect(result.current).toBeDefined()); + return Object.keys(result.current).length; +} + +describe("useGraphStyles style-context count", () => { + it("stays O(1) regardless of vertex/edge type count", async () => { + const small = await selectorCountFor(2); + const large = await selectorCountFor(50); + expect(large).toBe(small); + }); + + it("emits a small fixed number of selectors, not per-type", async () => { + const count = await selectorCountFor(50); + // node rule + edge rule + at most a couple of gated rules + expect(count).toBeLessThanOrEqual(6); + }); +}); diff --git a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx index 33da0ec8a4..242e69e38f 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.test.tsx @@ -1,352 +1,52 @@ // @vitest-environment happy-dom -import { waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; -import type { GraphProps } from "@/components/Graph"; - -import { createEdgeType, createVertexType } from "@/core"; -import { - createRandomEdgeTypeConfig, - createRandomVertexTypeConfig, - DbState, - renderHookWithState, -} from "@/utils/testing"; +import { renderHookWithState } from "@/utils/testing"; import useGraphStyles from "./useGraphStyles"; -// A raster icon resolves synchronously to its url, so this test can exercise -// the real icon pipeline and still pin the expected background image. -const RASTER_ICON = { - iconUrl: "https://example.test/icon.png", - iconImageType: "image/png", -} as const; - +// The stylesheet is now O(1) in the number of types: every per-type value flows +// onto element `data()` via `vertexStyleData` / `edgeStyleData` and the rules +// read them via `data(...)` mappers. See #2104. describe("useGraphStyles", () => { - let dbState: DbState; - - // Helper function to safely access result.current - const getStyles = (result: { current: GraphProps["styles"] | undefined }) => { - if (!result.current) { - throw new Error("result.current is undefined"); - } - return result.current; - }; - - beforeEach(() => { - dbState = new DbState(); - }); - - it("should generate vertex styles correctly", async () => { - const vertexConfig = { - ...createRandomVertexTypeConfig(), - ...RASTER_ICON, - type: createVertexType("Person"), - color: "#128EE5", - backgroundOpacity: 0.8, - borderColor: "#000000", - borderWidth: 2, - borderStyle: "solid" as const, - shape: "ellipse" as const, - }; - dbState.activeSchema.vertices = [vertexConfig]; - dbState.addVertexStyle(vertexConfig.type, vertexConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - await waitFor(() => { - const vertexStyle = getStyles(result)[`node[type="Person"]`] as any; - expect(vertexStyle).toEqual({ - "background-image": RASTER_ICON.iconUrl, - "background-color": "#128EE5", - "background-opacity": 0.8, - "border-color": "#000000", - "border-width": 2, - "border-opacity": 1, - "border-style": "solid", - shape: "ellipse", - width: 24, - height: 24, - }); - }); - }); - - it("should generate edge styles correctly", () => { - const edgeConfig = { - ...createRandomEdgeTypeConfig(), - type: createEdgeType("KNOWS"), - labelColor: "#17457b", - lineColor: "#b3b3b3", - lineStyle: "solid" as const, - lineThickness: 2, - sourceArrowStyle: "none" as const, - targetArrowStyle: "triangle" as const, - labelBackgroundOpacity: 0.8, - labelBorderWidth: 1, - labelBorderColor: "#000000", - labelBorderStyle: "solid" as const, - }; - dbState.activeSchema.edges = [edgeConfig]; - dbState.addEdgeStyle(edgeConfig.type, edgeConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - const edgeStyle = getStyles(result)[`edge[type="KNOWS"]`] as any; - expect(edgeStyle).toMatchObject({ - color: "#FFFFFF", // White text for dark background - "line-color": "#b3b3b3", - "line-style": "solid", - "line-dash-pattern": undefined, - "source-arrow-shape": "none", - "source-arrow-color": "#b3b3b3", - "target-arrow-shape": "triangle", - "target-arrow-color": "#b3b3b3", - "text-background-opacity": 0.8, - "text-background-color": "#17457b", - "text-border-width": 1, - "text-border-color": "#000000", - "text-border-style": "solid", - width: 2, - "source-distance-from-node": 0, - "target-distance-from-node": 0, - }); - }); - - it("should set border-opacity to 1 when border width is non-zero", () => { - const vertexConfig = { - ...createRandomVertexTypeConfig(), - type: createVertexType("Person"), - borderWidth: 3, - }; - dbState.activeSchema.vertices = [vertexConfig]; - dbState.addVertexStyle(vertexConfig.type, vertexConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - const vertexStyle = getStyles(result)[`node[type="Person"]`] as any; - expect(vertexStyle["border-opacity"]).toBe(1); - }); - - it("should set border-opacity to 0 when border width is zero", () => { - const vertexConfig = { - ...createRandomVertexTypeConfig(), - type: createVertexType("Person"), - borderWidth: 0, - }; - dbState.activeSchema.vertices = [vertexConfig]; - dbState.addVertexStyle(vertexConfig.type, vertexConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - const vertexStyle = getStyles(result)[`node[type="Person"]`] as any; - expect(vertexStyle["border-width"]).toBe(0); - expect(vertexStyle["border-opacity"]).toBe(0); - }); - - it("should handle edge config with dotted line style", () => { - const edgeConfig = { - ...createRandomEdgeTypeConfig(), - type: createEdgeType("KNOWS"), - lineStyle: "dotted" as const, - }; - dbState.activeSchema.edges = [edgeConfig]; - dbState.addEdgeStyle(edgeConfig.type, edgeConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - const edgeStyle = getStyles(result)[`edge[type="KNOWS"]`] as any; - expect(edgeStyle["line-style"]).toBe("dashed"); - expect(edgeStyle["line-dash-pattern"]).toEqual([1, 2]); - }); - - it("should handle edge config with dashed line style", () => { - const edgeConfig = { - ...createRandomEdgeTypeConfig(), - type: createEdgeType("KNOWS"), - lineStyle: "dashed" as const, - }; - dbState.activeSchema.edges = [edgeConfig]; - dbState.addEdgeStyle(edgeConfig.type, edgeConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - const edgeStyle = getStyles(result)[`edge[type="KNOWS"]`] as any; - expect(edgeStyle["line-style"]).toBe("dashed"); - expect(edgeStyle["line-dash-pattern"]).toEqual([5, 6]); - }); - - it("should use light text color for light label background", () => { - const edgeConfig = { - ...createRandomEdgeTypeConfig(), - type: createEdgeType("KNOWS"), - labelColor: "#ffffff", - }; - dbState.activeSchema.edges = [edgeConfig]; - dbState.addEdgeStyle(edgeConfig.type, edgeConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - const edgeStyle = getStyles(result)[`edge[type="KNOWS"]`] as any; - expect(edgeStyle.color).toBe("#000000"); // Black text for light background - }); - - it("should handle text transformation for edge labels", () => { - const edgeConfig = createRandomEdgeTypeConfig(); - dbState.activeSchema.edges = [edgeConfig]; - dbState.addEdgeStyle(edgeConfig.type, edgeConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - // The hook should work with the text transform functionality - // This test verifies the integration works properly - expect(getStyles(result)[`edge[type="${edgeConfig.type}"]`]).toBeDefined(); - }); - - it("should truncate long edge labels", () => { - const longEdgeType = createEdgeType( - "VERY_LONG_EDGE_TYPE_NAME_THAT_EXCEEDS_LIMIT", - ); - const edgeConfig = { - ...createRandomEdgeTypeConfig(), - type: longEdgeType, - }; - dbState.activeSchema.edges = [edgeConfig]; - dbState.addEdgeStyle(edgeConfig.type, edgeConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - // The label function should be defined, but we can't easily test the truncation - // without mocking the text transform function more specifically - const edgeStyle = getStyles(result)[`edge[type="${longEdgeType}"]`] as any; - expect(edgeStyle.label).toBeDefined(); - }); - - it("should have label use the display name in the data", () => { - const edgeConfig = { - ...createRandomEdgeTypeConfig(), - type: createEdgeType("KNOWS"), - }; - dbState.activeSchema.edges = [edgeConfig]; - dbState.addEdgeStyle(edgeConfig.type, edgeConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - const edgeStyle = getStyles(result)[`edge[type="KNOWS"]`] as any; - expect(edgeStyle.label).toEqual("data(displayName)"); - }); - - it("omits the background image when the vertex type has no icon", () => { - const vertexConfig = { - ...createRandomVertexTypeConfig(), - type: createVertexType("Person"), - iconUrl: "", - }; - dbState.activeSchema.vertices = [vertexConfig]; - dbState.addVertexStyle(vertexConfig.type, vertexConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - const vertexStyle = getStyles(result)[`node[type="Person"]`] as any; - expect(vertexStyle["background-image"]).toBeUndefined(); - }); - - it("should handle multiple vertex and edge types", () => { - const personConfig = { - ...createRandomVertexTypeConfig(), - type: createVertexType("Person"), - }; - const companyConfig = { - ...createRandomVertexTypeConfig(), - type: createVertexType("Company"), - color: "#ff0000", - }; - - const knowsConfig = createRandomEdgeTypeConfig(); - const worksAtConfig = { - ...createRandomEdgeTypeConfig(), - type: createEdgeType("WORKS_AT"), - lineColor: "#00ff00", - }; - - dbState.activeSchema.vertices = [personConfig, companyConfig]; - dbState.activeSchema.edges = [knowsConfig, worksAtConfig]; - dbState.addVertexStyle(personConfig.type, personConfig); - dbState.addVertexStyle(companyConfig.type, companyConfig); - dbState.addEdgeStyle(knowsConfig.type, knowsConfig); - dbState.addEdgeStyle(worksAtConfig.type, worksAtConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - expect( - (getStyles(result)[`node[type="Company"]`] as any)["background-color"], - ).toBe("#ff0000"); - expect( - (getStyles(result)[`edge[type="WORKS_AT"]`] as any)["line-color"], - ).toBe("#00ff00"); - }); - - it("should update styles when configs change", () => { - const vertexConfig = { - ...createRandomVertexTypeConfig(), - type: createVertexType("Person"), - color: "#ff0000", - }; - const edgeConfig = createRandomEdgeTypeConfig(); - - const updatedDbState = new DbState(); - updatedDbState.activeSchema.vertices = [vertexConfig]; - updatedDbState.activeSchema.edges = [edgeConfig]; - updatedDbState.addVertexStyle(vertexConfig.type, vertexConfig); - dbState.addEdgeStyle(edgeConfig.type, edgeConfig); - - const { result } = renderHookWithState( - () => useGraphStyles(), - updatedDbState, - ); - - expect( - (getStyles(result)[`node[type="Person"]`] as any)["background-color"], - ).toBe("#ff0000"); - }); - - it("should handle edge config with undefined optional properties", () => { - const minimalEdgeConfig = { - ...createRandomEdgeTypeConfig(), - type: createEdgeType("MINIMAL"), - // Remove optional properties to test undefined handling - labelBackgroundOpacity: undefined, - labelBorderWidth: undefined, - labelColor: undefined, - }; - - dbState.activeSchema.edges = [minimalEdgeConfig]; - dbState.addEdgeStyle(minimalEdgeConfig.type, minimalEdgeConfig); - - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - const edgeStyle = getStyles(result)[`edge[type="MINIMAL"]`] as any; - expect(edgeStyle["text-background-opacity"]).toBeUndefined(); - expect(edgeStyle["text-background-color"]).toBeUndefined(); - expect(edgeStyle["text-border-width"]).toBeUndefined(); - }); - - it("should use deferred values for performance", () => { - const vertexConfig = createRandomVertexTypeConfig(); - const edgeConfig = createRandomEdgeTypeConfig(); - - dbState.activeSchema.vertices = [vertexConfig]; - dbState.activeSchema.edges = [edgeConfig]; - dbState.addVertexStyle(vertexConfig.type, vertexConfig); - dbState.addEdgeStyle(edgeConfig.type, edgeConfig); - - // This test ensures that the hook uses useDeferredValue for configs - // The actual deferring behavior is handled by React, so we just verify - // that the hook works with the provided configs - const { result } = renderHookWithState(() => useGraphStyles(), dbState); - - // Verify that the hook successfully processes the configurations - expect( - getStyles(result)[`node[type="${vertexConfig.type}"]`], - ).toBeDefined(); - expect(getStyles(result)[`edge[type="${edgeConfig.type}"]`]).toBeDefined(); + it("emits one node rule + one edge rule + one gated dash-pattern rule", () => { + const { result } = renderHookWithState(() => useGraphStyles()); + const selectors = Object.keys(result.current).sort(); + expect(selectors).toEqual(["edge", "edge[ge_lineDashPattern]", "node"]); + }); + + it("uses data() mappers for every per-type property", () => { + const { result } = renderHookWithState(() => useGraphStyles()); + const styles = result.current; + const nodeRule = styles.node as Record; + const edgeRule = styles.edge as Record; + + // Node — every per-type visual reads from element data. + expect(nodeRule["background-color"]).toBe("data(ge_color)"); + expect(nodeRule["background-opacity"]).toBe("data(ge_backgroundOpacity)"); + expect(nodeRule["border-color"]).toBe("data(ge_borderColor)"); + expect(nodeRule["border-width"]).toBe("data(ge_borderWidth)"); + expect(nodeRule["border-opacity"]).toBe("data(ge_borderOpacity)"); + expect(nodeRule["border-style"]).toBe("data(ge_borderStyle)"); + expect(nodeRule["shape"]).toBe("data(ge_shape)"); + + // Edge — every per-type visual reads from element data. + expect(edgeRule["color"]).toBe("data(ge_labelTextColor)"); + expect(edgeRule["line-color"]).toBe("data(ge_lineColor)"); + expect(edgeRule["line-style"]).toBe("data(ge_lineStyle)"); + expect(edgeRule["source-arrow-shape"]).toBe("data(ge_sourceArrowShape)"); + expect(edgeRule["target-arrow-shape"]).toBe("data(ge_targetArrowShape)"); + expect(edgeRule["width"]).toBe("data(ge_lineThickness)"); + }); + + it("gates line-dash-pattern on ge_lineDashPattern presence", () => { + // Solid edges omit the field and fall through to the default; only dashed/ + // dotted edges pick up the pattern via the gated selector. + const { result } = renderHookWithState(() => useGraphStyles()); + const dashRule = result.current["edge[ge_lineDashPattern]"] as Record< + string, + unknown + >; + expect(dashRule["line-dash-pattern"]).toBe("data(ge_lineDashPattern)"); }); }); diff --git a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts index 869ded0421..9aa97d5bb1 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts @@ -1,93 +1,52 @@ -import Color from "color"; -import { useDeferredValue } from "react"; - import type { GraphProps } from "@/components/Graph"; -import { - type EdgeStyle, - useAllEdgeStyles, - useAllVertexStyles, - type VertexStyle, - type VertexType, -} from "@/core"; - -import { useBackgroundImageMap } from "./useBackgroundImageMap"; - -const LINE_PATTERN = { - solid: undefined, - dashed: [5, 6], - dotted: [1, 2], -}; - -export default function useGraphStyles() { - const vtConfigs = useAllVertexStyles(); - const etConfigs = useAllEdgeStyles(); - - const deferredVtConfigs = useDeferredValue(vtConfigs); - const deferredEtConfigs = useDeferredValue(etConfigs); - - const backgroundImageMap = useBackgroundImageMap(deferredVtConfigs); - - return createGraphStyles( - deferredVtConfigs, - deferredEtConfigs, - backgroundImageMap, - ); +/** + * Cytoscape stylesheet for the graph canvas. + * + * Every per-type visual value is precomputed onto element `data()` by + * `vertexStyleData` / `edgeStyleData` and read back through `data(...)` + * mappers, so the stylesheet is O(1) in the number of types rather than one + * selector per type — see #2104. Two gated selectors handle absent-field + * cases (`__iconUrl` for typed icons, `ge_lineDashPattern` for non-solid + * edges). + */ +export default function useGraphStyles(): NonNullable { + return CANVAS_STYLES; } -function createGraphStyles( - deferredVtConfigs: VertexStyle[], - deferredEtConfigs: EdgeStyle[], - backgroundImageMap: Map, -): GraphProps["styles"] { - const styles: GraphProps["styles"] = {}; - - for (const vtConfig of deferredVtConfigs) { - const vt = vtConfig.type; - - const backgroundImage = backgroundImageMap.get(vt); - - styles[`node[type="${vt}"]`] = { - "background-image": backgroundImage, - "background-color": vtConfig.color, - "background-opacity": vtConfig.backgroundOpacity, - "border-color": vtConfig.borderColor, - "border-width": vtConfig.borderWidth, - "border-opacity": vtConfig.borderWidth > 0 ? 1 : 0, - "border-style": vtConfig.borderStyle, - shape: vtConfig.shape, - width: 24, - height: 24, - }; - } - - for (const etConfig of deferredEtConfigs) { - const et = etConfig?.type; - - styles[`edge[type="${et}"]`] = { - label: "data(displayName)", - color: new Color(etConfig?.labelColor || "#17457b").isDark() - ? "#FFFFFF" - : "#000000", - "line-color": etConfig.lineColor, - "line-style": - etConfig.lineStyle === "dotted" ? "dashed" : etConfig.lineStyle, - "line-dash-pattern": etConfig.lineStyle - ? LINE_PATTERN[etConfig.lineStyle] - : undefined, - "source-arrow-shape": etConfig.sourceArrowStyle, - "source-arrow-color": etConfig.lineColor, - "target-arrow-shape": etConfig.targetArrowStyle, - "target-arrow-color": etConfig.lineColor, - "text-background-opacity": etConfig?.labelBackgroundOpacity, - "text-background-color": etConfig?.labelColor, - "text-border-width": etConfig?.labelBorderWidth, - "text-border-color": etConfig?.labelBorderColor, - "text-border-style": etConfig?.labelBorderStyle, - width: etConfig.lineThickness, - "source-distance-from-node": 0, - "target-distance-from-node": 0, - }; - } - return styles; -} +const CANVAS_STYLES: NonNullable = { + node: { + "background-color": "data(ge_color)", + "background-opacity": "data(ge_backgroundOpacity)", + "border-color": "data(ge_borderColor)", + "border-width": "data(ge_borderWidth)", + "border-opacity": "data(ge_borderOpacity)", + "border-style": "data(ge_borderStyle)", + shape: "data(ge_shape)", + width: 24, + height: 24, + }, + edge: { + label: "data(displayName)", + color: "data(ge_labelTextColor)", + "line-color": "data(ge_lineColor)", + "line-style": "data(ge_lineStyle)", + "source-arrow-shape": "data(ge_sourceArrowShape)", + "source-arrow-color": "data(ge_lineColor)", + "target-arrow-shape": "data(ge_targetArrowShape)", + "target-arrow-color": "data(ge_lineColor)", + "text-background-opacity": "data(ge_labelBackgroundOpacity)", + "text-background-color": "data(ge_labelBackgroundColor)", + "text-border-width": "data(ge_labelBorderWidth)", + "text-border-color": "data(ge_labelBorderColor)", + "text-border-style": "data(ge_labelBorderStyle)", + width: "data(ge_lineThickness)", + // Strings, not numbers: the mapper-heavy edge rule resolves to the union's + // string/mapper branch, which rejects numeric literals; cytoscape coerces. + "source-distance-from-node": "0", + "target-distance-from-node": "0", + }, + "edge[ge_lineDashPattern]": { + "line-dash-pattern": "data(ge_lineDashPattern)", + }, +}; diff --git a/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.test.tsx b/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.test.tsx new file mode 100644 index 0000000000..b646276ba1 --- /dev/null +++ b/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom +import { waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; + +import { createEdgeType, createVertexType } from "@/core"; +import { useBackgroundImageMap } from "@/core/icons"; +import { + createRandomEdgeTypeConfig, + createRandomVertexTypeConfig, + DbState, + renderHookWithState, +} from "@/utils/testing"; + +import { useSchemaGraphData } from "./useSchemaGraphData"; + +vi.mock(import("@/core/icons"), async importOriginal => ({ + ...(await importOriginal()), + useBackgroundImageMap: vi.fn(), +})); +const mockMap = useBackgroundImageMap as Mock; + +describe("useSchemaGraphData", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMap.mockImplementation( + (cfgs: { type: string }[]) => + new Map(cfgs.map(c => [c.type, "img:" + c.type])), + ); + }); + + it("enriches nodes with per-type ge_* style data", async () => { + const vertexConfig = { + ...createRandomVertexTypeConfig(), + type: createVertexType("Person"), + color: "#128EE5", + shape: "hexagon" as const, + borderWidth: 2, + }; + const dbState = new DbState(); + dbState.activeSchema.vertices = [vertexConfig]; + dbState.addVertexStyle(vertexConfig.type, vertexConfig); + + const { result } = renderHookWithState(() => useSchemaGraphData(), dbState); + await waitFor(() => expect(result.current.nodes.length).toBe(1)); + + const node = result.current.nodes[0]; + expect(node.data.ge_color).toBe("#128EE5"); + expect(node.data.ge_shape).toBe("hexagon"); + expect(node.data.ge_borderWidth).toBe(2); + expect(node.data.ge_borderOpacity).toBe(1); + expect(node.data.__iconUrl).toBe("img:Person"); + }); + + it("enriches edges with per-type ge_* style data (solid: no dash pattern)", async () => { + const vertexConfig = { + ...createRandomVertexTypeConfig(), + type: createVertexType("Person"), + }; + const edgeConfig = { + ...createRandomEdgeTypeConfig(), + type: createEdgeType("KNOWS"), + lineColor: "#ff0000", + lineStyle: "solid" as const, + sourceArrowStyle: "none" as const, + targetArrowStyle: "triangle" as const, + }; + const dbState = new DbState(); + dbState.activeSchema.vertices = [vertexConfig]; + dbState.activeSchema.edges = [edgeConfig]; + dbState.addVertexStyle(vertexConfig.type, vertexConfig); + dbState.addEdgeStyle(edgeConfig.type, edgeConfig); + dbState.activeSchema.edgeConnections = [ + { + edgeType: edgeConfig.type, + sourceVertexType: vertexConfig.type, + targetVertexType: vertexConfig.type, + }, + ]; + + const { result } = renderHookWithState(() => useSchemaGraphData(), dbState); + await waitFor(() => expect(result.current.edges.length).toBe(1)); + + const edge = result.current.edges[0]; + expect(edge.data.ge_lineColor).toBe("#ff0000"); + expect(edge.data.ge_lineStyle).toBe("solid"); + expect(edge.data.ge_targetArrowShape).toBe("triangle"); + expect(edge.data.ge_lineDashPattern).toBeUndefined(); + }); + + it("emits ge_lineDashPattern for dashed edges", async () => { + const vertexConfig = { + ...createRandomVertexTypeConfig(), + type: createVertexType("Person"), + }; + const edgeConfig = { + ...createRandomEdgeTypeConfig(), + type: createEdgeType("KNOWS"), + lineStyle: "dashed" as const, + }; + const dbState = new DbState(); + dbState.activeSchema.vertices = [vertexConfig]; + dbState.activeSchema.edges = [edgeConfig]; + dbState.addVertexStyle(vertexConfig.type, vertexConfig); + dbState.addEdgeStyle(edgeConfig.type, edgeConfig); + dbState.activeSchema.edgeConnections = [ + { + edgeType: edgeConfig.type, + sourceVertexType: vertexConfig.type, + targetVertexType: vertexConfig.type, + }, + ]; + + const { result } = renderHookWithState(() => useSchemaGraphData(), dbState); + await waitFor(() => expect(result.current.edges.length).toBe(1)); + + expect(result.current.edges[0].data.ge_lineDashPattern).toEqual([5, 6]); + }); +}); diff --git a/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.ts b/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.ts index 782fd58825..c69bf8c2e9 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.ts +++ b/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.ts @@ -1,21 +1,31 @@ +import { useAtomValue } from "jotai"; + import type { GraphEdge, GraphNode } from "@/components/Graph"; import { createEdgeConnectionId, type EdgeConnectionId, + edgeStyleAtom, + edgeStyleData, + type EdgeStyleData, type EdgeType, useActiveSchema, + useAllVertexStyles, useDisplayEdgeTypeConfigs, useDisplayVertexTypeConfigs, + vertexStyleAtom, + vertexStyleData, + type VertexStyleData, type VertexType, } from "@/core"; +import { useBackgroundImageMap } from "@/core/icons"; type SchemaGraphNode = GraphNode & { data: { id: VertexType; type: VertexType; displayLabel: string; - }; + } & VertexStyleData; }; type SchemaGraphEdge = GraphEdge & { @@ -25,7 +35,7 @@ type SchemaGraphEdge = GraphEdge & { target: VertexType; type: EdgeType; displayLabel: string; - }; + } & EdgeStyleData; }; /** @@ -44,15 +54,20 @@ export function useSchemaGraphData() { /** Transforms vertex type configs into schema graph nodes. */ function useSchemaGraphNodes(): SchemaGraphNode[] { const vtConfigs = useDisplayVertexTypeConfigs(); + const vertexStyles = useAtomValue(vertexStyleAtom); + const backgroundImages = useBackgroundImageMap(useAllVertexStyles()); const nodes: SchemaGraphNode[] = []; for (const config of vtConfigs.values()) { + const style = vertexStyles.get(config.type); + const backgroundImage = backgroundImages.get(config.type); nodes.push({ data: { id: config.type, type: config.type, displayLabel: config.displayLabel, + ...vertexStyleData(style, backgroundImage), }, }); } @@ -67,6 +82,7 @@ function useSchemaGraphEdges( const schema = useActiveSchema(); const edgeConnections = schema.edgeConnections ?? []; const etConfigs = useDisplayEdgeTypeConfigs(); + const edgeStyles = useAtomValue(edgeStyleAtom); const edges: SchemaGraphEdge[] = []; @@ -77,6 +93,7 @@ function useSchemaGraphEdges( const edgeConfig = etConfigs.get(connection.edgeType); const displayLabel = edgeConfig?.displayLabel ?? connection.edgeType; + const style = edgeStyles.get(connection.edgeType); edges.push({ data: { @@ -85,6 +102,7 @@ function useSchemaGraphEdges( target: connection.targetVertexType, type: connection.edgeType, displayLabel, + ...edgeStyleData(style), }, }); } diff --git a/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphStyles.test.tsx b/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphStyles.test.tsx new file mode 100644 index 0000000000..4d88b2981e --- /dev/null +++ b/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphStyles.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; + +import { renderHookWithState } from "@/utils/testing"; + +import { useSchemaGraphStyles } from "./useSchemaGraphStyles"; + +// The schema view adds a `displayLabel` label to the base node/edge rules. Those +// base rules carry the per-type `data(ge_*)` style mappers (#2104), so the label +// must be MERGED in, not replace the rule — otherwise the schema graph renders +// unstyled. These guard that seam and its O(1) selector count. +describe("useSchemaGraphStyles", () => { + it("retains the base node/edge data(ge_*) mappers alongside the schema label", () => { + const { result } = renderHookWithState(() => useSchemaGraphStyles()); + const styles = result.current!; + const node = styles.node as Record; + const edge = styles.edge as Record; + + // Label override applied… + expect(node["label"]).toBe("data(displayLabel)"); + expect(edge["label"]).toBe("data(displayLabel)"); + // …without clobbering the per-type style mappers. + expect(node["background-color"]).toBe("data(ge_color)"); + expect(node["shape"]).toBe("data(ge_shape)"); + expect(edge["line-color"]).toBe("data(ge_lineColor)"); + expect(edge["color"]).toBe("data(ge_labelTextColor)"); + }); + + it("stays O(1) in selector count", () => { + const { result } = renderHookWithState(() => useSchemaGraphStyles()); + // node + edge + gated edge[ge_lineDashPattern] + expect(Object.keys(result.current!).length).toBeLessThanOrEqual(6); + }); +}); diff --git a/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphStyles.ts b/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphStyles.ts index 48d9ed949d..1491302e72 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphStyles.ts +++ b/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphStyles.ts @@ -9,12 +9,17 @@ import useGraphStyles from "@/modules/GraphViewer/useGraphStyles"; export function useSchemaGraphStyles(): GraphProps["styles"] { const baseStyles = useGraphStyles(); + // Merge the schema label into the base node/edge rules rather than replacing + // them — the base rules carry the per-type `data(ge_*)` style mappers, so a + // wholesale override would leave the schema graph unstyled. return { ...baseStyles, node: { + ...baseStyles.node, label: "data(displayLabel)", }, edge: { + ...baseStyles.edge, label: "data(displayLabel)", }, }; From 852648e12c78f9a69d1a4299112484b06ba48da5 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 14 Aug 2026 15:52:10 -0500 Subject: [PATCH 2/2] Share one memoized label text color helper across canvas and previews (#2118) `LabelPreview` inlined its own `new Color(...).isDark()` with no guard for the empty `labelColor` an imported style file can carry, so the preview could throw where the canvas does not. Both now call `labelTextColorFor`, memoized because parsing a color is the only non-trivial work here. Also reads the fallback from `appDefaultEdgeStyle.labelColor` rather than repeating the hex, and looks up line dash patterns through a `Map` so a `lineStyle` colliding with `Object.prototype` cannot resolve to a function. --- .../src/components/LabelPreview.test.tsx | 3 +- .../src/components/LabelPreview.tsx | 13 +++---- .../graphElementStyleData.test.ts | 11 ++++++ .../StateProvider/graphElementStyleData.ts | 35 ++++++++++++++----- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/packages/graph-explorer/src/components/LabelPreview.test.tsx b/packages/graph-explorer/src/components/LabelPreview.test.tsx index 12b71bc2b8..08c59e9ff4 100644 --- a/packages/graph-explorer/src/components/LabelPreview.test.tsx +++ b/packages/graph-explorer/src/components/LabelPreview.test.tsx @@ -20,9 +20,10 @@ function renderLabel(style: LabelVisualStyle, scale = 2) { describe("LabelPreview", () => { describe("text color follows label darkness for contrast", () => { + // Casing follows `labelTextColorFor`, the helper the canvas shares. it("uses white text on a dark label color", () => { const el = renderLabel(labelStyle({ labelColor: "#1d2531" })); - expect(el.style.color).toBe("#ffffff"); + expect(el.style.color).toBe("#FFFFFF"); }); it("uses black text on a light label color", () => { diff --git a/packages/graph-explorer/src/components/LabelPreview.tsx b/packages/graph-explorer/src/components/LabelPreview.tsx index fed3f794ab..33f8985c08 100644 --- a/packages/graph-explorer/src/components/LabelPreview.tsx +++ b/packages/graph-explorer/src/components/LabelPreview.tsx @@ -1,9 +1,6 @@ import type React from "react"; -import Color from "color"; - -import type { LabelVisualStyle } from "@/core"; - +import { type LabelVisualStyle, labelTextColorFor } from "@/core"; import { cn } from "@/utils"; /** @@ -31,8 +28,8 @@ interface LabelPreviewProps { /** * A label badge preview that faithfully matches cytoscape's canvas rendering - * at any scale. Text color is derived from `labelColor` darkness (white on dark, - * black on light) — same logic as `useGraphStyles.ts`. + * at any scale. Text color comes from `labelTextColorFor`, the same helper the + * canvas uses, so a preview cannot drift from what gets drawn. * * Used for both vertex and edge label previews. */ @@ -53,9 +50,7 @@ export function LabelPreview({ fontSize: FONT_SIZE * scale, padding: PADDING * scale, borderRadius: BORDER_RADIUS * scale, - color: new Color(labelStyle.labelColor).isDark() - ? "#ffffff" - : "#000000", + color: labelTextColorFor(labelStyle.labelColor), backgroundColor: `color-mix(in srgb, ${labelStyle.labelColor} ${labelStyle.labelBackgroundOpacity * 100}%, transparent)`, borderWidth: labelStyle.labelBorderWidth * scale || undefined, borderStyle: diff --git a/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.test.ts b/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.test.ts index d27b189485..bbdbdd5daf 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.test.ts @@ -117,4 +117,15 @@ describe("labelTextColorFor", () => { expect(() => labelTextColorFor("")).not.toThrow(); expect(labelTextColorFor("")).toBe("#FFFFFF"); }); + + // The result is memoized in a module-level map, so a repeat call must not be + // able to return a different answer than the first. + it("returns a stable answer across repeated calls", () => { + expect(labelTextColorFor("#123456")).toBe(labelTextColorFor("#123456")); + expect(labelTextColorFor("")).toBe(labelTextColorFor("")); + }); + + it("keys the memo per color rather than sharing one answer", () => { + expect(labelTextColorFor("#000000")).not.toBe(labelTextColorFor("#ffffff")); + }); }); diff --git a/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.ts b/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.ts index 602f90a5b5..366e60c1ca 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphElementStyleData.ts @@ -1,6 +1,11 @@ import Color from "color"; -import type { EdgeStyle, LineStyle, VertexStyle } from "./graphStyles"; +import { + appDefaultEdgeStyle, + type EdgeStyle, + type LineStyle, + type VertexStyle, +} from "./graphStyles"; /** * Per-element style data pushed onto cytoscape `ele.data()` so a single @@ -11,11 +16,11 @@ import type { EdgeStyle, LineStyle, VertexStyle } from "./graphStyles"; * dash-pattern remap so the style loop stays pure `data()`. */ -const LINE_PATTERN: Record = { - solid: undefined, - dashed: [5, 6], - dotted: [1, 2], -}; +/** A `Map` so a type name colliding with `Object.prototype` cannot resolve to a function. */ +const LINE_PATTERN = new Map([ + ["dashed", [5, 6]], + ["dotted", [1, 2]], +]); /** Data-mapper fields set on every rendered vertex. Feeds the single `node` rule. */ export type VertexStyleData = { @@ -47,13 +52,27 @@ export type EdgeStyleData = { ge_lineThickness: number; }; +/** + * Memoized because parsing a color is the one non-trivial computation in this + * module, and the number of distinct label colors in a graph is tiny next to + * the number of edges asking about them. + */ +const labelTextColors = new Map(); + /** * Picks white-on-dark / black-on-light for a label against its background color. * Falls back to the default label color when unset: an imported style file can * carry an empty `labelColor`, and `new Color("")` throws. */ export function labelTextColorFor(labelColor: string): "#FFFFFF" | "#000000" { - return new Color(labelColor || "#17457b").isDark() ? "#FFFFFF" : "#000000"; + let textColor = labelTextColors.get(labelColor); + if (textColor === undefined) { + textColor = new Color(labelColor || appDefaultEdgeStyle.labelColor).isDark() + ? "#FFFFFF" + : "#000000"; + labelTextColors.set(labelColor, textColor); + } + return textColor; } /** Precomputed cytoscape data-mapper fields for a rendered vertex. */ @@ -80,7 +99,7 @@ export function vertexStyleData( export function edgeStyleData(style: EdgeStyle): EdgeStyleData { const lineStyle: LineStyle = style.lineStyle === "dotted" ? "dashed" : style.lineStyle; - const dashPattern = LINE_PATTERN[style.lineStyle]; + const dashPattern = LINE_PATTERN.get(style.lineStyle); const data: EdgeStyleData = { ge_lineColor: style.lineColor, ge_lineStyle: lineStyle,