Coverage for src / graphable / views / html.py: 100%
30 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-16 21:32 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-16 21:32 +0000
1from dataclasses import dataclass
2from html import escape
3from logging import getLogger
4from pathlib import Path
6from ..graph import Graph
7from ..registry import register_view
8from .cytoscape import CytoscapeStylingConfig, create_topology_cytoscape
10logger = getLogger(__name__)
13@dataclass
14class HtmlStylingConfig:
15 """
16 Configuration for Standalone HTML graph visualization.
18 Attributes:
19 title: Page title.
20 layout: Cytoscape layout algorithm (e.g., 'breadthfirst', 'dagre', 'cose').
21 theme: Color theme ('light' or 'dark').
22 node_color: CSS color for nodes.
23 edge_color: CSS color for edges.
24 cy_config: Underlying Cytoscape configuration.
25 """
27 title: str = "Graphable Visualization"
28 layout: str = "breadthfirst"
29 theme: str = "light"
30 node_color: str = "#007bff"
31 edge_color: str = "#ccc"
32 cy_config: CytoscapeStylingConfig | None = None
35_HTML_TEMPLATE = """<!DOCTYPE html>
36<html>
37<head>
38 <title>{title}</title>
39 <script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"></script>
40 <style>
41 body {{
42 font-family: sans-serif;
43 margin: 0;
44 padding: 0;
45 background-color: {bg_color};
46 color: {text_color};
47 overflow: hidden;
48 }}
49 #cy {{
50 width: 100vw;
51 height: 100vh;
52 display: block;
53 }}
54 .header {{
55 position: absolute;
56 top: 10px;
57 left: 10px;
58 z-index: 999;
59 background: rgba(255, 255, 255, 0.9);
60 padding: 5px 15px;
61 border-radius: 5px;
62 box-shadow: 0 2px 5px rgba(0,0,0,0.2);
63 color: #333;
64 }}
65 .controls {{
66 position: absolute;
67 top: 10px;
68 right: 10px;
69 z-index: 999;
70 background: rgba(255, 255, 255, 0.9);
71 padding: 10px;
72 border-radius: 5px;
73 box-shadow: 0 2px 5px rgba(0,0,0,0.2);
74 display: flex;
75 gap: 10px;
76 }}
77 .controls input {{
78 padding: 5px;
79 border: 1px solid #ccc;
80 border-radius: 3px;
81 }}
82 .sidebar {{
83 position: absolute;
84 top: 0;
85 right: -300px;
86 width: 300px;
87 height: 100vh;
88 background: rgba(255, 255, 255, 0.95);
89 z-index: 1000;
90 box-shadow: -2px 0 5px rgba(0,0,0,0.1);
91 transition: right 0.3s ease;
92 padding: 20px;
93 box-sizing: border-box;
94 color: #333;
95 overflow-y: auto;
96 }}
97 .sidebar.open {{
98 right: 0;
99 }}
100 .sidebar h3 {{ margin-top: 0; }}
101 .sidebar .close-btn {{
102 position: absolute;
103 top: 10px;
104 right: 10px;
105 cursor: pointer;
106 font-size: 20px;
107 }}
108 .metadata-item {{ margin-bottom: 10px; }}
109 .metadata-label {{ font-weight: bold; display: block; font-size: 0.8em; color: #666; }}
110 .tag {{
111 display: inline-block;
112 background: #e0e0e0;
113 padding: 2px 6px;
114 border-radius: 3px;
115 margin-right: 4px;
116 font-size: 0.9em;
117 }}
118 </style>
119</head>
120<body>
121 <div class="header">
122 <h2>{title}</h2>
123 <p>Scroll to zoom, drag to move nodes.</p>
124 </div>
125 <div class="controls">
126 <input type="text" id="search" placeholder="Search nodes...">
127 </div>
128 <div id="sidebar" class="sidebar">
129 <span class="close-btn" onclick="closeSidebar()">×</span>
130 <div id="sidebar-content">
131 <p>Select a node to view details.</p>
132 </div>
133 </div>
134 <div id="cy"></div>
135 <script>
136 // Live Reload Logic
137 (function() {{
138 var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
139 var address = protocol + '//' + window.location.host + '/ws';
140 var socket = new WebSocket(address);
141 socket.onmessage = function(msg) {{
142 if (msg.data == 'reload') window.location.reload();
143 }};
144 }})();
146 var cy = cytoscape({{
147 container: document.getElementById('cy'),
148 elements: {elements},
149 style: [
150 {{
151 selector: 'node',
152 style: {{
153 'background-color': '{node_color}',
154 'label': 'data(label)',
155 'color': '{text_color}',
156 'text-valign': 'center',
157 'text-halign': 'center',
158 'width': '60px',
159 'height': '60px',
160 'font-size': '12px'
161 }}
162 }},
163 {{
164 selector: 'node:selected',
165 style: {{
166 'border-width': '4px',
167 'border-color': '#ff0',
168 'background-color': '#0056b3'
169 }}
170 }},
171 {{
172 selector: 'edge',
173 style: {{
174 'width': 2,
175 'line-color': '{edge_color}',
176 'target-arrow-color': '{edge_color}',
177 'target-arrow-shape': 'triangle',
178 'curve-style': 'bezier'
179 }}
180 }}
181 ],
182 layout: {{
183 name: '{layout}',
184 directed: true,
185 padding: 50
186 }}
187 }});
189 // Sidebar logic
190 function openSidebar(node) {{
191 var data = node.data();
192 var content = '<h3>' + data.label + '</h3>';
194 content += '<div class="metadata-item"><span class="metadata-label">ID</span>' + data.id + '</div>';
196 if (data.tags && data.tags.length > 0) {{
197 content += '<div class="metadata-item"><span class="metadata-label">Tags</span>';
198 data.tags.forEach(function(tag) {{
199 content += '<span class="tag">' + tag + '</span>';
200 }});
201 content += '</div>';
202 }}
204 if (data.duration !== undefined) {{
205 content += '<div class="metadata-item"><span class="metadata-label">Duration</span>' + data.duration + 's</div>';
206 }}
208 if (data.status) {{
209 content += '<div class="metadata-item"><span class="metadata-label">Status</span>' + data.status + '</div>';
210 }}
212 document.getElementById('sidebar-content').innerHTML = content;
213 document.getElementById('sidebar').classList.add('open');
214 }}
216 function closeSidebar() {{
217 document.getElementById('sidebar').classList.remove('open');
218 cy.$(':selected').unselect();
219 }}
221 cy.on('tap', 'node', function(evt){{
222 openSidebar(evt.target);
223 }});
225 cy.on('tap', function(evt){{
226 if (evt.target === cy) {{
227 closeSidebar();
228 }}
229 }});
231 // Search logic
232 document.getElementById('search').addEventListener('input', function(e) {{
233 var query = e.target.value.toLowerCase();
234 cy.nodes().forEach(function(node) {{
235 if (query && node.data('label').toLowerCase().includes(query)) {{
236 node.addClass('highlighted');
237 node.style('background-color', '#ff0');
238 }} else {{
239 node.removeClass('highlighted');
240 node.style('background-color', '{node_color}');
241 }}
242 }});
243 }});
244 </script>
245</body>
246</html>
247"""
250def create_topology_html(graph: Graph, config: HtmlStylingConfig | None = None) -> str:
251 """
252 Generate a standalone interactive HTML representation of the graph.
254 Args:
255 graph (Graph): The graph to convert.
256 config (HtmlStylingConfig | None): Export configuration.
258 Returns:
259 str: The full HTML content.
260 """
261 logger.debug("Creating Interactive HTML representation.")
262 config = config or HtmlStylingConfig()
264 # Get elements using the Cytoscape view
265 elements_json = create_topology_cytoscape(graph, config.cy_config)
267 # Set theme colors
268 bg_color = "#fff" if config.theme == "light" else "#222"
269 text_color = "#333" if config.theme == "light" else "#eee"
271 # Escape JSON for safe inclusion in <script> block
272 safe_elements = elements_json.replace("</script>", r"<\/script>")
274 return _HTML_TEMPLATE.format(
275 title=escape(config.title),
276 elements=safe_elements,
277 layout=config.layout,
278 bg_color=bg_color,
279 text_color=text_color,
280 node_color=config.node_color,
281 edge_color=config.edge_color,
282 )
285@register_view(".html", creator_fnc=create_topology_html)
286def export_topology_html(
287 graph: Graph, output: Path, config: HtmlStylingConfig | None = None
288) -> None:
289 """
290 Export the graph to a standalone HTML file.
292 Args:
293 graph (Graph): The graph to export.
294 output (Path): The output file path.
295 config (HtmlStylingConfig | None): Export configuration.
296 """
297 logger.info(f"Exporting Interactive HTML to: {output}")
298 with open(output, "w+") as f:
299 f.write(create_topology_html(graph, config))