Skip to content
Closed
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
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
Expand Up @@ -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"));
});
});
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<LineStyle, readonly number[] | undefined> = {
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<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 = {
Expand Down Expand Up @@ -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<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" {
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. */
Expand All @@ -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,
Expand Down
23 changes: 12 additions & 11 deletions packages/graph-explorer/src/core/StateProvider/graphStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,14 @@ export type LegacyUserStylingStorage = {
* seam in that parser, not here.
*/

/** Resolves the full style of any vertex type. */
export type VertexStyleLookup = { get(type: VertexType): VertexStyle };

/** Resolves the full style of any edge type. */
export type EdgeStyleLookup = { get(type: EdgeType): EdgeStyle };

/** Vertex styles indexed by type for O(1) lookup, resolved against defaults. */
export const vertexStyleAtom = atom(get => {
export const vertexStyleAtom = atom<VertexStyleLookup>(get => {
const userStyles = get(userVertexStylesAtom);
return {
get(type: VertexType) {
Expand All @@ -210,7 +216,7 @@ export const vertexStyleAtom = atom(get => {
});

/** Edge styles indexed by type for O(1) lookup, resolved against defaults. */
export const edgeStyleAtom = atom(get => {
export const edgeStyleAtom = atom<EdgeStyleLookup>(get => {
const userStyles = get(userEdgeStylesAtom);
return {
get(type: EdgeType) {
Expand Down Expand Up @@ -244,8 +250,10 @@ export function resolveEdgeStyle(
}

/** Returns an array of vertex styles based on the known vertex types in the schema.
* Always includes an entry for `LABELS.MISSING_TYPE` so that blank nodes (which are
* assigned that synthetic type at runtime) receive icon styling on the canvas.
* For the schema view, which draws every type; the canvas scopes itself to the
* types it draws via `canvasVertexStylesAtom`. Always includes an entry for
* `LABELS.MISSING_TYPE` so blank nodes (assigned that synthetic type at runtime)
* are styled rather than skipped.
*/
export function useAllVertexStyles(): VertexStyle[] {
const styles = useAtomValue(vertexStyleAtom);
Expand All @@ -260,13 +268,6 @@ export function useAllVertexStyles(): VertexStyle[] {
return [...schemaStyles, styles.get(missingType)];
}

/** Returns an array of edge styles based on the known edge types in the schema. */
export function useAllEdgeStyles(): EdgeStyle[] {
const styles = useAtomValue(edgeStyleAtom);
const { edges: allSchemas } = useActiveSchema();
return allSchemas.map(({ type }) => styles.get(type));
}

/** Returns the resolved style for the specified vertex type. */
export function useVertexStyle(type: VertexType): VertexStyle {
return useDeferredValue(useAtomValue(vertexStyleByTypeAtom(type)));
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 @@ -12,6 +12,7 @@ export * from "./nodes";
export * from "./renderedEntities";
export * from "./graphStyles";
export * from "./graphElementStyleData";
export * from "./styleDataResolvers";
export * from "./schema";
export * from "./storageAtoms";
export * from "./graphSession";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// @vitest-environment happy-dom
import { act, waitFor } from "@testing-library/react";

import type { AppStore } from "@/core";

import { createEdgeType, createVertexType } from "@/core/entities";
import { DbState, renderHookWithJotai } from "@/utils/testing";

import { userEdgeStylesAtom, userVertexStylesAtom } from "./storageAtoms";
import {
useEdgeStyleDataResolver,
useVertexStyleDataResolver,
} from "./styleDataResolvers";

describe("useVertexStyleDataResolver", () => {
it("should return the same object for repeated lookups of a type", () => {
const type = createVertexType("Person");
const { result } = renderHookWithJotai(() =>
useVertexStyleDataResolver([]),
);

expect(result.current(type)).toBe(result.current(type));
});

// The cache is keyed only by type, so a style edit has to replace the whole
// resolver. If it doesn't, the canvas keeps the old colors.
it("should reflect a style edited after the first lookup", async () => {
const type = createVertexType("Person");
const dbState = new DbState();
dbState.addVertexStyle(type, { color: "#111111" });

let store!: AppStore;
const { result } = renderHookWithJotai(
() => useVertexStyleDataResolver([]),
s => {
store = s;
dbState.applyTo(s);
},
);
expect(result.current(type).ge_color).toBe("#111111");

act(() =>
store.set(userVertexStylesAtom, prev =>
new Map(prev).set(type, { type, color: "#222222" }),
),
);

await waitFor(() => {
expect(result.current(type).ge_color).toBe("#222222");
});
});

it("should resolve distinct style data per type", () => {
const dbState = new DbState();
dbState.addVertexStyle(createVertexType("Person"), { color: "#111111" });
dbState.addVertexStyle(createVertexType("City"), { color: "#222222" });

const { result } = renderHookWithJotai(
() => useVertexStyleDataResolver([]),
store => dbState.applyTo(store),
);

expect(result.current(createVertexType("Person")).ge_color).toBe("#111111");
expect(result.current(createVertexType("City")).ge_color).toBe("#222222");
});

// The canvas passes only the types it draws, so a type outside that scope must
// still resolve — without an icon.
it("should resolve a type outside the icon scope without an icon", () => {
const { result } = renderHookWithJotai(() =>
useVertexStyleDataResolver([]),
);

expect(
result.current(createVertexType("Person")).__iconUrl,
).toBeUndefined();
});
});

describe("useEdgeStyleDataResolver", () => {
it("should reflect a style edited after the first lookup", async () => {
const type = createEdgeType("route");
const dbState = new DbState();
dbState.addEdgeStyle(type, { lineColor: "#111111" });

let store!: AppStore;
const { result } = renderHookWithJotai(
() => useEdgeStyleDataResolver(),
s => {
store = s;
dbState.applyTo(s);
},
);
expect(result.current(type).ge_lineColor).toBe("#111111");

act(() =>
store.set(userEdgeStylesAtom, prev =>
new Map(prev).set(type, { type, lineColor: "#222222" }),
),
);

await waitFor(() => {
expect(result.current(type).ge_lineColor).toBe("#222222");
});
});

it("should return the same object for repeated lookups of a type", () => {
const type = createEdgeType("route");
const { result } = renderHookWithJotai(() => useEdgeStyleDataResolver());

expect(result.current(type)).toBe(result.current(type));
});

it("should resolve distinct style data per type", () => {
const dbState = new DbState();
dbState.addEdgeStyle(createEdgeType("route"), { lineColor: "#111111" });
dbState.addEdgeStyle(createEdgeType("owns"), { lineColor: "#222222" });

const { result } = renderHookWithJotai(
() => useEdgeStyleDataResolver(),
store => dbState.applyTo(store),
);

expect(result.current(createEdgeType("route")).ge_lineColor).toBe(
"#111111",
);
expect(result.current(createEdgeType("owns")).ge_lineColor).toBe("#222222");
});
});
Loading