Skip to content
Open
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
4 changes: 2 additions & 2 deletions packages/graph-explorer/src/components/VertexRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ComponentPropsWithoutRef } from "react";

import { type DisplayVertex, useVertexStyle } from "@/core";
import { type DisplayVertex, useVertexStyleForTypes } from "@/core";
import { ASCII, cn, LABELS } from "@/utils";

import { SearchResultSubtitle, SearchResultTitle, VertexSymbol } from ".";
Expand All @@ -14,7 +14,7 @@ export function VertexRow({
vertex: DisplayVertex;
name?: string;
} & ComponentPropsWithoutRef<"div">) {
const vertexStyle = useVertexStyle(vertex.primaryType);
const vertexStyle = useVertexStyleForTypes(vertex.types);
const resultName = name ? `${name}: ` : "";
const nameIsSameAsTypes = vertex.displayTypes === vertex.displayName;
const isDefaultType = vertex.displayTypes === LABELS.MISSING_TYPE;
Expand Down
11 changes: 10 additions & 1 deletion packages/graph-explorer/src/core/StateProvider/displayVertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
nodeSelector,
nodesSelectedIdsAtom,
queryEngineSelector,
resolveVertexStyleForTypes,
useVertex,
userVertexStylesAtom,
type Vertex,
type VertexId,
vertexStyleByTypeAtom,
Expand Down Expand Up @@ -108,7 +110,14 @@ const displayVertexSelector = atomFamily((vertex: Vertex) =>
return LABELS.MISSING_VALUE;
}

const vertexStyle = get(vertexStyleByTypeAtom(vertex.type));
// Merged across every type the vertex has, not just `vertex.type`/`primaryType` —
// a resource asserting multiple rdf:type values (e.g. under RDFS/OWL inference,
// where every superclass becomes a peer rdf:type) should pick up styling and the
// displayNameAttribute from whichever of its types set them, not an arbitrary one.
const vertexStyle = resolveVertexStyleForTypes(
vertexTypes,
get(userVertexStylesAtom),
);
const displayName = getDisplayAttributeValueByName(
vertexStyle.displayNameAttribute,
);
Expand Down
116 changes: 116 additions & 0 deletions packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@ import { act } from "react";
import { createEdgeType, createVertexType } from "@/core";
import { DbState, renderHookWithState } from "@/utils/testing";

import type { VertexType } from "../entities";

import {
appDefaultEdgeStyle,
appDefaultVertexStyle,
edgeStyleAtom,
type EdgeStyleStorage,
mergeVertexStyleFields,
useEdgeStyling,
useVertexStyling,
vertexStyleAtom,
type VertexStyleStorage,
vertexTypeSetKey,
} from "./graphStyles";

function createExpectedVertex(existing: VertexStyleStorage) {
Expand Down Expand Up @@ -460,3 +464,115 @@ describe("edgeStyleAtom", () => {
);
});
});

describe("vertexTypeSetKey", () => {
it("is stable regardless of the input order", () => {
const a = createVertexType("A");
const b = createVertexType("B");
const c = createVertexType("C");

expect(vertexTypeSetKey([a, b, c])).toBe(vertexTypeSetKey([c, a, b]));
});

it("ignores duplicate types", () => {
const a = createVertexType("A");
const b = createVertexType("B");

expect(vertexTypeSetKey([a, b, a])).toBe(vertexTypeSetKey([a, b]));
});

it("differs for different type sets", () => {
const a = createVertexType("A");
const b = createVertexType("B");

expect(vertexTypeSetKey([a])).not.toBe(vertexTypeSetKey([a, b]));
});
});

describe("mergeVertexStyleFields", () => {
it("merges non-conflicting fields from every type", () => {
const equipment = createVertexType("Equipment");
const breaker = createVertexType("Breaker");
const userStyles = new Map<VertexType, VertexStyleStorage>([
[equipment, { type: equipment, borderColor: "black", shape: "hexagon" }],
[breaker, { type: breaker, color: "red", iconUrl: "lucide:zap-off" }],
]);

expect(
mergeVertexStyleFields([breaker, equipment], userStyles),
).toStrictEqual({
borderColor: "black",
shape: "hexagon",
color: "red",
iconUrl: "lucide:zap-off",
});
});

it("resolves a field set by more than one type by lexicographic order, last wins", () => {
const a = createVertexType("A");
const z = createVertexType("Z");
const userStyles = new Map<VertexType, VertexStyleStorage>([
[a, { type: a, color: "from-a" }],
[z, { type: z, color: "from-z" }],
]);

// Order of the input array must not matter — only the type names' sort order does.
expect(mergeVertexStyleFields([a, z], userStyles).color).toBe("from-z");
expect(mergeVertexStyleFields([z, a], userStyles).color).toBe("from-z");
});

it("skips types with no stored style", () => {
const styled = createVertexType("Styled");
const unstyled = createVertexType("Unstyled");
const userStyles = new Map<VertexType, VertexStyleStorage>([
[styled, { type: styled, color: "red" }],
]);

expect(
mergeVertexStyleFields([styled, unstyled], userStyles),
).toStrictEqual({ color: "red" });
});

it("returns an empty object when no type has a stored style", () => {
const a = createVertexType("A");
expect(mergeVertexStyleFields([a], new Map())).toStrictEqual({});
});
});

describe("vertexStyleAtom.getForTypes", () => {
it("merges styles across all of a vertex's types, overlaid on defaults", () => {
const dbState = new DbState();
const equipment = createVertexType("Equipment");
const breaker = createVertexType("Breaker");
dbState.addVertexStyle(equipment, { borderColor: "black" });
dbState.addVertexStyle(breaker, { color: "red" });

const { result } = renderHookWithState(
() => useAtomValue(vertexStyleAtom),
dbState,
);

const resolved = result.current.getForTypes([breaker, equipment]);
expect(resolved.color).toBe("red");
expect(resolved.borderColor).toBe("black");
// Every other field still falls back to the app default.
expect(resolved.shape).toBe(appDefaultVertexStyle.shape);
});

it("is order-independent — the same two types resolve identically regardless of array order", () => {
const dbState = new DbState();
const a = createVertexType("A");
const z = createVertexType("Z");
dbState.addVertexStyle(a, { color: "from-a" });
dbState.addVertexStyle(z, { color: "from-z" });

const { result } = renderHookWithState(
() => useAtomValue(vertexStyleAtom),
dbState,
);

expect(result.current.getForTypes([a, z])).toStrictEqual(
result.current.getForTypes([z, a]),
);
});
});
116 changes: 116 additions & 0 deletions packages/graph-explorer/src/core/StateProvider/graphStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,15 @@ export const vertexStyleAtom = atom(get => {
get(type: VertexType) {
return resolveVertexStyle(type, userStyles.get(type));
},
/**
* The resolved style for a vertex carrying one or more types — see
* {@link resolveVertexStyleForTypes}. Use this (not repeated `get` calls)
* whenever the caller has an actual vertex instance rather than a single
* schema type, so styling reflects every type the vertex has.
*/
getForTypes(types: readonly VertexType[]) {
return resolveVertexStyleForTypes(types, userStyles);
},
};
});

Expand All @@ -231,6 +240,83 @@ export function resolveVertexStyle(
} as const;
}

/**
* A stable, order-independent identity for a vertex's full set of types —
* every distinct combination of types gets one key, and the same set of
* types (in any order) always produces the same key. Reuses the
* {@link VertexType} brand since it is consumed exactly like one: as an
* opaque Cytoscape selector value and style-lookup key (see
* `useGraphStyles.ts` and {@link resolveVertexStyleForTypes}). It is never a
* real schema type and must not be shown to the user or looked up against
* schema/connection data — {@link DisplayVertex.displayTypes} is the
* user-facing type label.
*/
function sortedUniqueTypes(types: readonly VertexType[]): VertexType[] {
return [...new Set(types)].sort();
}

export function vertexTypeSetKey(types: readonly VertexType[]): VertexType {
return sortedUniqueTypes(types).join(" ") as VertexType;
}

/**
* Merges the stored user style for every one of a vertex's types into one set
* of fields, so a vertex with multiple `rdf:type` values (common once RDFS/OWL
* inference is in play — every superclass ends up asserted as a peer
* `rdf:type`) picks up styling from all of them rather than an arbitrary one.
* A field left unset by one type falls through to another type that does set
* it — e.g. a `borderColor` styled once on a shared ancestor class applies to
* every subtype automatically, without repeating it on each one.
*
* Conflicts — two of the vertex's types both set the same field — are
* resolved by folding the types in ascending lexicographic order, so the
* lexicographically-last type's value wins. This is a **stable, reproducible
* tiebreak, not a specificity judgement**: nothing in the app tracks
* `rdfs:subClassOf` (or any other) class hierarchy today — schema sync only
* samples instance data (types + attribute names) for every connector, so
* there is no signal available to determine which of a resource's asserted
* types is actually "more specific" than another. Sorting at least makes the
* outcome a deterministic property of the type names themselves instead of an
* accident of SPARQL query result order, which is what motivated this fix
* (see the linked issue). A hierarchy-aware tiebreak would be a natural
* follow-up once class-hierarchy data is tracked anywhere in the app.
*/
export function mergeVertexStyleFields(
types: readonly VertexType[],
userStyles: ReadonlyMap<VertexType, VertexStyleStorage>,
): Omit<VertexStyleStorage, "type"> {
let merged: Omit<VertexStyleStorage, "type"> = {};
for (const type of sortedUniqueTypes(types)) {
const style = userStyles.get(type);
if (!style) {
continue;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- drop the per-type `type` field, only the fields matter
const { type: _type, ...fields } = style;
merged = { ...merged, ...fields };
}
return merged;
}

/**
* The resolved style for a vertex carrying one or more types — see
* {@link mergeVertexStyleFields} for how conflicts across types are resolved.
* `type` on the result is {@link vertexTypeSetKey}'s composite key, not a
* single real type, since the resolved style may draw fields from more than
* one type.
*/
export function resolveVertexStyleForTypes(
types: readonly VertexType[],
userStyles: ReadonlyMap<VertexType, VertexStyleStorage>,
): VertexStyle {
const merged = mergeVertexStyleFields(types, userStyles);
return {
type: vertexTypeSetKey(types),
...appDefaultVertexStyle,
...merged,
} as const;
}

/** The user's edge style overlaid on the app defaults. */
export function resolveEdgeStyle(
type: EdgeType,
Expand Down Expand Up @@ -272,6 +358,22 @@ export function useVertexStyle(type: VertexType): VertexStyle {
return useDeferredValue(useAtomValue(vertexStyleByTypeAtom(type)));
}

/**
* Returns the resolved style for a vertex instance, merged across every type
* it has — see {@link resolveVertexStyleForTypes}. Use this instead of
* {@link useVertexStyle} whenever `types` is an actual vertex's asserted
* types rather than a single schema type being styled/edited on its own
* (e.g. the Schema view's Styles panel, or a legend listing every known
* type — those still want {@link useVertexStyle} for one type at a time).
*/
export function useVertexStyleForTypes(
types: readonly VertexType[],
): VertexStyle {
return useDeferredValue(
useAtomValue(vertexStyleByTypesAtom(vertexTypeSetKey(types))),
);
}

/** Returns the resolved style for the specified edge type. */
export function useEdgeStyle(type: EdgeType): EdgeStyle {
return useDeferredValue(useAtomValue(edgeStyleByTypeAtom(type)));
Expand All @@ -284,6 +386,20 @@ export const vertexStyleByTypeAtom = atomFamily((type: VertexType) =>
atom(get => get(vertexStyleAtom).get(type)),
);

/**
* Returns the resolved style for a vertex's full set of types, keyed by
* {@link vertexTypeSetKey} — atomFamily needs a primitive key, and the key
* already carries the sorted, deduplicated type list, so it is split back
* into types rather than threading the original array through separately.
*/
export const vertexStyleByTypesAtom = atomFamily((typesKey: VertexType) =>
atom(get =>
get(vertexStyleAtom).getForTypes(
typesKey === "" ? [] : (typesKey.split(" ") as VertexType[]),
),
),
);

/**
* Returns the resolved style for the specified edge type.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
useAllNeighbors,
useDisplayEdgesInCanvas,
useDisplayVerticesInCanvas,
vertexTypeSetKey,
type VertexId,
} from "@/core";

Expand Down Expand Up @@ -170,7 +171,10 @@ function createRenderedVertex(vertex: DisplayVertex, neighborCount: number) {
return {
data: {
id: createRenderedVertexId(vertex.id),
type: vertex.primaryType,
// The Cytoscape stylesheet selector key — see useGraphStyles.ts. Covers every
// type the vertex has (not just primaryType) so a multi-typed vertex's rule
// is built from all of them; see resolveVertexStyleForTypes for why.
type: vertexTypeSetKey(vertex.types),
vertexId: vertex.id,
displayName: vertex.displayName,
displayTypes: vertex.displayTypes,
Expand Down
Loading