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

1from dataclasses import dataclass 

2from html import escape 

3from logging import getLogger 

4from pathlib import Path 

5 

6from ..graph import Graph 

7from ..registry import register_view 

8from .cytoscape import CytoscapeStylingConfig, create_topology_cytoscape 

9 

10logger = getLogger(__name__) 

11 

12 

13@dataclass 

14class HtmlStylingConfig: 

15 """ 

16 Configuration for Standalone HTML graph visualization. 

17 

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 """ 

26 

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 

33 

34 

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()">&times;</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 }})(); 

145 

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 }}); 

188 

189 // Sidebar logic 

190 function openSidebar(node) {{ 

191 var data = node.data(); 

192 var content = '<h3>' + data.label + '</h3>'; 

193  

194 content += '<div class="metadata-item"><span class="metadata-label">ID</span>' + data.id + '</div>'; 

195  

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 }} 

203  

204 if (data.duration !== undefined) {{ 

205 content += '<div class="metadata-item"><span class="metadata-label">Duration</span>' + data.duration + 's</div>'; 

206 }} 

207  

208 if (data.status) {{ 

209 content += '<div class="metadata-item"><span class="metadata-label">Status</span>' + data.status + '</div>'; 

210 }} 

211 

212 document.getElementById('sidebar-content').innerHTML = content; 

213 document.getElementById('sidebar').classList.add('open'); 

214 }} 

215 

216 function closeSidebar() {{ 

217 document.getElementById('sidebar').classList.remove('open'); 

218 cy.$(':selected').unselect(); 

219 }} 

220 

221 cy.on('tap', 'node', function(evt){{ 

222 openSidebar(evt.target); 

223 }}); 

224 

225 cy.on('tap', function(evt){{ 

226 if (evt.target === cy) {{ 

227 closeSidebar(); 

228 }} 

229 }}); 

230 

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""" 

248 

249 

250def create_topology_html(graph: Graph, config: HtmlStylingConfig | None = None) -> str: 

251 """ 

252 Generate a standalone interactive HTML representation of the graph. 

253 

254 Args: 

255 graph (Graph): The graph to convert. 

256 config (HtmlStylingConfig | None): Export configuration. 

257 

258 Returns: 

259 str: The full HTML content. 

260 """ 

261 logger.debug("Creating Interactive HTML representation.") 

262 config = config or HtmlStylingConfig() 

263 

264 # Get elements using the Cytoscape view 

265 elements_json = create_topology_cytoscape(graph, config.cy_config) 

266 

267 # Set theme colors 

268 bg_color = "#fff" if config.theme == "light" else "#222" 

269 text_color = "#333" if config.theme == "light" else "#eee" 

270 

271 # Escape JSON for safe inclusion in <script> block 

272 safe_elements = elements_json.replace("</script>", r"<\/script>") 

273 

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 ) 

283 

284 

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. 

291 

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))