diff --git a/app/api/graph/[graph]/[node]/route.ts b/app/api/graph/[graph]/[node]/route.ts new file mode 100644 index 00000000..62803529 --- /dev/null +++ b/app/api/graph/[graph]/[node]/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Graph } from 'falkordb'; +import { getServerSession } from "next-auth/next"; +import authOptions, { connections } from "../../../auth/[...nextauth]/options"; + +export async function GET(request: NextRequest, { params }: { params: { graph: string, node: string } }) { + + const session = await getServerSession(authOptions) + const id = session?.user?.id + if (!id) { + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }) + } + + let client = connections.get(id) + if (!client) { + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }) + } + + const nodeId = parseInt(params.node); + const graphId = params.graph; + + const graph = new Graph(client, graphId); + + // Get node's neighbors + const query = `MATCH (src)-[e]-(n) + WHERE ID(src) = $nodeId + RETURN e, n`; + + try { + let result: any = await graph.query(query, { params: { nodeId: nodeId } }); + return NextResponse.json({ result: result }, { status: 200 }) + } catch (err: any) { + return NextResponse.json({ message: err.message }, { status: 400 }) + } +} diff --git a/app/graph/labels.tsx b/app/graph/labels.tsx new file mode 100644 index 00000000..ea51a360 --- /dev/null +++ b/app/graph/labels.tsx @@ -0,0 +1,36 @@ +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Category, getCategoryColors } from "./model"; +import { cn } from "@/lib/utils"; +import { MinusCircle, Palette, PlusCircle } from "lucide-react"; +import { useState } from "react"; + +export function Labels(params: { categories: Category[], className?: string, onClick: (category: Category) => void }) { + + // fake stae to force reload + const [reload, setReload] = useState(false) + + return ( +
+ + {params.categories.map((category) => { + return ( + + { + params.onClick(category) + setReload(!reload) + }} + > + { category.show ? : } + + +

{category.name}

+
+
+ ) + })} +
+
+ ) +} \ No newline at end of file diff --git a/app/graph/model.ts b/app/graph/model.ts index c83989d3..6fd8b30f 100644 --- a/app/graph/model.ts +++ b/app/graph/model.ts @@ -1,7 +1,8 @@ export interface Category { + index: number, name: string, - index: number + show: boolean, } export interface Node { @@ -19,18 +20,21 @@ export interface Edge { } const COLORS = [ - "#ff0000", // red - "#0000ff", // blue - "#00ff00", // green - "#ffff00", // yellow - "#ff00ff", // magenta - "#00ffff", // cyan - "#ffffff", // white - "#000000", // black - "#800000", // maroon - "#808000", // olive + "red", + "blue", + "green", + "yellow", + "purple", + "fuchsia", + "aqua", + "gray", + "orange", ] +export function getCategoryColors(index: number): string { + return index(), new Map(), new Map()) } - public static create(results: any): Graph { + public static create(id: string, results: any): Graph { let graph = Graph.empty() graph.extend(results) - graph.id = results.id + graph.id = id return graph } @@ -130,8 +134,9 @@ export class Graph { // check if category already exists in categories let category = this.categoriesMap.get(cell.labels[0]) if (!category) { - category = { name: cell.labels[0], index: this.categoriesMap.size } + category = { name: cell.labels[0], index: this.categoriesMap.size, show: true } this.categoriesMap.set(category.name, category) + this.categories.push(category) } // check if node already exists in nodes or fake node was created @@ -141,7 +146,7 @@ export class Graph { id: cell.id.toString(), name: cell.id.toString(), value: JSON.stringify(cell), - color: category.index < COLORS.length ? COLORS[category.index] : COLORS[0] + color: getCategoryColors(category.index) } this.nodesMap.set(cell.id, node) this.elements.push({data:node}) diff --git a/app/graph/page.tsx b/app/graph/page.tsx index 19b41158..4f41440e 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -8,10 +8,11 @@ import CytoscapeComponent from 'react-cytoscapejs' import { useRef, useState } from "react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { XCircle, ZoomIn, ZoomOut } from "lucide-react"; -import { Node, Graph } from "./model"; +import { Node, Graph, Category, getCategoryColors } from "./model"; import { signOut } from "next-auth/react"; import { Toolbar } from "./toolbar"; import { Query, QueryState } from "./query"; +import { Labels } from "./labels"; // The stylesheet for the graph const STYLESHEET: cytoscape.Stylesheet[] = [ @@ -62,7 +63,7 @@ export default function Page() { const chartRef = useRef(null) // A reference to the query state to allow running the user query - const queryState = useRef(null) + const queryState = useRef(null) function prepareArg(arg: string): string { return encodeURIComponent(arg.trim()) @@ -71,7 +72,7 @@ export default function Page() { async function runQuery(event: any) { event.preventDefault(); let state = queryState.current; - if(!state){ + if (!state) { return } @@ -95,22 +96,67 @@ export default function Page() { } let json = await result.json() - let newGraph = Graph.create(json.result) + let newGraph = Graph.create(state.graphName, json.result) setGraph(newGraph) let chart = chartRef.current - if(chart){ + 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: Node) { + let 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: '/' }) + } + return [] as any[] + } + + let json = await result.json() + let elements = graph.extend(json.result) + return elements + } + + function onCategoryClick(category: Category) { + let chart = chartRef.current + if (chart) { + let color = getCategoryColors(category.index) + let elements = chart.elements(`[color = "${color}"]`) + + category.show = !category.show + + if (category.show) { + elements.style({ display: 'element' }) + } else { + elements.style({ display: 'none' }) + } + chart.elements().layout(LAYOUT).run(); + } + } + return (
queryState.current = state} />
- +
+ + +
{ chartRef.current = cy @@ -119,10 +165,15 @@ export default function Page() { cy.removeAllListeners(); // Listen to the click event on nodes for expanding the node - cy.on('dbltap', 'node', function (evt) { + cy.on('dbltap', 'node', async function (evt) { var node: Node = evt.target.json().data; - // TODO: - // parmas.onFetchNode(node); + let elements = await onFetchNode(node); + + // adjust entire graph. + if (elements.length > 0) { + cy.add(elements); + cy.elements().layout(LAYOUT).run(); + } }); }} stylesheet={STYLESHEET} diff --git a/app/graph/toolbar.tsx b/app/graph/toolbar.tsx index 662ad70e..34b9db0a 100644 --- a/app/graph/toolbar.tsx +++ b/app/graph/toolbar.tsx @@ -1,19 +1,20 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { XCircle, ZoomIn, ZoomOut } from "lucide-react"; +import { cn } from "@/lib/utils" -export function Toolbar(parmas: { - chartRef: React.RefObject, +export function Toolbar(params: { + chartRef: React.RefObject, className?: string }) { function handleZoomClick(changefactor: number) { - let chart = parmas.chartRef.current + let chart = params.chartRef.current if (chart) { chart.zoom(chart.zoom() * changefactor) } } function handleCenterClick() { - let chart = parmas.chartRef.current + let chart = params.chartRef.current if (chart) { chart.fit() chart.center() @@ -21,10 +22,10 @@ export function Toolbar(parmas: { } return ( -
+
- handleZoomClick(1.1)}> + handleZoomClick(1.1)}> diff --git a/tailwind.config.js b/tailwind.config.js index 7cb7e37a..57c950f4 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -7,6 +7,13 @@ module.exports = { './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}', ], + purge: { + safelist: [ + { + pattern: /^bg-/, + }, + ] + }, prefix: "", theme: { container: {