8000 fix(ui): open the graph on the namespace that has one by adeelahmad · Pull Request #339 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
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: 3 additions & 1 deletion docs/graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ vocabulary (SKOS), apart from entities, shown on the explorer canvas as their ow

## The explorer

Graph in the sidebar shows the graph of the scope picked at the top (one namespace, or all shared).
Graph in the sidebar shows the graph of the scope picked at the top (one namespace, or all shared). With no namespace
picked it opens on all shared namespaces, or, when none of them has a graph yet, on the namespace that keeps its graph
to itself and has one. On a computer the Find in graph box has the focus, so typing a name finds it.

- **Move around**: drag the background to pan, the wheel or the + and − buttons to zoom; on touch, drag with one
finger and pinch with two. Drag a node to put it where you want; it stays there on this device until a layout or
Expand Down
41 changes: 41 additions & 0 deletions nextjs-frontend/__tests__/graph-opening.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { openingNamespace } from "@/components/graph/model";

describe("where the graph opens when no namespace is picked", () => {
it("stays all shared namespaces when one of them has a graph", () => {
expect(
openingNamespace([
{ name: "interviews", graph: "isolated", analyzed: 4, speakers: 3 },
{ name: "podcasts", graph: "shared", analyzed: 1, speakers: 0 },
]),
).toBeNull();
});

it("opens the namespace that keeps its graph to itself when only it has one", () => {
expect(
openingNamespace([
{ name: "interviews", graph: "isolated", analyzed: 2, speakers: 3 },
{ name: "podcasts", graph: "shared", analyzed: 0, speakers: 0 },
]),
).toBe("interviews");
});

it("picks the fullest when several keep theirs to themselves", () => {
expect(
openingNamespace([
{ name: "family", graph: "isolated", analyzed: 1, speakers: 1 },
{ name: "work", graph: "isolated", analyzed: 5, speakers: 2 },
{ name: "podcasts", graph: "shared" },
]),
).toBe("work");
});

it("changes nothing when there is nothing to see yet", () => {
expect(
openingNamespace([
{ name: "interviews", graph: "isolated", analyzed: 0, speakers: 0 },
{ name: "podcasts", graph: "shared", analyzed: 0, speakers: 0 },
]),
).toBeNull();
expect(openingNamespace([])).toBeNull();
});
});
16 changes: 14 additions & 2 deletions nextjs-frontend/components/graph/filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@ import { count } from "@/lib/format";
import { cn } from "@/lib/utils";

/** "Find in graph": type to list matching nodes; Enter picks the best one. */
export function GraphFinder({ nodes, onPick }: { nodes: GraphNode[]; onPick: (id: string) => void }) {
export function GraphFinder({
nodes,
onPick,
autoFocus,
}: {
nodes: GraphNode[];
onPick: (id: string) => void;
autoFocus?: boolean;
}) {
const [q, setQ] = useState("");
const [active, setActive] = useState(0);
const hits = findNodes(nodes, q).slice(0, 8);
Expand All @@ -35,6 +43,7 @@ export function GraphFinder({ nodes, onPick }: { nodes: GraphNode[]; onPick: (id
setQ("");
} else if (e.key === "Escape") setQ("");
}}
autoFocus={autoFocus}
placeholder="Find in graph"
aria-label="Find in graph"
role="combobox"
Expand Down Expand Up @@ -94,6 +103,7 @@ export function GraphFilters({
maxNodes,
onPick,
isolated,
focusFind,
}: {
nodes: GraphNode[];
scope: "ns" | "all";
Expand All @@ -112,6 +122,8 @@ export function GraphFilters({
maxNodes: number | null;
onPick: (id: string) => void;
isolated: string[];
/** The find box takes the focus when it shows. */
focusFind?: boolean;
}) {
const present = new Set(nodes.map(nodeGroup));
const toggle = (set: Set<string>, v: string) => {
Expand All @@ -122,7 +134,7 @@ export function GraphFilters({
};
return (
<div className="flex flex-col gap-3.5">
<GraphFinder nodes={nodes} />
<GraphFinder nodes={nodes} autoFocus={focusFind} />
<div className="flex flex-col gap-1.5">
<span className="label-caps">Scope</span>
<Segmented
Expand Down
22 changes: 15 additions & 7 deletions nextjs-frontend/components/graph/graph-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
findFocus,
mergeGraph,
NODE_GROUPS,
openingNamespace,
summarize,
type GraphData,
type GraphNode,
Expand Down Expand Up @@ -63,15 +64,18 @@ export function GraphPage() {
const router = useRouter();
const pathname = usePathname();
const client = useApiClient();
const { namespaces, namespace: topNs, admin } = useArchive();
const { namespaces, namespace: topNs, admin, loaded } = useArchive();
const nsNames = namespaces.map((n) => n.name);
const focusParam = params.get("focus");
const urlNs = params.get("ns");
const scopeMode: "ns" | "all" = params.get("scope") === "all" ? "all" : urlNs ? "ns" : topNs ? "ns" : "all";
const allShared = params.get("scope") === "all";
// with no namespace picked, open where there is something to see
const opening = urlNs || topNs || allShared ? null : openingNamespace(namespaces);
const scopeMode: "ns" | "all" = allShared ? "all" : urlNs || topNs || opening ? "ns" : "all";
const ns =
urlNs && nsNames.includes(urlNs)
? urlNs
: (topNs ?? namespaces.find((n) => n.graph !== "isolated")?.name ?? nsNames[0] ?? null);
: (topNs ?? opening ?? namespaces.find((n) => n.graph !== "isolated")?.name ?? nsNames[0] ?? null);
const scope = scopeMode === "all" || !ns ? "global" : `ns:${ns}`;
const view = params.get("view") === "table" ? "table" : "graph";
const asOf = params.get("as_of");
Expand All @@ -98,6 +102,8 @@ export function GraphPage() {
queryKey: ["graph", scope, asOf],
queryFn: async () =>
(await data(Search.getGraph({ client, query: { scope, as_of: asOf } }))) as unknown as GraphData,
// the namespaces decide the scope, so wait for them rather than build a graph nobody looks at
enabled: loaded,
staleTime: 60_000,
});
const settings = useQuery({
Expand Down Expand Up @@ -201,7 +207,8 @@ export function GraphPage() {
(j.steps ?? []).some((s) => (typeof s === "string" ? s : (s as { type?: string })?.type) === "analyze")),
).length;

const filters = (
// the side column's find box takes the focus, so typing finds; the filters sheet on smaller screens doesn't
const filters = (focusFind: boolean) => (
<GraphFilters
nodes={all.nodes}
scope={scope === "global" ? "all" : "ns"}
Expand All @@ -223,6 +230,7 @@ export function GraphPage() {
maxNodes={maxNodes}
=> select(id)}
isolated={namespaces.filter((n) => n.graph === "isolated").map((n) => n.name)}
focusFind={focusFind}
/>
);
const viewSwitch = (
Expand All @@ -245,7 +253,7 @@ export function GraphPage() {
aria-label="Graph filters"
className="hidden min-h-0 flex-col gap-4 overflow-y-auto border-r border-border bg-surface p-4 lg:flex"
>
{filters}
{filters(true)}
<span className="flex-1" />
<Link href="/routines/changes" className="text-[12.5px] font-semibold text-fg-accent hover:underline">
Proposed changes to review
Expand Down Expand Up @@ -276,7 +284,7 @@ export function GraphPage() {
show, or be hidden.
</Banner>
)}
{graph.isLoading && (
{(!loaded || graph.isLoading) && (
<div className="flex flex-1 items-center justify-center p-10" aria-busy="true" aria-label="Loading the graph">
<Skeleton className="size-64 rounded-full" />
</div>
Expand Down Expand Up @@ -397,7 +405,7 @@ export function GraphPage() {
</div>
</Drawer>
<Dialog open={filtersOpen} title="Graph filters">
{filters}
{filters(false)}
</Dialog>
</div>
);
Expand Down
11 changes: 11 additions & 0 deletions nextjs-frontend/components/graph/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,17 @@ export function summarize(nodes: GraphNode[], edges: GraphEdge[], scopeLabel: st
return `${head} Most connected: ${top.map((n) => `${n.label} (${degree.get(n.id)} ${degree.get(n.id) === 1 ? "link" : "links"})`).join(", ")}.`;
}

/** Where the graph opens when no namespace is picked: all shared namespaces (null), unless none of them has anything in
* its graph yet and a namespace that keeps its graph to itself does; then that one, the fullest if there are several. */
export function openingNamespace(
namespaces: { name: string; graph?: string | null; analyzed?: number; speakers?: number }[],
): string | null {
const size = (n: { analyzed?: number; speakers?: number }) => (n.analyzed ?? 0) + (n.speakers ?? 0);
if (namespaces.some((n) => n.graph !== "isolated" && size(n) > 0)) return null;
const fullest = namespaces.filter((n) => size(n) > 0).sort((a, b) => size(b) - size(a))[0];
return fullest?.name ?? null;
}

/** Find a node from a `?focus=` value: "s4" (speaker), "e12" (an entity id, also inside merged global nodes). */
export function findFocus(nodes: GraphNode[], focus: string | null | undefined): GraphNode | undefined {
if (!focus) return undefined;
Expand Down
Loading
0