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
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ export function resolveEdgeStyle(

/** Returns an array of vertex styles based on the known vertex types in the schema.
* For the schema view, which draws every type; the canvas scopes itself to the
* types it draws via `canvasVertexStylesAtom`. Always includes an entry for
* types it draws via `canvasVerticesAtom`. Always includes an entry for
* `LABELS.MISSING_TYPE` so blank nodes (assigned that synthetic type at runtime)
* are styled rather than skipped.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,14 @@ import {
} from "@/utils/testing";

import {
canvasVertexStylesAtom,
canvasVerticesAtom,
createRenderedEdgeId,
createRenderedVertexId,
getEdgeIdFromRenderedEdgeId,
getVertexIdFromRenderedVertexId,
type RenderedEdgeId,
type RenderedVertexId,
useRenderedEntities,
visibleVertexIdsAtom,
} from "./renderedEntities";

describe("createRenderedVertexId", () => {
Expand Down Expand Up @@ -96,7 +95,7 @@ describe("getEdgeIdFromRenderedEdgeId", () => {
});
});

describe("visibleVertexIdsAtom", () => {
describe("canvasVerticesAtom", () => {
it("should exclude vertices filtered by ID and by type", () => {
const dbState = new DbState();
const kept = createTestableVertex();
Expand All @@ -112,12 +111,12 @@ describe("visibleVertexIdsAtom", () => {
const store = createStore();
dbState.applyTo(store);

expect([...store.get(visibleVertexIdsAtom)]).toStrictEqual([kept.id]);
const { vertices, ids } = store.get(canvasVerticesAtom);
expect(vertices.map(v => v.id)).toStrictEqual([kept.id]);
expect([...ids]).toStrictEqual([kept.id]);
});
});

describe("canvasVertexStylesAtom", () => {
// The point of the atom: a schema can carry far more types than the canvas
// The point of the scoping: a schema can carry far more types than the canvas
// draws, and resolving an icon for every one of them dominated render cost.
it("should cover only the types drawn on the canvas", () => {
const dbState = new DbState();
Expand All @@ -130,9 +129,9 @@ describe("canvasVertexStylesAtom", () => {
const store = createStore();
dbState.applyTo(store);

expect(store.get(canvasVertexStylesAtom).map(s => s.type)).toStrictEqual([
createVertexType(onCanvas.types[0]),
]);
expect([
...store.get(canvasVerticesAtom).stylesByType.keys(),
]).toStrictEqual([createVertexType(onCanvas.types[0])]);
});

it("should exclude the types of filtered-out vertices", () => {
Expand All @@ -146,9 +145,9 @@ describe("canvasVertexStylesAtom", () => {
const store = createStore();
dbState.applyTo(store);

expect(store.get(canvasVertexStylesAtom).map(s => s.type)).toStrictEqual([
createVertexType(kept.types[0]),
]);
expect([
...store.get(canvasVerticesAtom).stylesByType.keys(),
]).toStrictEqual([createVertexType(kept.types[0])]);
});

// `useAllVertexStyles` states this guarantee explicitly; here it has to hold
Expand All @@ -163,9 +162,9 @@ describe("canvasVertexStylesAtom", () => {
const store = createStore();
dbState.applyTo(store);

expect(store.get(canvasVertexStylesAtom).map(s => s.type)).toStrictEqual([
LABELS.MISSING_TYPE,
]);
expect([
...store.get(canvasVerticesAtom).stylesByType.keys(),
]).toStrictEqual([LABELS.MISSING_TYPE]);
});

it("should list a shared type once", () => {
Expand All @@ -178,7 +177,34 @@ describe("canvasVertexStylesAtom", () => {
const store = createStore();
dbState.applyTo(store);

expect(store.get(canvasVertexStylesAtom)).toHaveLength(1);
expect(store.get(canvasVerticesAtom).stylesByType.size).toBe(1);
});

// The invariant `useRenderedVertices` throws on: every drawn vertex has a
// style, because the same loop produced both.
it("should cover the primary type of every vertex it returns", () => {
const dbState = new DbState();
const first = createTestableVertex();
const shared = createTestableVertex().with({ types: first.types });
const filtered = createTestableVertex();
dbState.addTestableVertexToGraph(first);
dbState.addTestableVertexToGraph(shared);
dbState.addTestableVertexToGraph(filtered);
dbState.addVertexToGraph(
createVertex({ id: "blank", isBlankNode: true, types: [] }),
);
dbState.filterVertex(filtered.id);

const store = createStore();
dbState.applyTo(store);

const { vertices, stylesByType } = store.get(canvasVerticesAtom);
expect(vertices).not.toHaveLength(0);
for (const vertex of vertices) {
expect(stylesByType.get(vertex.primaryType)?.type).toBe(
vertex.primaryType,
);
}
});
});

Expand Down
108 changes: 52 additions & 56 deletions packages/graph-explorer/src/core/StateProvider/renderedEntities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,29 @@ import {
type DisplayEdge,
type DisplayVertex,
displayVerticesInCanvasSelector,
type EdgeType,
edgesFilteredIdsAtom,
edgeStyleAtom,
edgesTypesFilteredAtom,
type EntityRawId,
nodesFilteredIdsAtom,
nodesTypesFilteredAtom,
useAllNeighbors,
useDisplayEdgesInCanvas,
useDisplayVerticesInCanvas,
type VertexId,
type VertexStyle,
vertexStyleAtom,
type VertexType,
} from "@/core";

import type { EdgeId } from "../entities/edge";
import type { EdgeStyleData, VertexStyleData } from "./graphElementStyleData";

import {
useEdgeStyleDataResolver,
useVertexStyleDataResolver,
} from "./styleDataResolvers";
type EdgeStyleData,
edgeStyleData,
type VertexStyleData,
} from "./graphElementStyleData";
import { useVertexStyleDataByType } from "./styleDataResolvers";

/** A string representation of a vertex ID that encodes the original type. Cytoscape requires IDs to be strings. */
export type RenderedVertexId = Branded<string, "RenderedVertexId">;
Expand All @@ -41,84 +43,69 @@ export type RenderedVertex = ReturnType<typeof createRenderedVertex>;
export type RenderedEdge = ReturnType<typeof createRenderedEdge>;

/**
* The IDs of the canvas vertices that survive filtering.
* The canvas vertices that survive filtering, in canvas insertion order, plus
* their IDs for membership tests and the styles of the types they draw.
*
* One loop, so the style scope and the drawn set cannot disagree: a drawn
* vertex's `primaryType` is in `stylesByType` by construction. The schema can
* carry tens of thousands of vertex types while the canvas shows a handful, and
* resolving a style plus an icon for every type in the schema on every render is
* the dominant render cost otherwise. The schema view, which genuinely draws
* every type, uses `useAllVertexStyles` instead.
*
* An atom rather than a hook so both the vertex and edge pipelines share one
* computation per store — as a hook it ran once per call site.
*
* Note this still recomputes when a vertex style changes, because
* `displayVerticesInCanvasSelector` resolves display labels through
* `vertexStyleByTypeAtom`. Only `id` and `types` are actually needed, so
* sourcing the predicate from `nodesAtom` would decouple it.
* `vertexStyleByTypeAtom`.
*/
export const visibleVertexIdsAtom = atom(get => {
export const canvasVerticesAtom = atom(get => {
const filteredIds = get(nodesFilteredIdsAtom);
const filteredTypes = get(nodesTypesFilteredAtom);
const displayVerticesInGraph = get(displayVerticesInCanvasSelector);
const displayVertices = get(displayVerticesInCanvasSelector);
const styles = get(vertexStyleAtom);

const result = new Set<VertexId>();
const vertices: DisplayVertex[] = [];
const ids = new Set<VertexId>();
const stylesByType = new Map<VertexType, VertexStyle>();

for (const vertex of displayVerticesInGraph.values()) {
for (const vertex of displayVertices.values()) {
// Filters the nodes added to the graph by:
// - Individual nodes hidden using the table view
// - Vertex types unselected in the filter sidebar
if (filteredIds.has(vertex.id)) continue;
if (vertex.types.some(type => filteredTypes.has(type))) continue;

result.add(vertex.id);
}

return result;
});

/**
* Vertex styles for only the types drawn on the canvas.
*
* The schema can carry tens of thousands of vertex types while the canvas shows
* a handful, and resolving a style plus an icon for every type in the schema on
* every render is the dominant render cost otherwise. The schema view, which
* genuinely draws every type, uses `useAllVertexStyles` instead.
*/
export const canvasVertexStylesAtom = atom(get => {
const styles = get(vertexStyleAtom);
const displayVerticesInGraph = get(displayVerticesInCanvasSelector);
const visibleIds = get(visibleVertexIdsAtom);

const types = new Set<VertexType>();
for (const vertex of displayVerticesInGraph.values()) {
if (visibleIds.has(vertex.id)) {
types.add(vertex.primaryType);
vertices.push(vertex);
ids.add(vertex.id);
if (!stylesByType.has(vertex.primaryType)) {
stylesByType.set(vertex.primaryType, styles.get(vertex.primaryType));
}
}

const result: VertexStyle[] = [];
for (const type of types) {
result.push(styles.get(type));
}
return result;
return { vertices, ids, stylesByType };
});

/** Returns the filtered array of `RenderedVertex` instances for use by Cytoscape. */
export function useRenderedVertices(): RenderedVertex[] {
const displayVerticesInGraph = useDisplayVerticesInCanvas();
const visibleIds = useAtomValue(visibleVertexIdsAtom);
const { vertices, stylesByType } = useAtomValue(canvasVerticesAtom);
const neighborCounts = useAllNeighbors();
const canvasVertexStyles = useAtomValue(canvasVertexStylesAtom);
const resolveStyleData = useVertexStyleDataResolver(canvasVertexStyles);
const styleDataByType = useVertexStyleDataByType(stylesByType.values());

const result: RenderedVertex[] = [];

for (const vertex of displayVerticesInGraph.values()) {
if (!visibleIds.has(vertex.id)) continue;
for (const vertex of vertices) {
const styleData = styleDataByType.get(vertex.primaryType);
// `canvasVerticesAtom` scopes the styles to the types it drew.
if (styleData === undefined) {
throw new Error(
`No style data resolved for drawn vertex type "${vertex.primaryType}"`,
);
}

const neighborCount = neighborCounts.get(vertex.id)?.unfetched ?? 0;
result.push(
createRenderedVertex(
vertex,
neighborCount,
resolveStyleData(vertex.primaryType),
),
);
result.push(createRenderedVertex(vertex, neighborCount, styleData));
}

return result;
Expand All @@ -129,9 +116,12 @@ export function useRenderedEdges(): RenderedEdge[] {
const edges = useDisplayEdgesInCanvas();
const filteredEdgeIds = useAtomValue(edgesFilteredIdsAtom);
const filteredEdgeTypes = useAtomValue(edgesTypesFilteredAtom);
const visibleVertexIds = useAtomValue(visibleVertexIdsAtom);
const resolveStyleData = useEdgeStyleDataResolver();
const { ids: visibleVertexIds } = useAtomValue(canvasVerticesAtom);
const styles = useAtomValue(edgeStyleAtom);

// The drawn edge types are only known while filtering, so style data is
// resolved on first sight of a type — one `Color` parse per type, not per edge.
const styleDataByType = new Map<EdgeType, EdgeStyleData>();
const result: RenderedEdge[] = [];

for (const edge of edges.values()) {
Expand All @@ -144,7 +134,13 @@ export function useRenderedEdges(): RenderedEdge[] {
if (!visibleVertexIds.has(edge.sourceId)) continue;
if (!visibleVertexIds.has(edge.targetId)) continue;

result.push(createRenderedEdge(edge, resolveStyleData(edge.type)));
let styleData = styleDataByType.get(edge.type);
if (styleData === undefined) {
styleData = edgeStyleData(styles.get(edge.type));
styleDataByType.set(edge.type, styleData);
}

result.push(createRenderedEdge(edge, styleData));
}

return result;
Expand Down
Loading