diff --git a/apps/hash-frontend/package.json b/apps/hash-frontend/package.json index f374243c421..28f3de8275a 100644 --- a/apps/hash-frontend/package.json +++ b/apps/hash-frontend/package.json @@ -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", diff --git a/apps/hash-frontend/src/pages/globals.scss b/apps/hash-frontend/src/pages/globals.scss index b2f61e498fe..495e1df54a2 100644 --- a/apps/hash-frontend/src/pages/globals.scss +++ b/apps/hash-frontend/src/pages/globals.scss @@ -114,3 +114,7 @@ html { body { overflow: auto; } + +.full-height-for-react-full-screen { + height: 100%; +} diff --git a/apps/hash-frontend/src/pages/shared/entities-table.tsx b/apps/hash-frontend/src/pages/shared/entities-table.tsx index 8fe666530da..87203ac64f9 100644 --- a/apps/hash-frontend/src/pages/shared/entities-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-table.tsx @@ -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"; @@ -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"; @@ -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 && ( @@ -672,36 +697,15 @@ export const EntitiesTable: FunctionComponent<{ onBulkActionCompleted={() => setSelectedRows([])} /> {view === "Graph" && subgraph ? ( - - 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} - /> + + + ) : view === "Grid" ? ( ) : ( @@ -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) + diff --git a/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx new file mode 100644 index 00000000000..8bf4c73fe13 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx @@ -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 & + Partial>; + properties: PropertyObject; +}; + +const maxNodeSize = 32; +const minNodeSize = 10; + +export const EntityGraphVisualizer = ({ + 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 = {}; + const edgesToAdd: GraphVizEdge[] = []; + + const nonLinkEntitiesIncluded = new Set(); + const linkEntitiesToAdd: (T & { + linkData: NonNullable; + })[] = []; + + const entityTypeIdToColor = new Map(); + + const nodeIdToIncomingEdges = new Map(); + + 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; + }, + ); + 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 + >( + ({ nodeId, isFullScreen }) => { + if (isFullScreen) { + return; + } + + if (isEntityId(nodeId)) { + onEntityClick?.(nodeId); + } else { + onEntityTypeClick?.(nodeId as VersionedUrl); + } + }, + [onEntityClick, onEntityTypeClick], + ); + + const onEdgeClick = useCallback< + NonNullable + >( + ({ edgeId, isFullScreen }) => { + if (isFullScreen) { + return; + } + + if (isEntityId(edgeId)) { + onEntityClick?.(edgeId); + } + }, + [onEntityClick], + ); + + return ( + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer.tsx new file mode 100644 index 00000000000..7b7b826dcfb --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer.tsx @@ -0,0 +1,31 @@ +import "@react-sigma/core/lib/react-sigma.min.css"; + +import dynamic from "next/dynamic"; +import { memo } from "react"; + +import type { GraphContainerProps } from "./graph-visualizer/graph-container"; + +export type { + GraphVizEdge, + GraphVizNode, +} from "./graph-visualizer/graph-container/graph-data-loader"; + +export type GraphVisualizerProps = GraphContainerProps; + +export const GraphVisualizer = memo((props: GraphVisualizerProps) => { + if (typeof window !== "undefined") { + /** + * WebGL APIs aren't available in the server, so we need to dynamically load any module which uses Sigma/graphology. + */ + const GraphContainer = dynamic( + import("./graph-visualizer/graph-container").then( + (module) => module.GraphContainer, + ), + { ssr: false }, + ); + + return ; + } + + return null; +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container.tsx new file mode 100644 index 00000000000..917e53de598 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container.tsx @@ -0,0 +1,66 @@ +import { SigmaContainer } from "@react-sigma/core"; +import { createNodeBorderProgram } from "@sigma/node-border"; +import { MultiDirectedGraph } from "graphology"; +import { useState } from "react"; + +import { FullScreenButton } from "./graph-container/full-screen-button"; +import type { GraphLoaderProps } from "./graph-container/graph-data-loader"; +import { GraphDataLoader } from "./graph-container/graph-data-loader"; +import { FullScreenContextProvider } from "./graph-container/shared/full-screen"; + +export type GraphContainerProps = Omit; + +export const GraphContainer = ({ + edges, + nodes, + onEdgeClick, + onNodeClick, +}: GraphContainerProps) => { + /** + * When a node is hovered or selected, we highlight its neighbors up to this depth. + * + * Not currently exposed as a user setting but could be, thus the state. + */ + const [highlightDepth, _setHighlightDepth] = useState(2); + + return ( + + + + + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/types-graph/full-screen-button.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/full-screen-button.tsx similarity index 100% rename from apps/hash-frontend/src/pages/shared/types-graph/full-screen-button.tsx rename to apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/full-screen-button.tsx diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/graph-data-loader.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/graph-data-loader.tsx new file mode 100644 index 00000000000..ee30f6a766a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/graph-data-loader.tsx @@ -0,0 +1,197 @@ +import { useLoadGraph, useRegisterEvents, useSigma } from "@react-sigma/core"; +import { MultiDirectedGraph } from "graphology"; +import { useEffect, useRef } from "react"; +import type { SigmaNodeEventPayload } from "sigma/types"; + +import { useFullScreen } from "./shared/full-screen"; +import { useDefaultSettings } from "./shared/settings"; +import type { GraphState } from "./shared/state"; +import { useLayout } from "./use-layout"; + +export type GraphVizNode = { + borderColor?: string; + color: string; + nodeId: string; + label: string; + size: number; +}; + +export type GraphVizEdge = { + edgeId: string; + label?: string; + size: number; + source: string; + target: string; +}; + +export type GraphLoaderProps = { + highlightDepth: number; + edges: GraphVizEdge[]; + nodes: GraphVizNode[]; + onEdgeClick?: (params: { edgeId: string; isFullScreen: boolean }) => void; + onNodeClick?: (params: { nodeId: string; isFullScreen: boolean }) => void; +}; + +export const GraphDataLoader = ({ + highlightDepth, + edges, + nodes, + onNodeClick, + onEdgeClick, +}: GraphLoaderProps) => { + /** + * Hooks provided by the react-sigma library to simplify working with the sigma instance. + */ + const loadGraph = useLoadGraph(); + const registerEvents = useRegisterEvents(); + const sigma = useSigma(); + + /** + * Custom hooks for laying out the graph, and handling fullscreen state + */ + const layout = useLayout(); + const { isFullScreen } = useFullScreen(); + + /** + * State to track interactions with the graph. + * It's drawn in canvas so doesn't need to be in React state + * – redrawing the graph is done via sigma.refresh. + */ + const graphState = useRef({ + hoveredNodeId: null, + hoveredNeighborIds: null, + selectedNodeId: null, + }); + + useDefaultSettings(graphState.current); + + useEffect(() => { + /** + * Highlight a node and its neighbors up to a certain depth. + */ + const highlightNode = (event: SigmaNodeEventPayload) => { + graphState.current.hoveredNodeId = event.node; + + const getNeighbors = ( + nodeId: string, + neighborIds: Set = new Set(), + depth = 1, + ) => { + if (depth > highlightDepth) { + return neighborIds; + } + + const directNeighbors = sigma.getGraph().neighbors(nodeId); + + for (const neighbor of directNeighbors) { + neighborIds.add(neighbor); + getNeighbors(neighbor, neighborIds, depth + 1); + } + + return neighborIds; + }; + + graphState.current.hoveredNeighborIds = getNeighbors(event.node); + + sigma.setSetting("renderEdgeLabels", true); + + /** + * We haven't touched the graph data, so don't need to re-index. + * An additional optimization would be to supply partialGraph here and only redraw the affected nodes, + * but since the nodes whose appearance changes are the NON-highlighted nodes (they disappear), it's probably not worth it + * – they are likely to be the majority anyway, and we'd have to generate an array of them. + */ + sigma.refresh({ skipIndexation: true }); + }; + + const removeHighlights = () => { + graphState.current.hoveredNodeId = null; + graphState.current.hoveredNeighborIds = null; + sigma.setSetting("renderEdgeLabels", false); + sigma.refresh({ skipIndexation: true }); + }; + + registerEvents({ + clickEdge: (event) => { + onEdgeClick?.({ + edgeId: event.edge, + isFullScreen, + }); + }, + clickNode: (event) => { + onNodeClick?.({ + nodeId: event.node, + isFullScreen, + }); + + graphState.current.selectedNodeId = event.node; + highlightNode(event); + }, + clickStage: () => { + if (!graphState.current.selectedNodeId) { + return; + } + + /** + * If we click on the background (the 'stage'), deselect the selected node. + */ + graphState.current.selectedNodeId = null; + removeHighlights(); + }, + enterNode: (event) => { + graphState.current.selectedNodeId = null; + highlightNode(event); + }, + leaveNode: () => { + if (graphState.current.selectedNodeId) { + /** + * If there's a selected node (has been clicked on), we don't want to remove highlights. + * The user can click the background or another node to deselect it. + */ + return; + } + removeHighlights(); + }, + }); + }, [ + edges, + nodes, + onEdgeClick, + onNodeClick, + highlightDepth, + isFullScreen, + registerEvents, + sigma, + ]); + + useEffect(() => { + const graph = new MultiDirectedGraph(); + + for (const [index, node] of nodes.entries()) { + graph.addNode(node.nodeId, { + borderColor: node.borderColor ?? node.color, + color: node.color, + x: index % 20, + y: Math.floor(index / 20), + label: node.label, + size: node.size, + type: "bordered", + }); + } + + for (const edge of edges) { + graph.addEdgeWithKey(edge.edgeId, edge.source, edge.target, { + color: "rgba(230, 230, 230, 1)", + label: edge.label, + size: edge.size, + type: "arrow", + }); + } + + loadGraph(graph); + + layout(); + }, [layout, loadGraph, sigma, nodes, edges]); + + return null; +}; diff --git a/apps/hash-frontend/src/pages/shared/types-graph/shared/full-screen.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/full-screen.tsx similarity index 77% rename from apps/hash-frontend/src/pages/shared/types-graph/shared/full-screen.tsx rename to apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/full-screen.tsx index 36e19d3fc9e..0f6767938a9 100644 --- a/apps/hash-frontend/src/pages/shared/types-graph/shared/full-screen.tsx +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/full-screen.tsx @@ -32,7 +32,13 @@ export const FullScreenContextProvider = ({ children }: PropsWithChildren) => { return ( - {children} + {/* + * We need height: 100% to give the Sigma Container its proper height, this class is the only way to achieve it + * @see https://github.com/snakesilk/react-fullscreen/issues/103 + */} + + {children} + ); }; diff --git a/apps/hash-frontend/src/pages/shared/types-graph/shared/settings.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/settings.ts similarity index 78% rename from apps/hash-frontend/src/pages/shared/types-graph/shared/settings.ts rename to apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/settings.ts index a1ea112153e..03116f8f247 100644 --- a/apps/hash-frontend/src/pages/shared/types-graph/shared/settings.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/settings.ts @@ -2,15 +2,18 @@ import { useTheme } from "@mui/material"; import { useSigma } from "@react-sigma/core"; import { useEffect } from "react"; -import { drawRoundRect } from "../../../../components/grid/utils/draw-round-rect"; +import { drawRoundRect } from "../../../../../components/grid/utils/draw-round-rect"; import { useFullScreen } from "./full-screen"; import type { GraphState } from "./state"; export const labelRenderedSizeThreshold = { - fullScreen: 14, + fullScreen: 12, normal: 16, }; +/** + * See also {@link GraphContainer} for additional settings + */ export const useDefaultSettings = (state: GraphState) => { const { palette } = useTheme(); const sigma = useSigma(); @@ -29,7 +32,14 @@ export const useDefaultSettings = (state: GraphState) => { * * Labels are prioritised for display by node size. */ - sigma.setSetting("labelDensity", 50); + sigma.setSetting("labelDensity", 1); + + /** + * Edge labels are only shown on hover, controlled in the event handlers. + */ + sigma.setSetting("edgeLabelColor", { color: "rgba(80, 80, 80, 0.6)" }); + sigma.setSetting("edgeLabelFont", `"Inter", "Helvetica", "sans-serif"`); + sigma.setSetting("edgeLabelSize", 10); /** * Controls what labels will be shown at which zoom levels. @@ -104,9 +114,14 @@ export const useDefaultSettings = (state: GraphState) => { if (state.hoveredNodeId !== node && !state.hoveredNeighborIds.has(node)) { /** * Nodes are always drawn over edges by the library, so anything other than hiding non-highlighted nodes - * means that they can obscure the highlighted edges. + * means that they can obscure the highlighted edges, as is the case here. + * + * If they are hidden, it is much more jarring when hovering over nodes because of the rest of the graph + * fully disappears, so having the 'non-highlighted' nodes remain like this is a UX compromise. */ - nodeData.hidden = true; + nodeData.color = "rgba(170, 170, 170, 0.7)"; + nodeData.borderColor = "rgba(170, 170, 170, 0.7)"; + nodeData.label = ""; } else { nodeData.forceLabel = true; } @@ -147,8 +162,12 @@ export const useDefaultSettings = (state: GraphState) => { } } - edgeData.hidden = !(sourceIsShown && targetIsShown); - edgeData.zIndex = 2; + if (sourceIsShown && targetIsShown) { + edgeData.zIndex = 2; + edgeData.forceLabel = true; + } else { + edgeData.hidden = true; + } return edgeData; }); diff --git a/apps/hash-frontend/src/pages/shared/types-graph/shared/state.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/state.ts similarity index 100% rename from apps/hash-frontend/src/pages/shared/types-graph/shared/state.ts rename to apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/state.ts diff --git a/apps/hash-frontend/src/pages/shared/types-graph/use-layout.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/use-layout.tsx similarity index 56% rename from apps/hash-frontend/src/pages/shared/types-graph/use-layout.tsx rename to apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/use-layout.tsx index a7fb78c0601..de56dca804e 100644 --- a/apps/hash-frontend/src/pages/shared/types-graph/use-layout.tsx +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/use-layout.tsx @@ -1,5 +1,6 @@ import { useSigma } from "@react-sigma/core"; import forceAtlas2 from "graphology-layout-forceatlas2"; +import FA2Layout from "graphology-layout-forceatlas2/worker"; import { useCallback } from "react"; export const useLayout = () => { @@ -13,20 +14,31 @@ export const useLayout = () => { */ const settings = forceAtlas2.inferSettings(graph); - forceAtlas2.assign(sigma.getGraph(), { - /** - * How many iterations to run the layout algorithm for. - */ - iterations: 20, + const forceAtlasLayout = new FA2Layout(graph, { /** * @see https://graphology.github.io/standard-library/layout-forceatlas2.html * @see https://observablehq.com/@mef/forceatlas2-layout-settings-visualized */ settings: { ...settings, - gravity: 1, + outboundAttractionDistribution: true, + gravity: 2.5, }, }); + + forceAtlasLayout.start(); + + setInterval(() => { + /** + * The layout process will stop automatically if the algorithm has converged, but this is not guaranteed to happen. + * A few seconds seems sufficient for graphs of ~10k items (nodes and edges). + */ + forceAtlasLayout.stop(); + }, 4_000); + + return () => { + forceAtlasLayout.kill(); + }; }, [sigma]); return layout; diff --git a/apps/hash-frontend/src/pages/shared/type-graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/type-graph-visualizer.tsx new file mode 100644 index 00000000000..2d1bc2057ae --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/type-graph-visualizer.tsx @@ -0,0 +1,181 @@ +import type { VersionedUrl } from "@blockprotocol/type-system-rs/pkg/type-system"; +import { typedEntries } from "@local/advanced-types/typed-entries"; +import type { + DataTypeWithMetadata, + EntityTypeWithMetadata, + PropertyTypeWithMetadata, +} from "@local/hash-graph-types/ontology"; +import { useTheme } from "@mui/material"; +import { useCallback, useMemo } from "react"; + +import { useEntityTypesContextRequired } from "../../shared/entity-types-context/hooks/use-entity-types-context-required"; +import type { GraphVisualizerProps } from "./graph-visualizer"; +import { GraphVisualizer } from "./graph-visualizer"; +import type { + GraphVizEdge, + GraphVizNode, +} from "./graph-visualizer/graph-container/graph-data-loader"; + +const anythingNodeId = "anything"; + +export const TypeGraphVisualizer = ({ + onTypeClick, + types, +}: { + onTypeClick: (typeId: VersionedUrl) => void; + types: ( + | DataTypeWithMetadata + | EntityTypeWithMetadata + | PropertyTypeWithMetadata + )[]; +}) => { + const { palette } = useTheme(); + + const { isSpecialEntityTypeLookup } = useEntityTypesContextRequired(); + + const { edges, nodes } = useMemo(() => { + const edgesToAdd: GraphVizEdge[] = []; + const nodesToAdd: GraphVizNode[] = []; + + const addedNodeIds = new Set(); + + const anythingNode: GraphVizNode = { + color: palette.gray[30], + nodeId: anythingNodeId, + label: "Anything", + size: 18, + }; + + for (const { schema } of types) { + if (schema.kind !== "entityType") { + /** + * Don't yet add property or data types to the graph. + */ + continue; + } + + const entityTypeId = schema.$id; + + const isLink = isSpecialEntityTypeLookup?.[entityTypeId]?.isLink; + if (isLink) { + /** + * We'll add the links as we process each entity type. + */ + continue; + } + + nodesToAdd.push({ + nodeId: entityTypeId, + color: palette.blue[70], + label: schema.title, + size: 14, + }); + + addedNodeIds.add(entityTypeId); + + for (const [linkTypeId, destinationSchema] of typedEntries( + schema.links ?? {}, + )) { + const destinationTypeIds = + "oneOf" in destinationSchema.items + ? destinationSchema.items.oneOf.map((dest) => dest.$ref) + : null; + + /** + * Links can be re-used by multiple different entity types, e.g. + * @hash/person —> @hash/has-friend —> @hash/person + * @alice/person —> @hash/has-friend —> @alice/person + * + * We need to create a separate link node per destination set, even if the link type is the same, + * so that the user can tell the possible destinations for a given link type from a given entity type. + * But we can re-use any with the same destination set. + * The id is therefore based on the link type and the destination types. + */ + const linkNodeId = `${linkTypeId}~${ + destinationTypeIds?.join("-") ?? "anything" + }`; + + if (!addedNodeIds.has(linkNodeId)) { + const linkSchema = types.find( + (type) => type.schema.$id === linkTypeId, + )?.schema; + + if (!linkSchema) { + continue; + } + + nodesToAdd.push({ + nodeId: linkNodeId, + color: palette.common.black, + label: linkSchema.title, + size: 12, + }); + addedNodeIds.add(linkNodeId); + + if (destinationTypeIds) { + for (const destinationTypeId of destinationTypeIds) { + edgesToAdd.push({ + edgeId: `${linkNodeId}~${destinationTypeId}`, + size: 3, + source: linkNodeId, + target: destinationTypeId, + }); + } + } else { + /** + * There is no constraint on destinations, so we link it to the 'Anything' node. + */ + if (!addedNodeIds.has(anythingNodeId)) { + /** + * We only add the Anything node if it's being used (i.e. here). + */ + nodesToAdd.push(anythingNode); + addedNodeIds.add(anythingNodeId); + } + edgesToAdd.push({ + edgeId: `${linkNodeId}~${anythingNodeId}`, + size: 3, + source: linkNodeId, + target: anythingNodeId, + }); + } + } + + edgesToAdd.push({ + edgeId: `${entityTypeId}~${linkNodeId}`, + size: 3, + source: entityTypeId, + target: linkNodeId, + }); + } + } + + return { + edges: edgesToAdd, + nodes: nodesToAdd, + }; + }, [isSpecialEntityTypeLookup, palette, types]); + + const onNodeClick = useCallback< + NonNullable + >( + ({ nodeId, isFullScreen }) => { + if (nodeId === anythingNodeId) { + return; + } + + if (isFullScreen) { + return; + } + + const typeVersionedUrl = nodeId.split("~")[0] as VersionedUrl; + + onTypeClick(typeVersionedUrl); + }, + [onTypeClick], + ); + + return ( + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/types-graph.tsx b/apps/hash-frontend/src/pages/shared/types-graph.tsx deleted file mode 100644 index a4c0b9013ce..00000000000 --- a/apps/hash-frontend/src/pages/shared/types-graph.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import "@react-sigma/core/lib/react-sigma.min.css"; - -import { MultiDirectedGraph } from "graphology"; -import dynamic from "next/dynamic"; -import { memo, useState } from "react"; - -import type { TypesGraphProps } from "./types-graph/graph-loader"; -import { - FullScreenContextProvider, - useFullScreen, -} from "./types-graph/shared/full-screen"; - -const Graph = ({ - height, - onTypeClick, - types, -}: Omit & { height: string | number }) => { - /** - * When a node is hovered or selected, we highlight its neighbors up to this depth. - * - * Not currently exposed as a user setting but could be, thus the state. - */ - const [highlightDepth, _setHighlightDepth] = useState(2); - - /** - * WebGL APIs aren't available in the server, so we need to dynamically load any module which uses Sigma/graphology. - */ - const SigmaContainer = dynamic( - import("@react-sigma/core").then((module) => module.SigmaContainer), - { ssr: false }, - ); - - const TypesGraphLoader = dynamic( - import("./types-graph/graph-loader").then((module) => module.GraphLoader), - { ssr: false }, - ); - - const FullScreenButton = dynamic( - import("./types-graph/full-screen-button").then( - (module) => module.FullScreenButton, - ), - { ssr: false }, - ); - - const { isFullScreen } = useFullScreen(); - - return ( - - - - - ); -}; - -export const TypesGraph = memo( - ({ - height, - onTypeClick, - types, - }: Omit & { height: string | number }) => { - /** - * WebGL APIs aren't available in the server, so we need to dynamically load any module which uses Sigma/graphology. - */ - if (typeof window !== "undefined") { - return ( - - - - ); - } - - return null; - }, -); diff --git a/apps/hash-frontend/src/pages/shared/types-graph/graph-loader.tsx b/apps/hash-frontend/src/pages/shared/types-graph/graph-loader.tsx deleted file mode 100644 index 538dcca3f43..00000000000 --- a/apps/hash-frontend/src/pages/shared/types-graph/graph-loader.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import type { VersionedUrl } from "@blockprotocol/type-system/slim"; -import { typedEntries } from "@local/advanced-types/typed-entries"; -import type { - DataTypeWithMetadata, - EntityTypeWithMetadata, - PropertyTypeWithMetadata, -} from "@local/hash-graph-types/ontology"; -import { useTheme } from "@mui/material"; -import { useLoadGraph, useRegisterEvents, useSigma } from "@react-sigma/core"; -import { MultiDirectedGraph } from "graphology"; -import { useEffect, useRef } from "react"; -import type { SigmaNodeEventPayload } from "sigma/types"; - -import { useEntityTypesContextRequired } from "../../../shared/entity-types-context/hooks/use-entity-types-context-required"; -import { useFullScreen } from "./shared/full-screen"; -import { useDefaultSettings } from "./shared/settings"; -import type { GraphState } from "./shared/state"; -import { useLayout } from "./use-layout"; - -export type TypesGraphProps = { - highlightDepth: number; - onTypeClick: (typeId: VersionedUrl) => void; - types: ( - | DataTypeWithMetadata - | EntityTypeWithMetadata - | PropertyTypeWithMetadata - )[]; -}; - -const anythingNodeId = "anything"; - -export const GraphLoader = ({ - highlightDepth, - onTypeClick, - types, -}: TypesGraphProps) => { - /** - * Hooks provided by the react-sigma library to simplify working with the sigma instance. - */ - const loadGraph = useLoadGraph(); - const registerEvents = useRegisterEvents(); - const sigma = useSigma(); - - /** - * Custom hooks for laying out the graph, and handling fullscreen state - */ - const layout = useLayout(); - const { isFullScreen } = useFullScreen(); - - const { palette } = useTheme(); - - const { isSpecialEntityTypeLookup } = useEntityTypesContextRequired(); - - /** - * State to track interactions with the graph. - * It's drawn in canvas so doesn't need to be in React state - * – redrawing the graph is done via sigma.refresh. - */ - const graphState = useRef({ - hoveredNodeId: null, - hoveredNeighborIds: null, - selectedNodeId: null, - }); - - useDefaultSettings(graphState.current); - - useEffect(() => { - /** - * Highlight a node and its neighbors up to a certain depth. - */ - const highlightNode = (event: SigmaNodeEventPayload) => { - graphState.current.hoveredNodeId = event.node; - - const getNeighbors = ( - nodeId: string, - neighborIds: Set = new Set(), - depth = 1, - ) => { - if (depth > highlightDepth) { - return neighborIds; - } - - const directNeighbors = sigma.getGraph().neighbors(nodeId); - - for (const neighbor of directNeighbors) { - neighborIds.add(neighbor); - getNeighbors(neighbor, neighborIds, depth + 1); - } - - return neighborIds; - }; - - graphState.current.hoveredNeighborIds = getNeighbors(event.node); - - /** - * We haven't touched the graph data, so don't need to re-index. - * An additional optimization would be to supply partialGraph here and only redraw the affected nodes, - * but since the nodes whose appearance changes are the NON-highlighted nodes (they disappear), it's probably not worth it - * – they are likely to be the majority anyway, and we'd have to generate an array of them. - */ - sigma.refresh({ skipIndexation: true }); - }; - - const removeHighlights = () => { - graphState.current.hoveredNodeId = null; - graphState.current.hoveredNeighborIds = null; - sigma.refresh({ skipIndexation: true }); - }; - - registerEvents({ - clickNode: (event) => { - if (!isFullScreen && event.node !== anythingNodeId) { - onTypeClick(event.node.split("~")[0]! as VersionedUrl); - } - - graphState.current.selectedNodeId = event.node; - highlightNode(event); - }, - clickStage: () => { - if (!graphState.current.selectedNodeId) { - return; - } - - /** - * If we click on the background (the 'stage'), deselect the selected node. - */ - graphState.current.selectedNodeId = null; - removeHighlights(); - }, - enterNode: (event) => { - graphState.current.selectedNodeId = null; - highlightNode(event); - }, - leaveNode: () => { - if (graphState.current.selectedNodeId) { - /** - * If there's a selected node (has been clicked on), we don't want to remove highlights. - * The user can click the background or another node to deselect it. - */ - return; - } - removeHighlights(); - }, - }); - }, [highlightDepth, isFullScreen, onTypeClick, registerEvents, sigma]); - - useEffect(() => { - const graph = new MultiDirectedGraph(); - - const edgesToAdd: { - source: string; - target: string; - }[] = []; - - const addedNodeIds = new Set(); - - const anythingNode = { - color: palette.gray[30], - x: 0, - y: 0, - label: "Anything", - size: 18, - }; - - for (const { schema } of types) { - if (schema.kind !== "entityType") { - /** - * Don't yet add property or data types to the graph. - */ - continue; - } - - const entityTypeId = schema.$id; - - const isLink = isSpecialEntityTypeLookup?.[entityTypeId]?.isLink; - if (isLink) { - /** - * We'll add the links as we process each entity type. - */ - continue; - } - - graph.addNode(entityTypeId, { - color: palette.blue[70], - /** - * use a simple grid layout to start, to be improved upon by the layout algorithm once the full graph is built - */ - x: addedNodeIds.size % 20, - y: Math.floor(addedNodeIds.size / 20), - label: schema.title, - size: 14, - }); - addedNodeIds.add(entityTypeId); - - for (const [linkTypeId, destinationSchema] of typedEntries( - schema.links ?? {}, - )) { - const destinationTypeIds = - "oneOf" in destinationSchema.items - ? destinationSchema.items.oneOf.map((dest) => dest.$ref) - : null; - - /** - * Links can be re-used by multiple different entity types, e.g. - * @hash/person —> @hash/has-friend —> @hash/person - * @alice/person —> @hash/has-friend —> @alice/person - * - * We need to create a separate link node per destination set, even if the link type is the same, - * so that the user can tell the possible destinations for a given link type from a given entity type. - * But we can re-use any with the same destination set. - * The id is therefore based on the link type and the destination types. - */ - const linkNodeId = `${linkTypeId}~${destinationTypeIds?.join("-") ?? "anything"}`; - - if (!addedNodeIds.has(linkNodeId)) { - const linkSchema = types.find( - (type) => type.schema.$id === linkTypeId, - )?.schema; - - if (!linkSchema) { - continue; - } - - graph.addNode(linkNodeId, { - color: palette.common.black, - x: addedNodeIds.size % 20, - y: Math.floor(addedNodeIds.size / 20), - label: linkSchema.title, - size: 12, - }); - addedNodeIds.add(linkNodeId); - - if (destinationTypeIds) { - for (const destinationTypeId of destinationTypeIds) { - edgesToAdd.push({ - source: linkNodeId, - target: destinationTypeId, - }); - } - } else { - /** - * There is no constraint on destinations, so we link it to the 'Anything' node. - */ - if (!addedNodeIds.has(anythingNodeId)) { - /** - * We only add the Anything node if it's being used (i.e. here). - */ - graph.addNode(anythingNodeId, anythingNode); - addedNodeIds.add(anythingNodeId); - } - edgesToAdd.push({ - source: linkNodeId, - target: anythingNodeId, - }); - } - } - - edgesToAdd.push({ - source: entityTypeId, - target: linkNodeId, - }); - } - } - - for (const edge of edgesToAdd) { - graph.addEdgeWithKey( - `${edge.source}~${edge.target}`, - edge.source, - edge.target, - { - size: 3, - type: "arrow", - }, - ); - } - - loadGraph(graph); - - layout(); - }, [layout, loadGraph, isSpecialEntityTypeLookup, palette, sigma, types]); - - return null; -}; diff --git a/apps/hash-frontend/src/pages/types/[[...type-kind]].page/types-table.tsx b/apps/hash-frontend/src/pages/types/[[...type-kind]].page/types-table.tsx index c5daf2b913d..38bcb1ab266 100644 --- a/apps/hash-frontend/src/pages/types/[[...type-kind]].page/types-table.tsx +++ b/apps/hash-frontend/src/pages/types/[[...type-kind]].page/types-table.tsx @@ -49,7 +49,7 @@ import { TableHeaderToggle } from "../../shared/table-header-toggle"; import type { TableView } from "../../shared/table-views"; import { tableViewIcons } from "../../shared/table-views"; import { TOP_CONTEXT_BAR_HEIGHT } from "../../shared/top-context-bar"; -import { TypesGraph } from "../../shared/types-graph"; +import { TypeGraphVisualizer } from "../../shared/type-graph-visualizer"; const typesTableColumnIds = [ "title", @@ -510,11 +510,12 @@ export const TypesTable: FunctionComponent<{ freezeColumns={1} /> ) : ( - + + + )} diff --git a/libs/@hashintel/block-design-system/src/entities-graph-chart.tsx b/libs/@hashintel/block-design-system/src/entities-graph-chart.tsx index d8ce72778d2..86308693fde 100644 --- a/libs/@hashintel/block-design-system/src/entities-graph-chart.tsx +++ b/libs/@hashintel/block-design-system/src/entities-graph-chart.tsx @@ -38,6 +38,10 @@ const generateEntityLabel = ( const nodeCategories = [{ name: "entity" }, { name: "entityType" }]; +/** + * @deprecated use EntityGraphVisualizer instead. + * This is still referenced by the Chart block, but the blocks need updating to use the new libraries. + */ export const EntitiesGraphChart = ({ entities, filterEntity, diff --git a/yarn.lock b/yarn.lock index 85dd4f6892c..ab9e56550d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7237,32 +7237,11 @@ dependencies: "@babel/runtime" "^7.13.10" -"@react-sigma/core@4.0.3", "@react-sigma/core@^4.0.3": +"@react-sigma/core@4.0.3": version "4.0.3" resolved "https://registry.yarnpkg.com/@react-sigma/core/-/core-4.0.3.tgz#b97c1cf835df84a1401d715b176997f27c0fe7a9" integrity sha512-/y/U1GH18xjGYMWNYXXqHGJ+8tHf+e4z1i0gNSm9iuhch8sGFJooO3VfyPJlBox42Wl4/gCapQhOHAfVW0pRtw== -"@react-sigma/layout-core@4.0.3", "@react-sigma/layout-core@^4.0.3": - version "4.0.3" - resolved "https://registry.yarnpkg.com/@react-sigma/layout-core/-/layout-core-4.0.3.tgz#a6835cf416ab591ed9de0a64a8d5a25b03034433" - integrity sha512-0zt2JJw2Hjp/I8kn3RJgLR9M9u4SRSSlmp7XNuMqG4FLrHrL5MZ8nFhUMzMgNuxUn0rLtpdh7NDd8wgdhKeQjA== - dependencies: - "@react-sigma/core" "^4.0.3" - -"@react-sigma/layout-forceatlas2@4.0.3": - version "4.0.3" - resolved "https://registry.yarnpkg.com/@react-sigma/layout-forceatlas2/-/layout-forceatlas2-4.0.3.tgz#58f4c05d40f6fc6bf30e851b76c64731eb123bfb" - integrity sha512-JpYmFetlV6Sn/uslsOpqtuWyaRADASyQ+fMkkUsh9iPG7Vm/srGwLOR33kUw31yt1gBiagp3vy+4EKjUxex1Uw== - dependencies: - "@react-sigma/layout-core" "^4.0.3" - -"@react-sigma/layout-random@4.0.3": - version "4.0.3" - resolved "https://registry.yarnpkg.com/@react-sigma/layout-random/-/layout-random-4.0.3.tgz#f60e4a146085bd8f85eb6208c9443b15b24195fe" - integrity sha512-FjRWgKzrhdqaCkCIXSrGru+fFMMA/olcMFGuaNX2vC5CPzMildj0IZbSOhxju/QeLSMieDm5stG8VC5iZaKWyw== - dependencies: - "@react-sigma/layout-core" "^4.0.3" - "@reactflow/background@11.3.14": version "11.3.14" resolved "https://registry.yarnpkg.com/@reactflow/background/-/background-11.3.14.tgz#778ca30174f3de77fc321459ab3789e66e71a699" @@ -7858,6 +7837,11 @@ resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" integrity "sha1-z/j/rcNyrSn9P3gneusp5jLMcN8= sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" +"@sigma/node-border@3.0.0-beta.4": + version "3.0.0-beta.4" + resolved "https://registry.yarnpkg.com/@sigma/node-border/-/node-border-3.0.0-beta.4.tgz#0bd5c34d5acc68a93c04dc2ce4d9c6e2d0022e12" + integrity sha512-vELXc8e1laGNqEhS2+2flf8AqjDPEH0jQargeWJyfMdEjI9XTWR6qiiglyxsNwyp+plJi2LiDiUd0UzfWbRK2Q== + "@sigstore/bundle@^2.3.2": version "2.3.2" resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-2.3.2.tgz#ad4dbb95d665405fd4a7a02c8a073dbd01e4e95e"