diff --git a/api/app.py b/api/app.py index 4ba519fab..d0117b4a8 100644 --- a/api/app.py +++ b/api/app.py @@ -1,20 +1,20 @@ +import json import os +from io import StringIO +import networkx as nx from datacitekit.extractors import extract_doi from datacitekit.related_works import get_full_corpus_doi_attributes from datacitekit.resource_type_graph import RelatedWorkReports from flask import Flask, jsonify +from pyvis.network import Network DOI_API = os.getenv("DATACITE_API_URL", "https://api.stage.datacite.org/dois/") app = Flask(__name__) -@app.route("/api/doi/related-graph/", methods=["GET"]) -def related_works(doi): +def get_graph_data(doi): doi = extract_doi(doi) - if not doi: - return jsonify({"error": "Does not match DOI format"}), 400 - full_doi_attributes = get_full_corpus_doi_attributes( doi_query=doi, parser=RelatedWorkReports.parser, api_url=DOI_API ) @@ -25,8 +25,174 @@ def related_works(doi): non_zero_nodes = [agg for agg in report.aggregate_counts if agg["count"] > 0] graph = {"nodes": non_zero_nodes, "links": report.type_connection_report} + return graph + + +@app.route("/api/doi/related-graph/", methods=["GET"]) +def related_works(doi): + doi = extract_doi(doi) + if not doi: + return jsonify({"error": "Does not match DOI format"}), 400 + + graph = get_graph_data(doi) + return jsonify(graph) +@app.route("/api/doi/network-view/", methods=["GET"]) +def network_view(doi): + doi = extract_doi(doi) + if not doi: + return jsonify({"error": "Does not match DOI format"}), 400 + network = get_network(doi) + return network.generate_html() + + +@app.route("/api/doi/network-graph/", methods=["GET"]) +def network_graph(doi): + network = get_network(doi) + + nodes, edges, heading, height, width, options = network.get_network_data() + return jsonify( + { + "nodes": nodes, + "edges": edges, + "options": json.loads(options), + } + ) + + +def get_network(doi): + graph_data = get_graph_data(doi) + + # Define the color mapping + domain = [ + "Audiovisual", + "Book", + "Book Chapter", + "Collection", + "Computational Notebook", + "Conference Paper", + "Conference Proceeding", + "Data Paper", + "Dataset", + "Dissertation", + "Event", + "Image", + "Instrument", + "Interactive Resource", + "Journal", + "Journal Article", + "Model", + "Output Management Plan", + "Peer Review", + "Physical Object", + "Preprint", + "Project", + "Report", + "Service", + "Software", + "Sound", + "Standard", + "Study Registration", + "Text", + "Workflow", + "Other", + "People", + "Organizations", + ] + color_range = [ + "#AEC7E8", + "#FF7F0E", + "#FFBB78", + "#D62728", + "#FF9896", + "#9467BD", + "#C5B0D5", + "#8C564B", + "#1F77B4", + "#C49C94", + "#E377C2", + "#F7B6D2", + "#35424A", + "#7F7F7F", + "#C7C7C7", + "#BCBD22", + "#DBDB8D", + "#17BECF", + "#9EDAE5", + "#3182BD", + "#6BAED6", + "#AB8DF8", + "#9ECAE1", + "#C6DBEF", + "#E6550D", + "#FD8D3C", + "#FDAE6B", + "#6DBB5E", + "#FDD0A2", + "#9F4639", + "#C59088", + "#A83", + "#FAD", + ] + + # Define the color mapping + + color_map = dict(zip(domain, color_range)) + + G = nx.DiGraph() + net = Network( + height="750px", + width="100%", + notebook=False, + ) + + # Add nodes with their attributes + for node in graph_data["nodes"]: + G.add_node(node["title"], count=node["count"]) + + # Add edges with their attributes + for link in graph_data["links"]: + G.add_edge(link["source"], link["target"], count=link["count"]) + + # Create a PyVis network + net = Network(notebook=True, directed=True, cdn_resources="remote") + # Load the NetworkX graph into the PyVis network + net.from_nx(G) + # Customize the appearance of nodes and edges + for node in net.nodes: + node["label"] = f"{node['id']}\n {G.nodes[node['id']]['count']}" + node["value"] = G.nodes[node["id"]]["count"] + # Set color based on type mapping. Default to grey if no match + node["color"] = color_map.get(node["id"], "#999999") + + for edge in net.edges: + edge["title"] = f"{edge['from']} -> {edge['to']}: {edge['count']}" + edge["width"] = edge["count"] * 0.5 + + net.set_options( + """ + var options = { + "nodes": { + "labelHighlightBold": true, + "font": { + "align": "center" + } + }, + "interaction": { + "zoomView": false, + "dragView": true, + "multiselect": false, + "navigationButtons": true, + "hoverConnectedEdges": true + } + } + """ + ) + + return net + + if __name__ == "__main__": app.run(debug=False) diff --git a/api/requirements.txt b/api/requirements.txt index 80de29d16..4cb721148 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,2 +1,3 @@ Flask https://github.com/datacite/datacitekit/archive/main.zip +pyvis diff --git a/package.json b/package.json index 93ff448b8..b78c059b3 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "vega": "^5.30.0", "vega-embed": "^6.25.0", "vega-lite": "^5.19.0", + "vis-network": "^9.1.9", "wait-on": "^5.0.1" }, "devDependencies": { diff --git a/src/app/doi.org/[...doi]/NetworkGraph.tsx b/src/app/doi.org/[...doi]/NetworkGraph.tsx new file mode 100644 index 000000000..ea8507dd9 --- /dev/null +++ b/src/app/doi.org/[...doi]/NetworkGraph.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { Col, Row } from "src/components/Layout"; +import { getRelatedNetworkGraph, GraphData } from 'src/data/queries/relatedWorks' +import VisGraph from 'src/components/VisGraph/VisGraph' +// import Graph from 'react-graph-vis' +// import ForceDirectedGraph from 'src/components/ForceDirectedGraph/ForceDirectedGraph' +import EmptyChart from 'src/components/EmptyChart/EmptyChart' +import styles from "./RelatedAggregateGraph.module.scss" + +interface Props { + doi: string +} + +export default async function NetworkGraph( {doi}: Props) { + const data :GraphData = await getRelatedNetworkGraph(doi) + const gData = { + 'nodes':data.nodes, + 'edges':data.edges, + } + const titleText = "Connections" + const emptyTitleText = "No connections" + const helpText = 'The “relatedIdentifier” and “resourceTypeGeneral” fields in the metadata of the primary DOI and related work DOIs were used to generate this graph.' + const explanitoryText="The network graph visualizes the connections between different work types. It shows the number of instances of each work type, and hovering over a connection reveals the number of links between any two types." + const graphExists = data.nodes.length >0; + const innerGraph = (graphExists) ? + : + + return ( + +
+ {innerGraph} + { graphExists && +

{explanitoryText}

+ } +
+ +
+ ) +} diff --git a/src/app/doi.org/[...doi]/page.tsx b/src/app/doi.org/[...doi]/page.tsx index e9cfb0ba0..b36e19a0a 100644 --- a/src/app/doi.org/[...doi]/page.tsx +++ b/src/app/doi.org/[...doi]/page.tsx @@ -4,14 +4,14 @@ import { redirect, notFound } from 'next/navigation' import Script from 'next/script' import truncate from 'lodash/truncate' -import { rorFromUrl, isProject, isDMP, isAwardGrant } from 'src/utils/helpers' +import { rorFromUrl, isProject, isDMP } from 'src/utils/helpers' import apolloClient from 'src/utils/apolloClient/apolloClient' import { CROSSREF_FUNDER_GQL } from 'src/data/queries/crossrefFunderQuery' import Content from './Content' import { DOI_METADATA_QUERY, MetadataQueryData, MetadataQueryVar } from 'src/data/queries/doiQuery' import RelatedContent from './RelatedContent' -import RelatedAggregateGraph from './RelatedAggregateGraph' +import NetworkGraph from './NetworkGraph' import Loading from 'src/components/Loading/Loading' @@ -145,11 +145,11 @@ export default async function Page({ params, searchParams }: Props) { if (!data) notFound() const showSankey = isDMP(data.work) || isProject(data.work) - const showGraph = isDMP(data.work) || isProject(data.work) || isAwardGrant(data.work) + const showGraph = true const projectGraph = showGraph ? - + : '' diff --git a/src/components/VisGraph/VisGraph.module.scss b/src/components/VisGraph/VisGraph.module.scss new file mode 100644 index 000000000..35129f64e --- /dev/null +++ b/src/components/VisGraph/VisGraph.module.scss @@ -0,0 +1,11 @@ +.chartTitle { + font-size: 21px; + font-weight: 700; + + color: #34495E; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} diff --git a/src/components/VisGraph/VisGraph.tsx b/src/components/VisGraph/VisGraph.tsx new file mode 100644 index 000000000..d2e926564 --- /dev/null +++ b/src/components/VisGraph/VisGraph.tsx @@ -0,0 +1,87 @@ +'use client' +import React, { useRef, useEffect } from 'react' +import HelpIcon from '../HelpIcon/HelpIcon' +import {Node, Edge, Options} from 'src/data/queries/relatedWorks' +import {DataSet, Network} from 'vis-network/standalone' +import styles from './VisGraph.module.scss' + +interface VisNetworkProps { + nodes: Node[]; + edges: Edge[]; + options?: Options; +} + +const VisNetwork: React.FC = ({ nodes, edges, options }) => { + const networkRef = useRef(null); + + useEffect(() => { + const container = networkRef.current; + + if (container) { + const data = { + nodes: new DataSet(nodes), + edges: new DataSet(edges), + }; + + // Network options will be overridden if provided + const networkOptions: Options = options || { + autoResize: true, + height: '100%', + width: '100%', + interaction: { + zoomView: false, + dragView: true, + dragNodes: true, + hover: true, + hoverConnectedEdges: true, + selectConnectedEdges: true, + navigationButtons: true, + }, + physics: { + enabled: true, + }, + }; + + const network = new Network(container, data, networkOptions); + + return () => { + network.destroy(); + }; + } + }, [nodes, edges, options]); + + return
; +}; + + + +type VisGraphData = { + nodes: Node[] + edges: Edge[] +} +type Props = { + titleText: string | string[] + graph: VisGraphData + options?: Options + tooltipText?: string +} + +const VisGraph: React.FunctionComponent = ({ titleText, graph, options, tooltipText }) => { + return ( +
+
+
+ {titleText} + {tooltipText && } +
+ +
+
+ ) +} + +export default VisGraph diff --git a/src/data/queries/relatedWorks.ts b/src/data/queries/relatedWorks.ts index be7b98fda..755f4e9e1 100644 --- a/src/data/queries/relatedWorks.ts +++ b/src/data/queries/relatedWorks.ts @@ -38,3 +38,46 @@ export async function getRelatedWorksGraph( return nullGraph } } + +export interface Node { + id: string + label: string + [key: string]: any // allow for additional properties +} + +export interface Edge { + id?: number | string + from: string + to: string + [key: string]: any // allow for additional properties +} + +export interface Options { + [key: string]: any // allow for additional options +} + +export interface GraphData { + nodes: Node[] + edges: Edge[] + options?: Options +} + +export async function getRelatedNetworkGraph(doi: string): Promise { + const nullGraph = { + nodes: [], + edges: [], + options: {} + } + const baseUrl = getBaseUrl() + try { + const response = await fetch(`${baseUrl}/api/doi/network-graph/${doi}`) + if (!response.ok) { + return nullGraph + } + return response.json() + } catch (error) { + // Non-critical data fetch. If it fails, we return the nullGraph + console.error(error) + return nullGraph + } +} diff --git a/yarn.lock b/yarn.lock index bc4ab6b5b..8d502d426 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9325,6 +9325,11 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +vis-network@^9.1.9: + version "9.1.9" + resolved "https://registry.yarnpkg.com/vis-network/-/vis-network-9.1.9.tgz#8159b34e7c1570150fcc9296213d2b88ed169394" + integrity sha512-Ft+hLBVyiLstVYSb69Q1OIQeh3FeUxHJn0WdFcq+BFPqs+Vq1ibMi2sb//cxgq1CP7PH4yOXnHxEH/B2VzpZYA== + vm-browserify@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"