"""Export the knowledge graph to HTML, Plotly HTML, JSON, graph-data.js, and GEXF."""
from __future__ import annotations
import json
import math
import shutil
from collections import defaultdict
from pathlib import Path
from typing import Any, cast
from importlib.resources import files as resource_files
import pyjson5
from .branding import APP_NAME, APP_VERSION
from .config import GraphVisibility, normalize_graph_visibility
from .engine import GraphEngine
from .graph_filters import filtered_engine
from .models import KnowledgeGraph
_NODE_COLORS: dict[str, str] = {
"Carcinogen": "#e05565",
"Enzyme": "#4f98a3",
"Gene": "#3d8b8b",
"Metabolite": "#e8945a",
"DNA_Adduct": "#a86fdf",
"Pathway": "#5591c7",
"Tissue": "#c2855a",
}
_NODE_SIZES: dict[str, int] = {
"Carcinogen": 26,
"Enzyme": 20,
"Gene": 18,
"Metabolite": 18,
"DNA_Adduct": 20,
"Pathway": 24,
"Tissue": 17,
}
_NODE_SYMBOLS: dict[str, str] = {
"Carcinogen": "diamond",
"Enzyme": "circle",
"Gene": "circle",
"Metabolite": "circle",
"DNA_Adduct": "square",
"Pathway": "hexagon",
"Tissue": "triangle-up",
}
_EDGE_COLORS: dict[str, str] = {
"ACTIVATES": "#e05565",
"DETOXIFIES": "#6daa45",
"TRANSPORTS": "#5591c7",
"FORMS_ADDUCT": "#a86fdf",
"REPAIRS": "#e8af34",
"PATHWAY": "#707a8a",
"EXPRESSED_IN": "#c2855a",
"INDUCES": "#d4a843",
"INHIBITS": "#8b4a6b",
"ENCODES": "#3d8b8b",
"CUSTOM": "#9ea9bd",
}
def _builtin_viewer_template() -> str:
"""Return a minimal interactive HTML viewer template.
This fallback keeps HTML export working when no checked-in D3 viewer bundle
is present in the repo. It intentionally uses only inline CSS/JS so the
exported file remains self-contained once ``GRAPH_DATA`` is embedded.
"""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{APP_NAME} Viewer</title>
<style>
:root {{
--bg: #08111f;
--panel: rgba(8, 17, 31, 0.82);
--panel-border: rgba(142, 176, 205, 0.22);
--text: #e6edf7;
--muted: #95a6bc;
--accent: #76c3ff;
--node-stroke: rgba(8, 17, 31, 0.88);
--edge: rgba(159, 178, 199, 0.42);
--dim: 0.16;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at top, rgba(50, 110, 167, 0.28), transparent 42%),
linear-gradient(180deg, #0d1b2a 0%, #08111f 100%);
color: var(--text);
min-height: 100vh;
}}
.app {{
max-width: 1600px;
margin: 0 auto;
padding: 28px;
}}
.header {{
display: flex;
justify-content: space-between;
gap: 16px;
align-items: end;
margin-bottom: 18px;
}}
.title {{
margin: 0;
font-size: 1.8rem;
letter-spacing: 0.02em;
}}
.subtitle {{
margin: 6px 0 0;
color: var(--muted);
max-width: 48rem;
line-height: 1.5;
}}
.toolbar {{
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 18px;
}}
.toolbar input {{
min-width: 260px;
flex: 1 1 320px;
border: 1px solid var(--panel-border);
background: rgba(7, 15, 28, 0.84);
color: var(--text);
border-radius: 12px;
padding: 12px 14px;
outline: none;
}}
.toolbar button {{
border: 1px solid var(--panel-border);
background: rgba(11, 25, 44, 0.84);
color: var(--text);
border-radius: 12px;
padding: 12px 14px;
cursor: pointer;
}}
.toolbar button:hover {{
border-color: rgba(118, 195, 255, 0.48);
}}
.summary {{
color: var(--muted);
font-size: 0.95rem;
white-space: nowrap;
}}
.layout {{
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 18px;
}}
.card {{
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 18px;
backdrop-filter: blur(8px);
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.22);
overflow: hidden;
}}
.graph-wrap {{
min-height: 760px;
padding: 10px;
}}
#graph {{
width: 100%;
height: 760px;
display: block;
background:
radial-gradient(circle at center, rgba(118, 195, 255, 0.08), transparent 46%),
linear-gradient(180deg, rgba(7, 15, 28, 0.7), rgba(7, 15, 28, 0.92));
border-radius: 14px;
}}
.detail {{
padding: 18px;
}}
.detail h2 {{
margin: 0 0 8px;
font-size: 1.15rem;
}}
.detail p {{
margin: 0 0 12px;
color: var(--muted);
line-height: 1.5;
}}
.detail dl {{
margin: 0;
display: grid;
grid-template-columns: 110px minmax(0, 1fr);
gap: 8px 10px;
font-size: 0.92rem;
}}
.detail dt {{
color: var(--muted);
}}
.detail dd {{
margin: 0;
overflow-wrap: anywhere;
}}
.legend {{
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}}
.chip {{
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--panel-border);
border-radius: 999px;
padding: 6px 10px;
color: var(--muted);
font-size: 0.82rem;
}}
.chip::before {{
content: "";
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--chip-color, var(--accent));
}}
.edge {{
stroke: var(--edge-color, var(--edge));
stroke-width: 2.2;
opacity: 0.85;
}}
.edge.dimmed,
.node-group.dimmed,
.label.dimmed {{
opacity: var(--dim);
}}
.node {{
stroke: var(--node-stroke);
stroke-width: 2.5;
cursor: grab;
}}
.node:active {{
cursor: grabbing;
}}
.node.selected {{
stroke: #ffffff;
stroke-width: 3;
}}
.label {{
fill: var(--text);
font-size: 12px;
font-weight: 600;
pointer-events: none;
text-anchor: middle;
}}
.footer {{
margin-top: 14px;
color: var(--muted);
font-size: 0.8rem;
}}
@media (max-width: 1080px) {{
.layout {{
grid-template-columns: 1fr;
}}
#graph {{
height: 560px;
}}
}}
</style>
<script src="./graph-data.js"></script>
</head>
<body>
<div class="app">
<div class="header">
<div>
<h1 class="title">{APP_NAME} Graph Viewer</h1>
<p class="subtitle">Self-contained fallback viewer for exported knowledge graphs. Search, click, and drag nodes to inspect the graph without a checked-in viewer bundle.</p>
</div>
<div class="summary" id="summary"></div>
</div>
<div class="toolbar">
<input id="search" type="search" placeholder="Search nodes by id, label, type, detail, or source..." />
<button id="reset">Reset Focus</button>
</div>
<div class="layout">
<div class="card graph-wrap">
<svg id="graph" viewBox="0 0 1200 760" role="img" aria-label="Knowledge graph"></svg>
</div>
<aside class="card detail">
<h2 id="detail-title">Graph Overview</h2>
<p id="detail-text">Click a node to inspect its metadata. Use search to highlight matching nodes and their immediate neighbors.</p>
<dl id="detail-meta"></dl>
<div class="legend" id="legend"></div>
</aside>
</div>
<div class="footer">Generated by {APP_NAME} v{APP_VERSION}</div>
</div>
<script>
(() => {{
const graphData = typeof GRAPH_DATA !== "undefined" ? GRAPH_DATA : {{ nodes: [], edges: [] }};
const svg = document.getElementById("graph");
const summary = document.getElementById("summary");
const search = document.getElementById("search");
const reset = document.getElementById("reset");
const detailTitle = document.getElementById("detail-title");
const detailText = document.getElementById("detail-text");
const detailMeta = document.getElementById("detail-meta");
const legend = document.getElementById("legend");
const width = 1200;
const height = 760;
const ns = "http://www.w3.org/2000/svg";
const colorByType = {{
Carcinogen: "#e05565",
Enzyme: "#4f98a3",
Gene: "#3d8b8b",
Metabolite: "#e8945a",
DNA_Adduct: "#a86fdf",
Pathway: "#5591c7",
Tissue: "#c2855a",
}};
const colorByEdge = {{
ACTIVATES: "#e05565",
DETOXIFIES: "#6daa45",
TRANSPORTS: "#5591c7",
FORMS_ADDUCT: "#a86fdf",
REPAIRS: "#e8af34",
PATHWAY: "#707a8a",
EXPRESSED_IN: "#c2855a",
INDUCES: "#d4a843",
INHIBITS: "#8b4a6b",
ENCODES: "#3d8b8b",
CUSTOM: "#9ea9bd",
}};
const nodes = (graphData.nodes || []).map((node, index, arr) => {{
const angle = (Math.PI * 2 * index) / Math.max(arr.length, 1);
const radius = Math.min(width, height) * 0.34 + (index % 5) * 10;
return {{
...node,
x: width / 2 + Math.cos(angle) * radius,
y: height / 2 + Math.sin(angle) * radius,
}};
}});
const edges = (graphData.edges || []).map((edge) => ({{ ...edge }}));
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const neighbors = new Map(nodes.map((node) => [node.id, new Set()]));
edges.forEach((edge) => {{
neighbors.get(edge.source)?.add(edge.target);
neighbors.get(edge.target)?.add(edge.source);
}});
let searchTerm = "";
let selectedId = null;
let draggingId = null;
function svgPoint(evt) {{
const point = svg.createSVGPoint();
point.x = evt.clientX;
point.y = evt.clientY;
return point.matrixTransform(svg.getScreenCTM().inverse());
}}
function createEl(tag, attrs = {{}}, text = "") {{
const el = document.createElementNS(ns, tag);
Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, String(value)));
if (text) {{
el.textContent = text;
}}
return el;
}}
function clear(el) {{
while (el.firstChild) {{
el.removeChild(el.firstChild);
}}
}}
function nodeMatches(node, term) {{
if (!term) {{
return true;
}}
const haystack = [
node.id,
node.label,
node.type,
node.detail,
node.group,
node.source_db,
node.match_status,
node.origin,
].filter(Boolean).join(" ").toLowerCase();
return haystack.includes(term);
}}
function highlightedIds() {{
const term = searchTerm.trim().toLowerCase();
if (!term) {{
return null;
}}
const matches = new Set(nodes.filter((node) => nodeMatches(node, term)).map((node) => node.id));
for (const nodeId of Array.from(matches)) {{
for (const neighbor of neighbors.get(nodeId) || []) {{
matches.add(neighbor);
}}
}}
return matches;
}}
function setDetails(node) {{
clear(detailMeta);
if (!node) {{
detailTitle.textContent = "Graph Overview";
detailText.textContent = "Click a node to inspect its metadata. Use search to highlight matching nodes and their immediate neighbors.";
return;
}}
detailTitle.textContent = node.label || node.id;
detailText.textContent = node.detail || "No detail available for this node.";
const fields = [
["ID", node.id],
["Type", node.type],
["Origin", node.origin],
["Match", node.match_status],
["Group", node.group],
["IARC", node.iarc],
["Phase", node.phase],
["Role", node.role],
["Reactivity", node.reactivity],
["Tissue", node.tissue],
["Variant", node.variant],
["Phenotype", node.phenotype],
["Source", node.source_db],
["Evidence", node.evidence],
["PMID", node.pmid],
["Canonical", node.canonical_label || node.canonical_id],
].filter(([, value]) => value !== undefined && value !== null && value !== "");
for (const [label, value] of fields) {{
const dt = document.createElement("dt");
dt.textContent = label;
const dd = document.createElement("dd");
dd.textContent = String(value);
detailMeta.appendChild(dt);
detailMeta.appendChild(dd);
}}
}}
function renderLegend() {{
clear(legend);
const usedTypes = Array.from(new Set(nodes.map((node) => node.type))).sort();
for (const type of usedTypes) {{
const chip = document.createElement("span");
chip.className = "chip";
chip.style.setProperty("--chip-color", colorByType[type] || "#76c3ff");
chip.textContent = type;
legend.appendChild(chip);
}}
}}
function render() {{
clear(svg);
const defs = createEl("defs");
const marker = createEl("marker", {{
id: "arrow",
markerWidth: 10,
markerHeight: 10,
refX: 8,
refY: 3,
orient: "auto",
markerUnits: "strokeWidth",
}});
marker.appendChild(createEl("path", {{ d: "M0,0 L0,6 L9,3 z", fill: "#8ea4bb" }}));
defs.appendChild(marker);
svg.appendChild(defs);
const focusIds = highlightedIds();
summary.textContent = `${{nodes.length}} nodes, ${{edges.length}} edges`;
edges.forEach((edge) => {{
const source = nodeById.get(edge.source);
const target = nodeById.get(edge.target);
if (!source || !target) {{
return;
}}
const line = createEl("line", {{
x1: source.x,
y1: source.y,
x2: target.x,
y2: target.y,
class: "edge",
"marker-end": "url(#arrow)",
}});
line.style.setProperty("--edge-color", colorByEdge[edge.type] || "#8ea4bb");
if (focusIds && (!focusIds.has(edge.source) || !focusIds.has(edge.target))) {{
line.classList.add("dimmed");
}}
const title = createEl("title", {{}}, `${{edge.type}}: ${{edge.source}} -> ${{edge.target}}`);
line.appendChild(title);
svg.appendChild(line);
}});
nodes.forEach((node) => {{
const group = createEl("g", {{ class: "node-group" }});
if (focusIds && !focusIds.has(node.id)) {{
group.classList.add("dimmed");
}}
const circle = createEl("circle", {{
cx: node.x,
cy: node.y,
r: selectedId === node.id ? 18 : 15,
class: "node" + (selectedId === node.id ? " selected" : ""),
fill: colorByType[node.type] || "#76c3ff",
}});
circle.addEventListener("mousedown", (evt) => {{
draggingId = node.id;
evt.preventDefault();
}});
circle.addEventListener("click", () => {{
selectedId = node.id;
setDetails(node);
render();
}});
circle.appendChild(createEl("title", {{}}, `${{node.label || node.id}} (${{node.type}})`));
const label = createEl("text", {{
x: node.x,
y: node.y + 30,
class: "label",
}}, node.label || node.id);
if (focusIds && !focusIds.has(node.id)) {{
label.classList.add("dimmed");
}}
group.appendChild(circle);
group.appendChild(label);
svg.appendChild(group);
}});
}}
svg.addEventListener("mousemove", (evt) => {{
if (!draggingId) {{
return;
}}
const node = nodeById.get(draggingId);
if (!node) {{
return;
}}
const pt = svgPoint(evt);
node.x = Math.max(24, Math.min(width - 24, pt.x));
node.y = Math.max(24, Math.min(height - 24, pt.y));
render();
}});
window.addEventListener("mouseup", () => {{
draggingId = null;
}});
search.addEventListener("input", () => {{
searchTerm = search.value;
render();
}});
reset.addEventListener("click", () => {{
searchTerm = "";
selectedId = null;
search.value = "";
setDetails(null);
render();
}});
renderLegend();
setDetails(null);
render();
}})();
</script>
</body>
</html>
"""
def _extract_graph_data_object(raw: str) -> str:
"""Extract the object assigned to ``GRAPH_DATA`` from JS or HTML text."""
marker = "GRAPH_DATA"
marker_pos = raw.find(marker)
if marker_pos == -1:
start = raw.find("{")
end = raw.rfind("}")
if start == -1 or end == -1 or start >= end:
raise ValueError("Could not locate graph data object")
return raw[start:end + 1]
start = raw.find("{", marker_pos)
if start == -1:
raise ValueError("Could not locate start of GRAPH_DATA object")
depth = 0
in_string = False
string_char = ""
escape = False
for idx in range(start, len(raw)):
ch = raw[idx]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == string_char:
in_string = False
else:
if ch in ('"', "'", "`"):
in_string = True
string_char = ch
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return raw[start:idx + 1]
raise ValueError("Could not find the end of the GRAPH_DATA object")
[docs]
def parse_graph_data_text(raw: str) -> KnowledgeGraph:
"""Parse JS/HTML text that contains a ``GRAPH_DATA`` object assignment.
Uses a JSON5 parser to handle unquoted keys, single-line comments,
and trailing commas that are valid in JavaScript but not in JSON.
"""
js_obj = _extract_graph_data_object(raw)
data = pyjson5.loads(js_obj)
return KnowledgeGraph(**data)
[docs]
def parse_graph_data_js(path: str | Path) -> KnowledgeGraph:
"""Parse a ``graph-data.js`` file."""
raw = Path(path).read_text(encoding="utf-8")
return parse_graph_data_text(raw)
[docs]
def parse_graph_html(path: str | Path) -> KnowledgeGraph:
"""Parse a standalone HTML graph export with embedded ``GRAPH_DATA``."""
raw = Path(path).read_text(encoding="utf-8")
return parse_graph_data_text(raw)
[docs]
def parse_graph_artifact(path: str | Path) -> KnowledgeGraph:
"""Parse JSON, JS, or HTML graph artifacts."""
path = Path(path)
raw = path.read_text(encoding="utf-8")
if path.suffix.lower() == ".json":
return KnowledgeGraph(**json.loads(raw))
return parse_graph_data_text(raw)
def _clean_for_js(obj: Any) -> Any:
"""Strip None values so the JS side sees clean objects."""
if isinstance(obj, dict):
return {k: _clean_for_js(v) for k, v in obj.items() if v is not None}
if isinstance(obj, list):
return [_clean_for_js(i) for i in obj]
return obj
def _export_engine(
engine: GraphEngine,
visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> GraphEngine:
normalized = (
visibility
if isinstance(visibility, GraphVisibility)
else normalize_graph_visibility(visibility)
)
if normalized == GraphVisibility.ALL:
return engine
return filtered_engine(engine, normalized)
def _require_plotly() -> Any:
try:
import plotly.graph_objects as go # type: ignore[import-untyped]
except ImportError as exc: # pragma: no cover - exercised only when dependency missing
raise ImportError(
"Plotly export requires the `plotly` package. "
"Install `ExposoGraph[notebook]` or `pip install plotly`."
) from exc
return go
def _plotly_hover_lines(data: dict[str, Any], fields: list[tuple[str, str]]) -> str:
lines = []
for label, key in fields:
value = data.get(key)
if value not in (None, ""):
lines.append(f"<b>{label}:</b> {value}")
return "<br>".join(lines)
def _plotly_node_hover(node: dict[str, Any]) -> str:
title = node.get("label") or node.get("id") or "Node"
detail = node.get("detail") or "No detail available."
meta = _plotly_hover_lines(
node,
[
("ID", "id"),
("Type", "type"),
("Origin", "origin"),
("Match", "match_status"),
("Canonical", "canonical_label"),
("Canonical ID", "canonical_id"),
("Group", "group"),
("IARC", "iarc"),
("Phase", "phase"),
("Role", "role"),
("Reactivity", "reactivity"),
("Tissue", "tissue"),
("Variant", "variant"),
("Phenotype", "phenotype"),
("Source", "source_db"),
("Evidence", "evidence"),
("PMID", "pmid"),
],
)
return f"<b>{title}</b><br>{detail}" + (f"<br><br>{meta}" if meta else "")
def _plotly_edge_hover(edge: dict[str, Any]) -> str:
title = f"{edge.get('type', 'EDGE')}: {edge.get('source')} -> {edge.get('target')}"
meta = _plotly_hover_lines(
edge,
[
("Label", "label"),
("Origin", "origin"),
("Match", "match_status"),
("Canonical", "canonical_predicate"),
("Custom", "custom_predicate"),
("Source", "source_db"),
("Evidence", "evidence"),
("PMID", "pmid"),
],
)
return title + (f"<br><br>{meta}" if meta else "")
def _plotly_positions(engine: GraphEngine) -> dict[str, tuple[float, float]]:
import networkx as nx
graph = engine.G
if graph.number_of_nodes() == 0:
return {}
if graph.number_of_nodes() == 1:
node_id = next(iter(graph.nodes()))
return {str(node_id): (0.0, 0.0)}
layout_graph = nx.Graph()
layout_graph.add_nodes_from(graph.nodes())
layout_graph.add_edges_from((u, v) for u, v in graph.edges())
spring_k = 1.6 / math.sqrt(max(layout_graph.number_of_nodes(), 1))
pos = nx.spring_layout(layout_graph, seed=42, k=spring_k, iterations=200)
return {
str(node_id): (float(coords[0]), float(coords[1]))
for node_id, coords in pos.items()
}
def _graph_data_script(
engine: GraphEngine,
*,
visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> str:
data = _clean_for_js(_export_engine(engine, visibility).to_dict())
return f"const GRAPH_DATA = {json.dumps(data, indent=2)};"
def _inline_graph_data_script(template: str, data_script: str) -> str:
script_tag = f"<script>\n{data_script.rstrip()}\n</script>"
external_tag = '<script src="./graph-data.js"></script>'
if external_tag in template:
return template.replace(external_tag, script_tag, 1)
head_close = "</head>"
if head_close in template:
return template.replace(head_close, f"{script_tag}\n{head_close}", 1)
return f"{script_tag}\n{template}"
def _default_template_candidates() -> list[Path]:
repo_root = Path(__file__).resolve().parent.parent
return [
repo_root / "references" / "knowledge-graph" / "index.html",
]
def _package_template_text() -> str | None:
"""Load the checked-in D3 HTML template from package resources.
This makes exports work after installation without relying on a repo-local
`references/knowledge-graph/` folder.
"""
try:
return (resource_files(__package__) / "temp_kg" / "index.html").read_text(encoding="utf-8")
except Exception:
return None
def _load_viewer_template(template_path: str | Path | None = None) -> str:
candidates: list[Path] = []
if template_path is not None:
template_path = Path(template_path)
if template_path.is_dir():
candidates.append(template_path / "index.html")
else:
candidates.append(template_path)
candidates.extend(_default_template_candidates())
for candidate in candidates:
if candidate.exists():
return candidate.read_text(encoding="utf-8")
pkg_template = _package_template_text()
if pkg_template is not None:
return pkg_template
return _builtin_viewer_template()
[docs]
def to_graph_data_js(
engine: GraphEngine,
path: str | Path,
*,
visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> Path:
"""Write a ``graph-data.js`` file consumable by the D3 viewer."""
path = Path(path)
header = (
"// ═══════════════════════════════════════════════════════════════\n"
f"// Auto-generated by {APP_NAME} v{APP_VERSION}\n"
"// ═══════════════════════════════════════════════════════════════\n\n"
)
js = header + _graph_data_script(engine, visibility=visibility) + "\n"
path.write_text(js, encoding="utf-8")
return path
[docs]
def to_plotly_html_string(
engine: GraphEngine,
*,
visibility: GraphVisibility | str = GraphVisibility.ALL,
title: str | None = None,
include_plotlyjs: bool | str = True,
) -> str:
"""Render a standalone Plotly HTML document."""
figure = to_plotly_figure(engine, visibility=visibility, title=title)
return cast(
str,
figure.to_html(
full_html=True,
include_plotlyjs=include_plotlyjs,
config={
"displaylogo": False,
"responsive": True,
"scrollZoom": True,
},
),
)
[docs]
def to_plotly_html(
engine: GraphEngine,
path: str | Path,
*,
visibility: GraphVisibility | str = GraphVisibility.ALL,
title: str | None = None,
include_plotlyjs: bool | str = True,
) -> Path:
"""Write a standalone Plotly HTML file."""
path = Path(path)
html = to_plotly_html_string(
engine,
visibility=visibility,
title=title,
include_plotlyjs=include_plotlyjs,
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(html, encoding="utf-8")
return path
[docs]
def to_interactive_html_string(
engine: GraphEngine,
*,
template_path: str | Path | None = None,
visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> str:
"""Render a self-contained interactive HTML document."""
template = _load_viewer_template(template_path)
data_script = _graph_data_script(engine, visibility=visibility)
return _inline_graph_data_script(template, data_script)
[docs]
def bundle_to_html_string(
template_path: str | Path,
graph_data_path: str | Path,
) -> str:
"""Render a viewer bundle directory as a self-contained HTML document."""
template = Path(template_path).read_text(encoding="utf-8")
data_script = Path(graph_data_path).read_text(encoding="utf-8")
return _inline_graph_data_script(template, data_script)
[docs]
def to_interactive_html(
engine: GraphEngine,
path: str | Path,
*,
template_path: str | Path | None = None,
visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> Path:
"""Write a standalone interactive HTML file with embedded graph data."""
path = Path(path)
html = to_interactive_html_string(
engine,
template_path=template_path,
visibility=visibility,
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(html, encoding="utf-8")
return path
[docs]
def ensure_viewer_bundle(
output_dir: str | Path,
*,
template_dir: str | Path | None = None,
) -> Path:
"""Ensure *output_dir* is a usable D3 viewer bundle directory.
When *template_dir* contains an ``index.html`` file, it is copied into the
output directory if missing so the generated ``graph-data.js`` has a viewer
to pair with.
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if template_dir is not None:
template_dir = Path(template_dir)
template_index = template_dir / "index.html"
target_index = output_dir / "index.html"
if template_index.exists() and template_index != target_index and not target_index.exists():
shutil.copy2(template_index, target_index)
target_index = output_dir / "index.html"
if not target_index.exists():
# If a repo-local template isn't present, use the package-embedded
# one so viewer bundles still work after installation.
target_index.write_text(_load_viewer_template(template_dir), encoding="utf-8")
return output_dir
[docs]
def export_viewer_bundle(
engine: GraphEngine,
output_dir: str | Path,
*,
template_dir: str | Path | None = None,
visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> Path:
"""Write a complete viewer bundle directory.
The resulting folder contains ``graph-data.js`` and, when a template is
available, ``index.html``.
"""
output_dir = ensure_viewer_bundle(output_dir, template_dir=template_dir)
to_graph_data_js(engine, output_dir / "graph-data.js", visibility=visibility)
return output_dir
[docs]
def to_json(
engine: GraphEngine,
path: str | Path,
*,
visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> Path:
"""Write a plain JSON export."""
path = Path(path)
data = _clean_for_js(_export_engine(engine, visibility).to_dict())
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
return path
[docs]
def to_gexf(
engine: GraphEngine,
path: str | Path,
*,
visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> Path:
"""Write GEXF (Gephi) format.
Non-scalar node/edge attributes (lists, dicts) are serialized to
JSON strings since GEXF only supports primitive attribute types.
"""
import networkx as nx
path = Path(path)
# Deep-copy graph and flatten complex attributes for GEXF compatibility
G = _export_engine(engine, visibility).G.copy()
_SCALAR = (str, int, float, bool)
for _node_id, data in G.nodes(data=True):
for key, val in list(data.items()):
if val is not None and not isinstance(val, _SCALAR):
data[key] = json.dumps(val, default=str)
for _u, _v, _k, data in G.edges(keys=True, data=True):
for key, val in list(data.items()):
if val is not None and not isinstance(val, _SCALAR):
data[key] = json.dumps(val, default=str)
nx.write_gexf(G, str(path))
return path