Skip to content

Commit

Permalink
H-3328: Upgrade entity graph visualization (#5178)
Browse files Browse the repository at this point in the history
  • Loading branch information
CiaranMn committed Sep 19, 2024
1 parent 065dce4 commit bce2575
Show file tree
Hide file tree
Showing 18 changed files with 834 additions and 443 deletions.
1 change: 1 addition & 0 deletions apps/hash-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@react-sigma/core": "4.0.3",
"@sentry/nextjs": "7.119.0",
"@sentry/react": "7.119.0",
"@sigma/node-border": "3.0.0-beta.4",
"@svgr/webpack": "8.1.0",
"@tldraw/editor": "2.0.0-alpha.12",
"@tldraw/primitives": "2.0.0-alpha.12",
Expand Down
4 changes: 4 additions & 0 deletions apps/hash-frontend/src/pages/globals.scss
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,7 @@ html {
body {
overflow: auto;
}

.full-height-for-react-full-screen {
height: 100%;
}
73 changes: 36 additions & 37 deletions apps/hash-frontend/src/pages/shared/entities-table.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { VersionedUrl } from "@blockprotocol/type-system/slim";
import type { CustomCell, Item, TextCell } from "@glideapps/glide-data-grid";
import { GridCellKind } from "@glideapps/glide-data-grid";
import { EntitiesGraphChart } from "@hashintel/block-design-system";
import type { Entity } from "@local/hash-graph-sdk/entity";
import type { EntityId } from "@local/hash-graph-types/entity";
import { gridRowHeight } from "@local/hash-isomorphic-utils/data-grid";
import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids";
Expand Down Expand Up @@ -47,6 +47,7 @@ import { createRenderTextIconCell } from "./entities-table/text-icon-cell";
import type { TypeEntitiesRow } from "./entities-table/use-entities-table";
import { useEntitiesTable } from "./entities-table/use-entities-table";
import { useGetEntitiesTableAdditionalCsvData } from "./entities-table/use-get-entities-table-additional-csv-data";
import { EntityGraphVisualizer } from "./entity-graph-visualizer";
import { TypeSlideOverStack } from "./entity-type-page/type-slide-over-stack";
import { generateEntityRootedSubgraph } from "./subgraphs";
import { TableHeaderToggle } from "./table-header-toggle";
Expand Down Expand Up @@ -611,6 +612,30 @@ export const EntitiesTable: FunctionComponent<{
addPropertiesColumns: hidePropertiesColumns,
});

const maximumTableHeight = `calc(100vh - (${
HEADER_HEIGHT + TOP_CONTEXT_BAR_HEIGHT + 179 + tableHeaderHeight
}px + ${theme.spacing(5)} + ${theme.spacing(5)}))`;

const isPrimaryEntity = useCallback(
(entity: Entity) =>
entityTypeBaseUrl
? extractBaseUrl(entity.metadata.entityTypeId) === entityTypeBaseUrl
: entityTypeId
? entityTypeId === entity.metadata.entityTypeId
: false,
[entityTypeId, entityTypeBaseUrl],
);

const filterEntity = useCallback(
(entity: Entity) =>
filterState.includeGlobal
? true
: internalWebIds.includes(
extractOwnedByIdFromEntityId(entity.metadata.recordId.entityId),
),
[filterState, internalWebIds],
);

return (
<>
{selectedEntityTypeId && (
Expand Down Expand Up @@ -672,36 +697,15 @@ export const EntitiesTable: FunctionComponent<{
onBulkActionCompleted={() => setSelectedRows([])}
/>
{view === "Graph" && subgraph ? (
<EntitiesGraphChart
entities={entities}
isPrimaryEntity={(entity) =>
entityTypeBaseUrl
? extractBaseUrl(entity.metadata.entityTypeId) ===
entityTypeBaseUrl
: entityTypeId
? entityTypeId === entity.metadata.entityTypeId
: true
}
filterEntity={(entity) =>
filterState.includeGlobal
? true
: internalWebIds.includes(
extractOwnedByIdFromEntityId(
entity.metadata.recordId.entityId,
),
)
}
onEntityClick={handleEntityClick}
sx={{
background: ({ palette }) => palette.common.white,
height: `calc(100vh - (${
HEADER_HEIGHT + TOP_CONTEXT_BAR_HEIGHT + 179 + tableHeaderHeight
}px + ${theme.spacing(5)} + ${theme.spacing(5)}))`,
borderBottomRightRadius: 6,
borderBottomLeftRadius: 6,
}}
subgraphWithTypes={subgraph}
/>
<Box height={maximumTableHeight}>
<EntityGraphVisualizer
entities={entities}
isPrimaryEntity={isPrimaryEntity}
filterEntity={filterEntity}
onEntityClick={handleEntityClick}
subgraphWithTypes={subgraph}
/>
</Box>
) : view === "Grid" ? (
<GridView entities={entities} />
) : (
Expand All @@ -721,12 +725,7 @@ export const EntitiesTable: FunctionComponent<{
firstColumnLeftPadding={16}
height={`
min(
calc(100vh - (${
HEADER_HEIGHT +
TOP_CONTEXT_BAR_HEIGHT +
179 +
tableHeaderHeight
}px + ${theme.spacing(5)} + ${theme.spacing(5)})),
${maximumTableHeight},
calc(
${gridHeaderHeightWithBorder}px +
(${rows?.length ? rows.length : 1} * ${gridRowHeight}px) +
Expand Down
250 changes: 250 additions & 0 deletions apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import type { VersionedUrl } from "@blockprotocol/type-system-rs/pkg/type-system";
import type {
EntityId,
EntityMetadata,
LinkData,
PropertyObject,
} from "@local/hash-graph-types/entity";
import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label";
import type { Subgraph } from "@local/hash-subgraph";
import { isEntityId } from "@local/hash-subgraph";
import { getEntityTypeById } from "@local/hash-subgraph/stdlib";
import { useTheme } from "@mui/material";
import { useCallback, useMemo } from "react";

import type {
GraphVisualizerProps,
GraphVizEdge,
GraphVizNode,
} from "./graph-visualizer";
import { GraphVisualizer } from "./graph-visualizer";

export type EntityForGraph = {
linkData?: LinkData;
metadata: Pick<EntityMetadata, "recordId" | "entityTypeId"> &
Partial<Pick<EntityMetadata, "temporalVersioning">>;
properties: PropertyObject;
};

const maxNodeSize = 32;
const minNodeSize = 10;

export const EntityGraphVisualizer = <T extends EntityForGraph>({
entities,
filterEntity,
isPrimaryEntity,
subgraphWithTypes,
onEntityClick,
onEntityTypeClick,
}: {
entities?: T[];
/**
* A function to filter out entities from display. If the function returns false, the entity will not be displayed.
*/
filterEntity?: (entity: T) => boolean;
onEntityClick?: (entityId: EntityId) => void;
onEntityTypeClick?: (entityTypeId: VersionedUrl) => void;
/**
* Whether this entity should receive a special highlight.
*/
isPrimaryEntity?: (entity: T) => boolean;
subgraphWithTypes: Subgraph;
}) => {
const { palette } = useTheme();

const nodeColors = useMemo(() => {
return [
{
color: palette.blue[30],
borderColor: palette.blue[40],
},
{
color: palette.purple[30],
borderColor: palette.purple[40],
},
{
color: palette.green[50],
borderColor: palette.green[60],
},
] as const;
}, [palette]);

const { nodes, edges } = useMemo<{
nodes: GraphVizNode[];
edges: GraphVizEdge[];
}>(() => {
const nodesToAddByNodeId: Record<string, GraphVizNode> = {};
const edgesToAdd: GraphVizEdge[] = [];

const nonLinkEntitiesIncluded = new Set<EntityId>();
const linkEntitiesToAdd: (T & {
linkData: NonNullable<T["linkData"]>;
})[] = [];

const entityTypeIdToColor = new Map<string, number>();

const nodeIdToIncomingEdges = new Map<string, number>();

for (const entity of entities ?? []) {
/**
* If we have been provided a filter function, check it doesn't filter out the entity
*/
if (filterEntity) {
if (!filterEntity(entity)) {
continue;
}
}

if (entity.linkData) {
/**
* We process links afterwards, because we only want to add them if both source and target are in the graph.
*/
linkEntitiesToAdd.push(
entity as T & {
linkData: NonNullable<T["linkData"]>;
},
);
continue;
}

nonLinkEntitiesIncluded.add(entity.metadata.recordId.entityId);

const specialHighlight = isPrimaryEntity?.(entity) ?? false;

if (!entityTypeIdToColor.has(entity.metadata.entityTypeId)) {
entityTypeIdToColor.set(
entity.metadata.entityTypeId,
entityTypeIdToColor.size % nodeColors.length,
);
}

const { color, borderColor } = specialHighlight
? { color: palette.blue[50], borderColor: palette.blue[60] }
: nodeColors[entityTypeIdToColor.get(entity.metadata.entityTypeId)!]!;

nodesToAddByNodeId[entity.metadata.recordId.entityId] = {
label: generateEntityLabel(subgraphWithTypes, entity),
nodeId: entity.metadata.recordId.entityId,
color,
borderColor,
size: minNodeSize,
};

nodeIdToIncomingEdges.set(entity.metadata.recordId.entityId, 0);
}

for (const linkEntity of linkEntitiesToAdd) {
if (
!nonLinkEntitiesIncluded.has(linkEntity.linkData.leftEntityId) ||
!nonLinkEntitiesIncluded.has(linkEntity.linkData.rightEntityId)
) {
/**
* We don't have both sides of this link in the graph.
*/
continue;
}

const linkEntityType = getEntityTypeById(
subgraphWithTypes,
linkEntity.metadata.entityTypeId,
);

edgesToAdd.push({
source: linkEntity.linkData.leftEntityId,
target: linkEntity.linkData.rightEntityId,
edgeId: linkEntity.metadata.recordId.entityId,
label: linkEntityType?.schema.title ?? "Unknown",
size: 1,
});

nodeIdToIncomingEdges.set(
linkEntity.linkData.rightEntityId,
(nodeIdToIncomingEdges.get(linkEntity.linkData.rightEntityId) ?? 0) + 1,
);
}

const fewestIncomingEdges = Math.min(...nodeIdToIncomingEdges.values());
const mostIncomingEdges = Math.max(...nodeIdToIncomingEdges.values());

const incomingEdgeRange = mostIncomingEdges - fewestIncomingEdges;

/**
* If incomingEdgeRange is 0, all nodes have the same number of incoming edges
*/
if (incomingEdgeRange > 0) {
for (const [nodeId, incomingEdges] of nodeIdToIncomingEdges) {
if (!nodesToAddByNodeId[nodeId]) {
continue;
}

const relativeEdgeCount = incomingEdges / incomingEdgeRange;

const maxSizeIncrease = maxNodeSize - minNodeSize;

/**
* Scale the size of the node based on the number of incoming edges within the range of incoming edges
*/
nodesToAddByNodeId[nodeId].size = Math.min(
maxNodeSize,
Math.max(
minNodeSize,
relativeEdgeCount * maxSizeIncrease + minNodeSize,
),
);
}
}

return {
nodes: Object.values(nodesToAddByNodeId),
edges: edgesToAdd,
};
}, [
entities,
filterEntity,
isPrimaryEntity,
nodeColors,
palette,
subgraphWithTypes,
]);

const onNodeClick = useCallback<
NonNullable<GraphVisualizerProps["onNodeClick"]>
>(
({ nodeId, isFullScreen }) => {
if (isFullScreen) {
return;
}

if (isEntityId(nodeId)) {
onEntityClick?.(nodeId);
} else {
onEntityTypeClick?.(nodeId as VersionedUrl);
}
},
[onEntityClick, onEntityTypeClick],
);

const onEdgeClick = useCallback<
NonNullable<GraphVisualizerProps["onEdgeClick"]>
>(
({ edgeId, isFullScreen }) => {
if (isFullScreen) {
return;
}

if (isEntityId(edgeId)) {
onEntityClick?.(edgeId);
}
},
[onEntityClick],
);

return (
<GraphVisualizer
nodes={nodes}
edges={edges}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
/>
);
};
Loading

0 comments on commit bce2575

Please sign in to comment.