import json import logging from fasthtml.components import Script, Div from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.instances import MultipleInstance logger = logging.getLogger("VisNetwork") class VisNetworkState(DbObject): def __init__(self, owner): super().__init__(owner) with self.initializing(): # persisted in DB self.nodes: list = [] self.edges: list = [] self.options: dict = { "autoResize": True, "interaction": { "dragNodes": True, "zoomView": True, "dragView": True, }, "physics": {"enabled": True} } self.events_handlers: dict = {} # {event_name: command_url} class VisNetwork(MultipleInstance): def __init__(self, parent, _id=None, nodes=None, edges=None, options=None, events_handlers=None): super().__init__(parent, _id=_id) logger.debug(f"VisNetwork created with id: {self._id}") # possible events (expected in snake_case # - select_node → selectNode # - select → select # - click → click # - double_click → doubleClick self._state = VisNetworkState(self) # Convert Commands to URLs handlers_htmx_options = { event_name: command.ajax_htmx_options() for event_name, command in events_handlers.items() } if events_handlers else {} self._update_state(nodes, edges, options, handlers_htmx_options) def _update_state(self, nodes, edges, options, events_handlers=None): logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}, {events_handlers=}") if not nodes and not edges and not options and not events_handlers: return state = self._state.copy() if nodes is not None: state.nodes = nodes if edges is not None: state.edges = edges if options is not None: state.options = options if events_handlers is not None: state.events_handlers = events_handlers self._state.update(state) def add_to_options(self, **kwargs): logger.debug(f"add_to_options: {kwargs=}") new_options = self._state.options.copy() | kwargs self._update_state(None, None, new_options) return self def render(self): # Serialize nodes and edges to JSON # This preserves all properties (color, shape, size, etc.) that are present js_nodes = ",\n ".join( json.dumps(node) for node in self._state.nodes ) js_edges = ",\n ".join( json.dumps(edge) for edge in self._state.edges ) # Convert Python options to JS js_options = json.dumps(self._state.options, indent=2) # Map Python event names to vis-network event names event_name_map = { "select_node": "selectNode", "select": "select", "click": "click", "double_click": "doubleClick" } # Generate event handlers JavaScript event_handlers_js = "" for event_name, command_htmx_options in self._state.events_handlers.items(): vis_event_name = event_name_map.get(event_name, event_name) event_handlers_js += f""" network.on('{vis_event_name}', function(params) {{ const event_data = {{ event_name: '{event_name}', nodes: params.nodes, edges: params.edges, pointer: params.pointer }}; htmx.ajax('POST', '{command_htmx_options['url']}', {{ values: {{event_data: JSON.stringify(event_data)}}, target: '{command_htmx_options['target']}', swap: '{command_htmx_options['swap']}' }}); }}); """ return ( Div( id=self._id, cls="mf-vis", ), # The script initializing Vis.js Script(f""" (function() {{ const container = document.getElementById("{self._id}"); const nodes = new vis.DataSet([ {js_nodes} ]); const edges = new vis.DataSet([ {js_edges} ]); const data = {{ nodes: nodes, edges: edges }}; const options = {js_options}; const network = new vis.Network(container, data, options); {event_handlers_js} }})(); """) ) def __ft__(self): return self.render()