Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/adr/20260813-element-data-style-mappers.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/adr/20260813-icon-registry-not-react-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion packages/graph-explorer/src/components/LabelPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
13 changes: 4 additions & 9 deletions packages/graph-explorer/src/components/LabelPreview.tsx
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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<VertexStyle> = {}) =>
({
...appDefaultVertexStyle,
type: createVertexType("Person"),
...overrides,
}) satisfies VertexStyle;

const edge = (overrides: Partial<EdgeStyle> = {}) =>
({
...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");
});

// 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"));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Color from "color";

import {
appDefaultEdgeStyle,
type EdgeStyle,
type LineStyle,
type 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()`.
*/

/** A `Map` so a type name colliding with `Object.prototype` cannot resolve to a function. */
const LINE_PATTERN = new Map<LineStyle, readonly number[]>([
["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;
};

/**
* 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<string, "#FFFFFF" | "#000000">();

/**
* 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" {
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. */
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.get(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;
}
29 changes: 29 additions & 0 deletions packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions packages/graph-explorer/src/core/StateProvider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading