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
121 changes: 113 additions & 8 deletions graphconstructor/graph.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import json
from dataclasses import dataclass
from typing import Iterable, Literal, Optional, Sequence
import networkx as nx
import numpy as np
import pandas as pd
import scipy.sparse as sp
Expand Down Expand Up @@ -238,10 +240,6 @@ def from_graphml(
- Graph-level attributes 'mode', 'directed', 'weighted',
'ignore_selfloops', and 'keep_explicit_zeros' are honored if present.
"""
try:
import networkx as nx # lazy import
except Exception as e:
raise ImportError("networkx is required for from_graphml().") from e

if default_mode not in {"distance", "similarity"}:
raise ValueError(
Expand Down Expand Up @@ -495,10 +493,6 @@ def to_graphml(self, path, *, include_graph_attrs: bool = True) -> None:
'mode', 'directed', 'weighted', 'ignore_selfloops',
and 'keep_explicit_zeros' in the GraphML file.
"""
try:
import networkx as nx # lazy import
except Exception as e:
raise ImportError("networkx is required for to_graphml().") from e

G_nx = self.to_networkx()

Expand All @@ -512,6 +506,117 @@ def to_graphml(self, path, *, include_graph_attrs: bool = True) -> None:

nx.write_graphml(G_nx, path)

def to_cytoscape(
self,
path=None,
*,
include_graph_attrs: bool = True,
node_id_col: str | None = None,
node_label_col: str | None = "name",
edge_weight_attr: str = "weight",
) -> dict:
"""
Export the graph in Cytoscape.js JSON format.

Parameters
----------
path
Optional output path. If provided, the Cytoscape JSON is written to
this file. If None, the JSON-compatible dictionary is returned only.
include_graph_attrs
If True, include graph-level metadata under ``data``.
node_id_col
Optional metadata column to use as Cytoscape node IDs.
If None, integer node indices are used.
node_label_col
Optional metadata column to use as node labels. By default, uses
``"name"`` if present. Set to None to omit labels.
edge_weight_attr
Name of the edge weight attribute. Defaults to ``"weight"``.
"""

if node_id_col is not None:
if self.meta is None or node_id_col not in self.meta.columns:
raise KeyError(f"Column '{node_id_col}' not found in metadata.")
node_ids = self.meta[node_id_col].astype(str).tolist()
else:
node_ids = [str(i) for i in range(self.n_nodes)]

if len(set(node_ids)) != len(node_ids):
raise ValueError("Cytoscape node IDs must be unique.")

nodes = []
for i, node_id in enumerate(node_ids):
data = {
"id": node_id,
"index": int(i),
}

if (
node_label_col is not None
and self.meta is not None
and node_label_col in self.meta.columns
):
data["label"] = self.meta.iloc[i][node_label_col]

if self.meta is not None:
for col in self.meta.columns:
value = self.meta.iloc[i][col]
if pd.isna(value):
value = None
data[col] = value

nodes.append({"data": data})

coo = self.adj.tocoo()

if self.directed:
edge_mask = coo.row != coo.col
else:
# Store each undirected edge only once.
edge_mask = coo.row < coo.col

rows = coo.row[edge_mask]
cols = coo.col[edge_mask]
values = coo.data[edge_mask]

edges = []
for edge_idx, (src, dst, value) in enumerate(zip(rows, cols, values)):
data = {
"id": f"e{edge_idx}",
"source": node_ids[int(src)],
"target": node_ids[int(dst)],
}

if self.weighted:
data[edge_weight_attr] = float(value)
else:
data[edge_weight_attr] = 1.0

edges.append({"data": data})

cytoscape = {
"elements": {
"nodes": nodes,
"edges": edges,
}
}

if include_graph_attrs:
cytoscape["data"] = {
"directed": bool(self.directed),
"weighted": bool(self.weighted),
"mode": self.mode,
"ignore_selfloops": self.ignore_selfloops,
"keep_explicit_zeros": self.keep_explicit_zeros,
}

if path is not None:
with open(path, "w", encoding="utf-8") as f:
json.dump(cytoscape, f, indent=2)

return cytoscape

# -------- Utilities --------
def copy(self) -> "Graph":
return Graph(
Expand Down
87 changes: 87 additions & 0 deletions tests/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,93 @@ def test_to_igraph_types_and_attributes():
assert "weight" in igG.es.attributes()


def test_to_cytoscape_returns_expected_nodes_edges_and_metadata():
A = _csr([0.5, 0.7], [0, 1], [1, 2], 3)
meta = pd.DataFrame({
"name": ["n0", "n1", "n2"],
"group": ["a", "b", "a"],
})
G = Graph.from_csr(
A,
directed=False,
weighted=True,
mode="similarity",
meta=meta,
)

cy = G.to_cytoscape()

assert set(cy.keys()) == {"data", "elements"}
assert cy["data"]["directed"] is False
assert cy["data"]["weighted"] is True
assert cy["data"]["mode"] == "similarity"

nodes = cy["elements"]["nodes"]
edges = cy["elements"]["edges"]

assert len(nodes) == 3
assert len(edges) == 2

assert nodes[0]["data"]["id"] == "0"
assert nodes[0]["data"]["index"] == 0
assert nodes[0]["data"]["label"] == "n0"
assert nodes[0]["data"]["name"] == "n0"
assert nodes[0]["data"]["group"] == "a"

edge_data = {tuple(sorted((e["data"]["source"], e["data"]["target"]))): e["data"] for e in edges}

assert set(edge_data) == {("0", "1"), ("1", "2")}
assert edge_data[("0", "1")]["weight"] == pytest.approx(0.5)
assert edge_data[("1", "2")]["weight"] == pytest.approx(0.7)


def test_to_cytoscape_writes_json_and_uses_custom_node_ids(tmp_path):
import json

A = _csr([1.0, 2.0], [0, 1], [1, 2], 3)
meta = pd.DataFrame({
"id": ["a", "b", "c"],
"name": ["node-a", "node-b", "node-c"],
})
G = Graph.from_csr(
A,
directed=True,
weighted=True,
mode="distance",
meta=meta,
)

path = tmp_path / "graph.cyjs"
returned = G.to_cytoscape(path, node_id_col="id")

with open(path, encoding="utf-8") as f:
loaded = json.load(f)

assert loaded == returned

nodes = loaded["elements"]["nodes"]
edges = loaded["elements"]["edges"]

assert [node["data"]["id"] for node in nodes] == ["a", "b", "c"]
assert [node["data"]["label"] for node in nodes] == ["node-a", "node-b", "node-c"]

assert len(edges) == 2
assert edges[0]["data"]["source"] == "a"
assert edges[0]["data"]["target"] == "b"
assert edges[0]["data"]["weight"] == pytest.approx(1.0)
assert edges[1]["data"]["source"] == "b"
assert edges[1]["data"]["target"] == "c"
assert edges[1]["data"]["weight"] == pytest.approx(2.0)


def test_to_cytoscape_rejects_duplicate_custom_node_ids():
A = _csr([1.0], [0], [1], 2)
meta = pd.DataFrame({"id": ["same", "same"]})
G = Graph.from_csr(A, directed=False, weighted=True, mode="distance", meta=meta)

with pytest.raises(ValueError, match="node IDs must be unique"):
G.to_cytoscape(node_id_col="id")


# ----------------- Distance/similarity conversion -----------------
def test_convert_mode_distance_to_similarity_and_back_dense(S_dense, meta_df):
Expand Down
Loading