Skip to content
Draft
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
480 changes: 250 additions & 230 deletions apps/hash-frontend/src/pages/shared/entities-visualizer.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { ignoreNoisySystemTypesFilter } from "@local/hash-isomorphic-utils/graph-queries";
import { systemPropertyTypes } from "@local/hash-isomorphic-utils/ontology-type-ids";

import { hasActiveSemanticQuery } from "./types";

import type { EntitiesFilterState } from "./types";
import type { BaseUrl, VersionedUrl, WebId } from "@blockprotocol/type-system";
import type { Filter } from "@local/hash-graph-client";

const MATCH_NOTHING_WEB_ID = "00000000-0000-0000-0000-000000000000" as WebId;

const buildArchivedClauses = (includeArchived: boolean): Filter[] => {
if (includeArchived) {
return [];
}

return [
{
notEqual: [{ path: ["archived"] }, { parameter: true }],
},
{
any: [
{
exists: {
path: [
"properties",
systemPropertyTypes.archived.propertyTypeBaseUrl,
],
},
},
{
equal: [
{
path: [
"properties",
systemPropertyTypes.archived.propertyTypeBaseUrl,
],
},
{ parameter: false },
],
},
],
},
];
};

const buildWebClause = (
webState: EntitiesFilterState["web"],
internalWebIds: WebId[],
): Filter | null => {
if (!webState.includeOtherWebs) {
const selected = internalWebIds.filter((id) =>
webState.selectedInternalWebIds.has(id),
);

const webIdsToMatch = selected.length ? selected : [MATCH_NOTHING_WEB_ID];

return {
any: webIdsToMatch.map((webId) => ({
equal: [{ path: ["webId"] }, { parameter: webId }],
})),
};
}

const uncheckedInternalWebIds = internalWebIds.filter(
(id) => !webState.selectedInternalWebIds.has(id),
);

if (uncheckedInternalWebIds.length === 0) {
return null;
}

return {
all: uncheckedInternalWebIds.map((webId) => ({
notEqual: [{ path: ["webId"] }, { parameter: webId }],
})),
};
};

const buildTypeClause = ({
pinnedEntityTypeBaseUrl,
pinnedEntityTypeIds,
selectedTypeIds,
}: {
pinnedEntityTypeBaseUrl?: BaseUrl;
pinnedEntityTypeIds?: VersionedUrl[];
selectedTypeIds: Set<VersionedUrl> | null;
}): { clause: Filter | null; isPinned: boolean } => {
if (pinnedEntityTypeBaseUrl) {
return {
clause: {
equal: [
{ path: ["type", "baseUrl"] },
{ parameter: pinnedEntityTypeBaseUrl },
],
},
isPinned: true,
};
}

if (pinnedEntityTypeIds?.length) {
return {
clause: {
any: pinnedEntityTypeIds.map((entityTypeId) => ({
equal: [
{ path: ["type", "versionedUrl"] },
{ parameter: entityTypeId },
],
})),
},
isPinned: true,
};
}

if (selectedTypeIds === null) {
return { clause: null, isPinned: false };
}

const typeIds = Array.from(selectedTypeIds);

if (typeIds.length === 0) {
return {
clause: {
equal: [{ path: ["type", "versionedUrl"] }, { parameter: "" }],
},
isPinned: false,
};
}

return {
clause: {
any: typeIds.map((entityTypeId) => ({
equal: [
{ path: ["type", "versionedUrl"] },
{ parameter: entityTypeId },
],
})),
},
isPinned: false,
};
};

/**
* The maximum cosine distance between the query embedding and an entity's
* embedding for the entity to count as a match. Mirrors the global search bar
* (`search-bar.tsx`), which is tuned for the same OpenAI embedding model.
*/
const MAXIMUM_SEMANTIC_DISTANCE = 0.7;

const buildSemanticSearchClause = (
filterState: EntitiesFilterState,
): Filter | null => {
if (!hasActiveSemanticQuery(filterState)) {
return null;
}

return {
cosineDistance: [
{ path: ["embedding"] },
// The string is embedded server-side before the distance is computed.
{ parameter: filterState.semanticSearch.query.trim() },
{ parameter: MAXIMUM_SEMANTIC_DISTANCE },
],
};
};

export const buildEntitiesFilter = ({
filterState,
internalWebIds,
pinnedEntityTypeBaseUrl,
pinnedEntityTypeIds,
}: {
filterState: EntitiesFilterState;
internalWebIds: WebId[];
pinnedEntityTypeBaseUrl?: BaseUrl;
pinnedEntityTypeIds?: VersionedUrl[];
}): Filter => {
const clauses: Filter[] = [];

clauses.push(...buildArchivedClauses(filterState.includeArchived));

const webClause = buildWebClause(filterState.web, internalWebIds);
if (webClause) {
clauses.push(webClause);
}

const { clause: typeClause, isPinned: isTypePinned } = buildTypeClause({
pinnedEntityTypeBaseUrl,
pinnedEntityTypeIds,
selectedTypeIds: filterState.type.selectedTypeIds,
});

if (typeClause) {
clauses.push(typeClause);
}

const userPickedSpecificTypes =
!isTypePinned && filterState.type.selectedTypeIds !== null;

if (!isTypePinned && !userPickedSpecificTypes) {
clauses.push(ignoreNoisySystemTypesFilter);
}

const semanticSearchClause = buildSemanticSearchClause(filterState);
if (semanticSearchClause) {
clauses.push(semanticSearchClause);
}

return { all: clauses };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { VisualizerView } from "../../visualizer-views";
import type { TraversalPath } from "@local/hash-graph-client";

/**
* Graph view resolves links into and out of the displayed entities so link
* endpoints render even when the entity filter is narrow.
*/
const graphViewTraversalPaths: TraversalPath[] = [
{
edges: [
{ kind: "has-left-entity", direction: "incoming" },
{ kind: "has-right-entity", direction: "outgoing" },
],
},
];

/**
* Table / Grid views only need to resolve a link entity's own source and
* target endpoints.
*/
const tableViewTraversalPaths: TraversalPath[] = [
{
edges: [
{ kind: "has-left-entity", direction: "outgoing" },
{ kind: "has-right-entity", direction: "outgoing" },
],
},
];

export const traversalPathsForView = (
view: VisualizerView,
): TraversalPath[] => {
return view === "Graph" ? graphViewTraversalPaths : tableViewTraversalPaths;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { VersionedUrl, WebId } from "@blockprotocol/type-system";

export type EntitiesFilterState = {
web: {
selectedInternalWebIds: Set<WebId>;
includeOtherWebs: boolean;
};
type: {
selectedTypeIds: Set<VersionedUrl> | null;
};
includeArchived: boolean;
/**
* Server-side semantic search, modelled as a filter. `added` tracks whether
* the search pill is present (it can be added with an empty query, which is a
* no-op browse); `query` holds the debounced free-text query that is embedded
* server-side and turned into a `cosineDistance` clause.
*/
semanticSearch: {
added: boolean;
query: string;
};
};

export const createDefaultFilterState = (
internalWebIds: WebId[],
): EntitiesFilterState => ({
web: {
selectedInternalWebIds: new Set<WebId>(internalWebIds),
includeOtherWebs: false,
},
type: { selectedTypeIds: null },
includeArchived: false,
semanticSearch: { added: false, query: "" },
});

/**
* Whether a semantic search is currently driving the query β€” i.e. the pill is
* present and holds a non-empty query. When false the `cosineDistance` clause is
* omitted and the table behaves as a normal (cursor-paginated, column-sorted)
* browse.
*/
export const hasActiveSemanticQuery = (
filterState: EntitiesFilterState,
): boolean =>
filterState.semanticSearch.added &&
filterState.semanticSearch.query.trim().length > 0;
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useQuery } from "@apollo/client";
import { useMemo } from "react";

import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries";

import { queryEntitySubgraphQuery } from "../../../../graphql/queries/knowledge/entity.queries";
import { buildEntitiesFilter } from "./build-filter";

import type {
QueryEntitySubgraphQuery,
QueryEntitySubgraphQueryVariables,
} from "../../../../graphql/api-types.gen";
import type { EntitiesFilterState } from "./types";
import type { BaseUrl, VersionedUrl, WebId } from "@blockprotocol/type-system";

export type AvailableType = {
entityTypeId: VersionedUrl;
title: string;
count: number;
};

export const useAvailableTypes = ({
filterState,
internalWebIds,
entityTypeBaseUrl,
entityTypeIds,
}: {
filterState: EntitiesFilterState;
internalWebIds: WebId[];
entityTypeBaseUrl?: BaseUrl;
entityTypeIds?: VersionedUrl[];
}): { types: AvailableType[]; loading: boolean } => {
const skip = !!entityTypeBaseUrl || !!entityTypeIds?.length;

const filterStateWithoutType = useMemo<EntitiesFilterState>(
() => ({
...filterState,
type: { selectedTypeIds: null },
}),
[filterState],
);

const filter = useMemo(
() =>
buildEntitiesFilter({
filterState: filterStateWithoutType,
internalWebIds,
}),
[filterStateWithoutType, internalWebIds],
);

const { data, loading } = useQuery<
QueryEntitySubgraphQuery,
QueryEntitySubgraphQueryVariables
>(queryEntitySubgraphQuery, {
skip,
fetchPolicy: "cache-and-network",
variables: {
request: {
limit: 1,
filter,
includeTypeIds: true,
includeTypeTitles: true,
temporalAxes: currentTimeInstantTemporalAxes,
includeDrafts: false,
includePermissions: false,
traversalPaths: [],
},
},
});

const types = useMemo<AvailableType[]>(() => {
if (skip || !data) {
return [];
}
const typeIds = data.queryEntitySubgraph.typeIds ?? {};
const typeTitles = data.queryEntitySubgraph.typeTitles ?? {};
return Object.entries(typeIds)
.map(([entityTypeId, count]) => {
const versionedUrl = entityTypeId as VersionedUrl;
return {
entityTypeId: versionedUrl,
title: typeTitles[versionedUrl] ?? entityTypeId,
count,
};
})
.sort((a, b) => a.title.localeCompare(b.title));
}, [data, skip]);

return { types, loading: skip ? false : loading };
};
Loading
Loading