diff --git a/app/graph/DataPanel.tsx b/app/graph/DataPanel.tsx new file mode 100644 index 0000000..d7ef06d --- /dev/null +++ b/app/graph/DataPanel.tsx @@ -0,0 +1,41 @@ +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +export default function DataPanel({node}: {node: Node}) { + return ( + + + + Field + Value + + + + { + Object.entries(node).map((row, index) => ( + // eslint-disable-next-line react/no-array-index-key + + { + Object.values(row).map((cell, cellIndex) => ( + // eslint-disable-next-line react/no-array-index-key + + + + + {JSON.stringify(cell)} + + +

{JSON.stringify(cell)}

+
+
+
+
+ )) + } +
+ )) + } +
+
+ ) +} \ No newline at end of file diff --git a/app/graph/GraphView.tsx b/app/graph/GraphView.tsx new file mode 100644 index 0000000..1cfb5cf --- /dev/null +++ b/app/graph/GraphView.tsx @@ -0,0 +1,208 @@ +import CytoscapeComponent from "react-cytoscapejs"; +import { toast } from "@/components/ui/use-toast"; +import cytoscape, { ElementDefinition, EventObject, NodeDataDefinition } from "cytoscape"; +import { RefAttributes, useRef, useState, useImperativeHandle } from "react"; +import { signOut } from "next-auth/react"; +import fcose from 'cytoscape-fcose'; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { ImperativePanelHandle } from "react-resizable-panels"; +import Labels from "./labels"; +import Toolbar from "./toolbar"; +import { Category, Graph } from "./model"; +import DataPanel from "./DataPanel"; + +const LAYOUT = { + name: "fcose", + fit: true, + padding: 30, +} + +cytoscape.use(fcose); + +// The stylesheet for the graph +function getStyle(darkmode: boolean) { + + const style: cytoscape.Stylesheet[] = [ + { + selector: "core", + style: { + 'active-bg-size': 0, // hide gray circle when panning + // All of the following styles are meaningless and are specified + // to satisfy the linter... + 'active-bg-color': 'blue', + 'active-bg-opacity': 0.3, + "selection-box-border-color": 'blue', + "selection-box-border-width": 0, + "selection-box-opacity": 1, + "selection-box-color": 'blue', + "outside-texture-bg-color": 'blue', + "outside-texture-bg-opacity": 1, + }, + }, + { + selector: "node", + style: { + label: "data(name)", + "text-valign": "center", + "text-halign": "center", + "text-wrap": "ellipsis", + "text-max-width": "10rem", + shape: "ellipse", + height: "10rem", + width: "10rem", + "border-width": 0.15, + "border-opacity": 0.5, + "background-color": "data(color)", + "font-size": "3rem", + "overlay-padding": "1rem", + }, + }, + { + selector: "node:active", + style: { + "overlay-opacity": 0, // hide gray box around active node + }, + }, + { + selector: "edge", + style: { + width: 0.5, + "line-color": "#ccc", + "arrow-scale": 0.3, + "target-arrow-shape": "triangle", + label: "data(label)", + 'curve-style': 'straight', + "text-background-color": darkmode ? "#020817" : "white", + "color": darkmode ? "white" : "black", + "text-background-opacity": 1, + "font-size": "3rem", + "overlay-padding": "2rem", + + }, + }, + ] + return style +} + +export interface GraphViewRef { + expand: (elements: ElementDefinition[]) => void +} + +interface GraphViewProps extends RefAttributes { + graph: Graph, + darkmode: boolean +} + +export function GraphView({ graph, darkmode, ref }: GraphViewProps) { + + const [selectedNode, setSelectedNode] = useState(null); + + // A reference to the chart container to allowing zooming and editing + const chartRef = useRef(null) + const dataPanel = useRef(null) + + useImperativeHandle(ref, () => ({ + expand: (elements) => { + const chart = chartRef.current + if (chart) { + chart.elements().remove() + chart.add(elements) + chart.elements().layout(LAYOUT).run(); + } + } + })) + + // Send the user query to the server to expand a node + async function onFetchNode(node: NodeDataDefinition) { + const result = await fetch(`/api/graph/${graph.Id}/${node.id}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }) + + if (result.status >= 300) { + toast({ + title: "Error", + description: result.text(), + }) + if (result.status >= 400 && result.status < 500) { + signOut({ callbackUrl: '/login' }) + } + return [] as ElementDefinition[] + } + + const json = await result.json() + const elements = graph.extend(json.result) + return elements + } + + const onCategoryClick = (category: Category) => { + const chart = chartRef.current + if (chart) { + const elements = chart.elements(`node[category = "${category.name}"]`) + + // eslint-disable-next-line no-param-reassign + category.show = !category.show + + if (category.show) { + elements.style({ display: 'element' }) + } else { + elements.style({ display: 'none' }) + } + chart.elements().layout(LAYOUT).run(); + } + } + + const handleDoubleClick = async (evt: EventObject) => { + const node: Node = evt.target.json().data; + const elements = await onFetchNode(node); + + // adjust entire graph. + if (chartRef.current && elements.length > 0) { + chartRef.current.add(elements); + chartRef.current.elements().layout(LAYOUT).run(); + } + } + + const handleTap = (evt: EventObject) => { + const node: Node = evt.target.json().data; + setSelectedNode(node); + dataPanel.current?.expand(); + } + + return ( + + +
+ + +
+ { + chartRef.current = cy + + // Make sure no previous listeners are attached + cy.removeAllListeners(); + + // Listen to the double click event on nodes for expanding the node + cy.on('dbltap', 'node', handleDoubleClick); + + // Listen to the click event on nodes for showing node properties + cy.on('tap', 'node', handleTap); + }} + stylesheet={getStyle(darkmode)} + elements={graph.Elements} + layout={LAYOUT} + className="w-full grow" + /> +
+ + + {selectedNode && } + +
+ + + ) +} \ No newline at end of file diff --git a/app/graph/page.tsx b/app/graph/page.tsx index 6fd489d..f2f88d5 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -1,91 +1,14 @@ 'use client' import { toast } from "@/components/ui/use-toast"; -import CytoscapeComponent from 'react-cytoscapejs' -import cytoscape, { ElementDefinition, NodeDataDefinition } from 'cytoscape'; -import fcose from 'cytoscape-fcose'; import { useRef, useState } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { signOut } from "next-auth/react"; import { useTheme } from "next-themes"; -import Toolbar from "./toolbar"; import { Query, QueryState } from "./query"; -import Labels from "./labels"; import { TableView } from "./tableview"; -import { Graph, Category } from "./model"; - -cytoscape.use(fcose); - -// The stylesheet for the graph -function getStyle(darkmode: boolean) { - - const style: cytoscape.Stylesheet[] = [ - { - selector: "core", - style: { - 'active-bg-size': 0, // hide gray circle when panning - // All of the following styles are meaningless and are specified - // to satisfy the linter... - 'active-bg-color': 'blue', - 'active-bg-opacity': 0.3, - "selection-box-border-color": 'blue', - "selection-box-border-width": 0, - "selection-box-opacity": 1, - "selection-box-color": 'blue', - "outside-texture-bg-color": 'blue', - "outside-texture-bg-opacity": 1, - }, - }, - { - selector: "node", - style: { - label: "data(name)", - "text-valign": "center", - "text-halign": "center", - "text-wrap": "ellipsis", - "text-max-width": "10rem", - shape: "ellipse", - height: "10rem", - width: "10rem", - "border-width": 0.15, - "border-opacity": 0.5, - "background-color": "data(color)", - "font-size": "3rem", - "overlay-padding": "1rem", - }, - }, - { - selector: "node:active", - style: { - "overlay-opacity": 0, // hide gray box around active node - }, - }, - { - selector: "edge", - style: { - width: 0.5, - "line-color": "#ccc", - "arrow-scale": 0.3, - "target-arrow-shape": "triangle", - label: "data(label)", - 'curve-style': 'straight', - "text-background-color": darkmode? "#020817": "white", - "color": darkmode? "white" : "black", - "text-background-opacity": 1, - "font-size": "3rem", - "overlay-padding": "2rem", - - }, - }, - ] - return style -} - -const LAYOUT = { - name: "fcose", - fit: true, - padding: 30, -} +import { Graph } from "./model"; +import { GraphView, GraphViewRef } from "./GraphView"; // Validate the graph selection is not empty and show an error message if it is @@ -103,8 +26,8 @@ function validateGraphSelection(graphName: string): boolean { export default function Page() { const [graph, setGraph] = useState(Graph.empty()); - // A reference to the chart container to allowing zooming and editing - const chartRef = useRef(null) + const graphView = useRef(null) + // A reference to the query state to allow running the user query const queryState = useRef(null) @@ -149,54 +72,8 @@ export default function Page() { const newGraph = Graph.create(state.graphName, json.result) setGraph(newGraph) - const chart = chartRef.current - if (chart) { - chart.elements().remove() - chart.add(newGraph.Elements) - chart.elements().layout(LAYOUT).run(); - } - } - // Send the user query to the server to expand a node - async function onFetchNode(node: NodeDataDefinition) { - const result = await fetch(`/api/graph/${graph.Id}/${node.id}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - } - }) - - if (result.status >= 300) { - toast({ - title: "Error", - description: result.text(), - }) - if (result.status >= 400 && result.status < 500) { - signOut({ callbackUrl: '/login' }) - } - return [] as ElementDefinition[] - } - - const json = await result.json() - const elements = graph.extend(json.result) - return elements - } - - const onCategoryClick = (category: Category) => { - const chart = chartRef.current - if (chart) { - const elements = chart.elements(`node[category = "${category.name}"]`) - - // eslint-disable-next-line no-param-reassign - category.show = !category.show - - if (category.show) { - elements.style({ display: 'element' }) - } else { - elements.style({ display: 'none' }) - } - chart.elements().layout(LAYOUT).run(); - } + graphView.current?.expand(newGraph.Elements) } return ( @@ -214,36 +91,7 @@ export default function Page() { -
-
- - -
- { - chartRef.current = cy - - // Make sure no previous listeners are attached - cy.removeAllListeners(); - - // Listen to the click event on nodes for expanding the node - cy.on('dbltap', 'node', async (evt) => { - const node: Node = evt.target.json().data; - const elements = await onFetchNode(node); - - // adjust entire graph. - if (elements.length > 0) { - cy.add(elements); - cy.elements().layout(LAYOUT).run(); - } - }); - }} - stylesheet={getStyle(darkmode)} - elements={graph.Elements} - layout={LAYOUT} - className="w-full grow" - /> -
+
}