From 345f0110f093c07864f90435496a2d6e10033c9c Mon Sep 17 00:00:00 2001 From: FBumann Date: Wed, 9 Jul 2025 12:27:01 +0200 Subject: [PATCH 01/21] Add possibility to create a netowrk as a cytoscape dash app --- flixopt/flow_system.py | 4 + flixopt/network.py | 217 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 flixopt/network.py diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 93720de60..16348dfd5 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -241,6 +241,10 @@ def plot_network( node_infos, edge_infos = self.network_infos() return plotting.plot_network(node_infos, edge_infos, path, controls, show) + def plot_network_dash(self): + from .network import shownetwork, flow_graph + return shownetwork(flow_graph(self)) + def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: if not self._connected: self._connect_network() diff --git a/flixopt/network.py b/flixopt/network.py new file mode 100644 index 000000000..0565c4395 --- /dev/null +++ b/flixopt/network.py @@ -0,0 +1,217 @@ +from dash import Dash, html, dcc, Input, Output +import dash_cytoscape as cyto +import networkx +import socket +import logging + +from .flow_system import FlowSystem +from .elements import Bus, Flow, Component +from .components import Sink, Source, SourceAndSink, Storage, LinearConverter + +logger = logging.getLogger('flixopt') + + +def flow_graph(flow_system: FlowSystem) -> networkx.DiGraph: + nodes = list(flow_system.components.values()) + list(flow_system.buses.values()) + edges = list(flow_system.flows.values()) + + def get_color(element): + if isinstance(element, Flow): + raise TypeError('Flow graph shape not yet implemented') + if isinstance(element, Bus): + return '#7F8C8D' + if isinstance(element, (Sink, Source, SourceAndSink)): + return '#F1C40F' + if isinstance(element, Storage): + return '#2980B9' + if isinstance(element, LinearConverter): + return '#D35400' + return '#27AE60' + + def get_shape(element): + + if isinstance(element, Bus): + return 'ellipse' + if isinstance(element, (Source)): + return 'custom-source' + if isinstance(element, (Sink, SourceAndSink)): + return 'custom-sink' + return 'rectangle' + + graph = networkx.DiGraph() # Directed Graph using networkx + + for node in nodes: + graph.add_node( + node.label_full, + color=get_color(node), + shape=get_shape(node), + #type + parameters=node.__str__(), + ) + + for edge in edges: + graph.add_edge( + u_of_edge=edge.bus if edge.is_input_in_component else edge.component, + v_of_edge=edge.component if edge.is_input_in_component else edge.bus, + label=edge.label_full, + parameters=edge.__str__().replace(')', '\n)'), + ) + + return graph + + + +def make_cytoscape_elements(graph: networkx.DiGraph): + nodes = [{'data': {'id': node, + 'label': node, + 'type' : graph.nodes[node].get('type',{}), + 'color': graph.nodes[node]['color'], + 'shape': graph.nodes[node]['shape'], + 'parameters': {}}} #graph.nodes[node].get('parameters', {})}} + for node in graph.nodes()] + edges = [{'data': {'source': u, 'target': v}} for u, v in graph.edges()] + return nodes + edges + + +def shownetwork(graph: networkx.DiGraph): + app = Dash(__name__, suppress_callback_exceptions=True) + + elements = make_cytoscape_elements(graph) + cyto.load_extra_layouts() + + textcolor = 'white' + + app.layout = html.Div([ + dcc.Dropdown( + id='layout-dropdown', + options=[ + {'label': 'Klay (horizontal)', 'value': 'klay'}, + {'label': 'Dagre (vertical)', 'value': 'dagre'}, + {'label': 'Breadthfirst', 'value': 'breadthfirst'}, + {'label': 'Cose (force-directed)', 'value': 'cose'}, + {'label': 'Grid', 'value': 'grid'}, + {'label': 'Circle', 'value': 'circle'}, + ], + value='klay', + clearable=False, + style={'width': '300px', 'margin': '10px'} + ), + + cyto.Cytoscape( + id='cytoscape', + layout={'name': 'grid'}, + style={'width': '100%', 'height': '500px'}, + elements=elements, + stylesheet=cytoscape_stylesheet[ + { + 'selector': 'node', + 'style': { + 'content': 'data(label)', + 'background-color': 'data(color)', + 'font-size': 10, + 'color': 'white', + 'text-valign': 'center', + 'text-halign': 'center', + 'width': '90px', + 'height': '70px', + 'shape': 'data(shape)', + 'text-outline-color': 'black', + 'text-outline-width': 0.5, + } + }, + { + 'selector': '[shape = "custom-source"]', + 'style': { + 'shape': 'polygon', + 'shape-polygon-points': '-0.5 0.5, 0.5 0.5, 1 -0.5, -1 -0.5', + } + }, + { + 'selector': '[shape = "custom-sink"]', + 'style': { + 'shape': 'polygon', + 'shape-polygon-points': '-0.5 -0.5, 0.5 -0.5, 1 0.5, -1 0.5', + } + }, + { + 'selector': 'edge', + 'style': { + 'curve-style': 'straight', + 'width': 2, + 'line-color': 'gray', + 'target-arrow-color': 'gray', + 'target-arrow-shape': 'triangle', + 'arrow-scale': 2, + } + } + ] + ), + + html.Div(id='node-data', style={ + 'white-space': 'pre-line', 'color': 'white', + 'background-color': '#2D3033', 'padding': '10px' + }), + + html.Button("Export as Image", id="btn-image", n_clicks=0) + ]) + + # Show node data on click + @app.callback( + Output('node-data', 'children'), + Input('cytoscape', 'tapNodeData') + ) + def display_node_data(data): + if data: + parameters = data.get('parameters', {}) + components = [html.H4(f"Node {data['id']} Parameters:", style={'color': textcolor})] + for k, v in parameters.items(): + components.append(html.P(f"{k}: {v}", style={'color': textcolor})) + return html.Div(components) + return html.P("Click on a node to see its parameters.", style={'color': textcolor}) + + # Allow changing layout dynamically + @app.callback( + Output('cytoscape', 'layout'), + Input('layout-dropdown', 'value') + ) + def update_layout(selected_layout): + return {'name': selected_layout} + + # Export graph as image + app.clientside_callback( + """ + function(n_clicks) { + if (n_clicks > 0) { + var cy = window.cy; + if (cy) { + var png64 = cy.png({scale: 3, full: true}); + var a = document.createElement('a'); + a.href = png64; + a.download = 'Oemof_model.png'; + a.click(); + } + } + return 'Export as Image'; + } + """, + Output('btn-image', 'children'), + Input('btn-image', 'n_clicks') + ) + + # Find a free port + def is_port_in_use(port): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex(('localhost', port)) == 0 + + def find_free_port(start_port=8050, end_port=8100): + for port in range(start_port, end_port): + if not is_port_in_use(port): + return port + raise Exception("No free port found") + + # Run app + port = find_free_port(8050, 8100) + print(f'Starting Network on port {port}')# logger.info(f'Starting Network on port {port}') + app.run(debug=True, port=port) + + return app \ No newline at end of file From 795317ad5d95e0ac708c4203b0394762e932a893 Mon Sep 17 00:00:00 2001 From: FBumann Date: Wed, 9 Jul 2025 12:39:08 +0200 Subject: [PATCH 02/21] Add customizability to the app --- flixopt/network.py | 495 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 446 insertions(+), 49 deletions(-) diff --git a/flixopt/network.py b/flixopt/network.py index 0565c4395..6bdc4f71b 100644 --- a/flixopt/network.py +++ b/flixopt/network.py @@ -1,8 +1,9 @@ -from dash import Dash, html, dcc, Input, Output +from dash import Dash, html, dcc, Input, Output, State, callback_context import dash_cytoscape as cyto import networkx import socket import logging +import json from .flow_system import FlowSystem from .elements import Bus, Flow, Component @@ -10,26 +11,83 @@ logger = logging.getLogger('flixopt') +# Default stylesheet (can be reset to this) +default_cytoscape_stylesheet = [ + { + 'selector': 'node', + 'style': { + 'content': 'data(label)', + 'background-color': 'data(color)', + 'font-size': 10, + 'color': 'white', + 'text-valign': 'center', + 'text-halign': 'center', + 'width': '90px', + 'height': '70px', + 'shape': 'data(shape)', + 'text-outline-color': 'black', + 'text-outline-width': 0.5, + } + }, + { + 'selector': '[shape = "custom-source"]', + 'style': { + 'shape': 'polygon', + 'shape-polygon-points': '-0.5 0.5, 0.5 0.5, 1 -0.5, -1 -0.5', + } + }, + { + 'selector': '[shape = "custom-sink"]', + 'style': { + 'shape': 'polygon', + 'shape-polygon-points': '-0.5 -0.5, 0.5 -0.5, 1 0.5, -1 0.5', + } + }, + { + 'selector': 'edge', + 'style': { + 'curve-style': 'straight', + 'width': 2, + 'line-color': 'gray', + 'target-arrow-color': 'gray', + 'target-arrow-shape': 'triangle', + 'arrow-scale': 2, + } + } +] + +# Color presets for different node types +color_presets = { + 'Default': {'Bus': '#7F8C8D', 'Source': '#F1C40F', 'Sink': '#F1C40F', 'Storage': '#2980B9', 'Converter': '#D35400', + 'Other': '#27AE60'}, + 'Vibrant': {'Bus': '#FF6B6B', 'Source': '#4ECDC4', 'Sink': '#45B7D1', 'Storage': '#96CEB4', 'Converter': '#FFEAA7', + 'Other': '#DDA0DD'}, + 'Dark': {'Bus': '#2C3E50', 'Source': '#34495E', 'Sink': '#7F8C8D', 'Storage': '#95A5A6', 'Converter': '#BDC3C7', + 'Other': '#ECF0F1'}, + 'Pastel': {'Bus': '#FFB3BA', 'Source': '#BAFFC9', 'Sink': '#BAE1FF', 'Storage': '#FFFFBA', 'Converter': '#FFDFBA', + 'Other': '#E0BBE4'} +} + def flow_graph(flow_system: FlowSystem) -> networkx.DiGraph: nodes = list(flow_system.components.values()) + list(flow_system.buses.values()) edges = list(flow_system.flows.values()) - def get_color(element): + def get_color(element, color_scheme='Default'): + colors = color_presets[color_scheme] if isinstance(element, Flow): raise TypeError('Flow graph shape not yet implemented') if isinstance(element, Bus): - return '#7F8C8D' + return colors['Bus'] if isinstance(element, (Sink, Source, SourceAndSink)): - return '#F1C40F' + return colors['Source'] if isinstance(element, Source) else colors['Sink'] if isinstance(element, Storage): - return '#2980B9' + return colors['Storage'] if isinstance(element, LinearConverter): - return '#D35400' - return '#27AE60' + return colors['Converter'] + return colors['Other'] def get_shape(element): - if isinstance(element, Bus): return 'ellipse' if isinstance(element, (Source)): @@ -38,14 +96,13 @@ def get_shape(element): return 'custom-sink' return 'rectangle' - graph = networkx.DiGraph() # Directed Graph using networkx + graph = networkx.DiGraph() for node in nodes: graph.add_node( node.label_full, color=get_color(node), shape=get_shape(node), - #type parameters=node.__str__(), ) @@ -60,19 +117,213 @@ def get_shape(element): return graph - def make_cytoscape_elements(graph: networkx.DiGraph): nodes = [{'data': {'id': node, 'label': node, - 'type' : graph.nodes[node].get('type',{}), 'color': graph.nodes[node]['color'], 'shape': graph.nodes[node]['shape'], - 'parameters': {}}} #graph.nodes[node].get('parameters', {})}} - for node in graph.nodes()] + 'parameters': graph.nodes[node].get('parameters', {})}} + for node in graph.nodes()] edges = [{'data': {'source': u, 'target': v}} for u, v in graph.edges()] return nodes + edges +def create_style_controls(): + """Create UI controls for modifying the stylesheet""" + return html.Div([ + html.H3("Style Controls", style={'color': 'white', 'margin-bottom': '10px'}), + + # Color scheme selector + html.Div([ + html.Label("Color Scheme:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Dropdown( + id='color-scheme-dropdown', + options=[{'label': k, 'value': k} for k in color_presets.keys()], + value='Default', + style={'width': '200px', 'display': 'inline-block'} + ), + # Arrow styling + html.Div([ + html.Label("Arrow Style:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Dropdown( + id='arrow-style-dropdown', + options=[ + {'label': 'Triangle', 'value': 'triangle'}, + {'label': 'Triangle (Tee)', 'value': 'triangle-tee'}, + {'label': 'Circle', 'value': 'circle'}, + {'label': 'Square', 'value': 'square'}, + {'label': 'Diamond', 'value': 'diamond'}, + {'label': 'None', 'value': 'none'} + ], + value='triangle', + style={'width': '200px', 'display': 'inline-block'} + ) + ], style={'margin-bottom': '15px'}), + + # Individual color pickers for each node type + html.Div([ + html.H4("Custom Colors:", style={'color': 'white', 'margin-bottom': '10px'}), + html.Div([ + html.Label("Bus Color:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Input(id='bus-color-input', type='text', value='#7F8C8D', + style={'width': '100px', 'margin-right': '20px'}), + html.Label("Source Color:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Input(id='source-color-input', type='text', value='#F1C40F', + style={'width': '100px', 'margin-right': '20px'}), + ], style={'margin-bottom': '10px'}), + html.Div([ + html.Label("Sink Color:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Input(id='sink-color-input', type='text', value='#F1C40F', + style={'width': '100px', 'margin-right': '20px'}), + html.Label("Storage Color:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Input(id='storage-color-input', type='text', value='#2980B9', + style={'width': '100px', 'margin-right': '20px'}), + ], style={'margin-bottom': '10px'}), + html.Div([ + html.Label("Converter Color:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Input(id='converter-color-input', type='text', value='#D35400', + style={'width': '100px', 'margin-right': '20px'}), + html.Label("Edge Color:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Input(id='edge-color-input', type='text', value='gray', + style={'width': '100px', 'margin-right': '20px'}), + ], style={'margin-bottom': '15px'}), + ]), + + # Text styling controls + html.Div([ + html.H4("Text Styling:", style={'color': 'white', 'margin-bottom': '10px'}), + html.Div([ + html.Label("Text Color:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Input(id='text-color-input', type='text', value='white', + style={'width': '100px', 'margin-right': '20px'}), + html.Label("Text Outline:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Input(id='text-outline-input', type='text', value='black', + style={'width': '100px', 'margin-right': '20px'}), + ], style={'margin-bottom': '10px'}), + html.Div([ + html.Label("Text Position:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Dropdown( + id='text-valign-dropdown', + options=[ + {'label': 'Top', 'value': 'top'}, + {'label': 'Center', 'value': 'center'}, + {'label': 'Bottom', 'value': 'bottom'} + ], + value='center', + style={'width': '120px', 'margin-right': '20px', 'display': 'inline-block'} + ), + html.Label("Text Alignment:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Dropdown( + id='text-halign-dropdown', + options=[ + {'label': 'Left', 'value': 'left'}, + {'label': 'Center', 'value': 'center'}, + {'label': 'Right', 'value': 'right'} + ], + value='center', + style={'width': '120px', 'display': 'inline-block'} + ), + ], style={'margin-bottom': '15px'}), + ]), + html.Div([ + html.Label("Node Size:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Slider( + id='node-size-slider', + min=50, + max=150, + step=10, + value=90, + marks={i: str(i) for i in range(50, 151, 25)}, + tooltip={"placement": "bottom", "always_visible": True} + ) + ], style={'margin-bottom': '15px'}), + + # Font size controls + html.Div([ + html.Label("Font Size:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Slider( + id='font-size-slider', + min=8, + max=20, + step=1, + value=10, + marks={i: str(i) for i in range(8, 21, 2)}, + tooltip={"placement": "bottom", "always_visible": True} + ) + ], style={'margin-bottom': '15px'}), + + # Edge styling controls + html.Div([ + html.H4("Edge Styling:", style={'color': 'white', 'margin-bottom': '10px'}), + html.Div([ + html.Label("Edge Width:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Slider( + id='edge-width-slider', + min=1, + max=10, + step=1, + value=2, + marks={i: str(i) for i in range(1, 11)}, + tooltip={"placement": "bottom", "always_visible": True} + ) + ], style={'margin-bottom': '15px'}), + + # Edge curve style + html.Div([ + html.Label("Edge Curve Style:", style={'color': 'white', 'margin-right': '10px'}), + dcc.Dropdown( + id='edge-curve-dropdown', + options=[ + {'label': 'Straight', 'value': 'straight'}, + {'label': 'Bezier', 'value': 'bezier'}, + {'label': 'Unbundled Bezier', 'value': 'unbundled-bezier'}, + {'label': 'Segments', 'value': 'segments'} + ], + value='straight', + style={'width': '200px', 'display': 'inline-block'} + ) + ], style={'margin-bottom': '15px'}), + + # Save/Load custom styles + html.Div([ + html.H4("Style Presets:", style={'color': 'white', 'margin-bottom': '10px'}), + html.Div([ + dcc.Input(id='save-style-name', type='text', placeholder='Enter style name...', + style={'width': '200px', 'margin-right': '10px'}), + html.Button("Save Style", id="save-style-btn", n_clicks=0, + style={'margin-right': '10px', 'background-color': '#27AE60', 'color': 'white', + 'border': 'none', 'padding': '5px 10px'}), + dcc.Dropdown( + id='load-style-dropdown', + options=[], + placeholder="Load saved style...", + style={'width': '200px', 'display': 'inline-block'} + ) + ], style={'margin-bottom': '15px'}), + ]), + html.Div([ + html.Label("Custom Stylesheet (JSON):", style={'color': 'white', 'margin-bottom': '5px'}), + dcc.Textarea( + id='custom-stylesheet-textarea', + placeholder='Enter custom Cytoscape stylesheet as JSON...', + style={'width': '100%', 'height': '150px', 'background-color': '#34495E', 'color': 'white'}, + value=json.dumps(default_cytoscape_stylesheet, indent=2) + ) + ], style={'margin-bottom': '15px'}), + + # Control buttons + html.Div([ + html.Button("Apply Custom Style", id="apply-custom-btn", n_clicks=0, + style={'margin-right': '10px', 'background-color': '#3498DB', 'color': 'white', + 'border': 'none', 'padding': '5px 10px'}), + html.Button("Reset to Default", id="reset-style-btn", n_clicks=0, + style={'background-color': '#E74C3C', 'color': 'white', 'border': 'none', + 'padding': '5px 10px'}) + ]) + ], style={'background-color': '#2D3033', 'padding': '15px', 'margin': '10px', 'border-radius': '5px'}) + ])]) + + def shownetwork(graph: networkx.DiGraph): app = Dash(__name__, suppress_callback_exceptions=True) @@ -82,40 +333,154 @@ def shownetwork(graph: networkx.DiGraph): textcolor = 'white' app.layout = html.Div([ - dcc.Dropdown( - id='layout-dropdown', - options=[ - {'label': 'Klay (horizontal)', 'value': 'klay'}, - {'label': 'Dagre (vertical)', 'value': 'dagre'}, - {'label': 'Breadthfirst', 'value': 'breadthfirst'}, - {'label': 'Cose (force-directed)', 'value': 'cose'}, - {'label': 'Grid', 'value': 'grid'}, - {'label': 'Circle', 'value': 'circle'}, - ], - value='klay', - clearable=False, - style={'width': '300px', 'margin': '10px'} - ), + # Main controls row + html.Div([ + html.Div([ + dcc.Dropdown( + id='layout-dropdown', + options=[ + {'label': 'Klay (horizontal)', 'value': 'klay'}, + {'label': 'Dagre (vertical)', 'value': 'dagre'}, + {'label': 'Breadthfirst', 'value': 'breadthfirst'}, + {'label': 'Cose (force-directed)', 'value': 'cose'}, + {'label': 'Grid', 'value': 'grid'}, + {'label': 'Circle', 'value': 'circle'}, + ], + value='klay', + clearable=False, + style={'width': '300px', 'margin': '10px'} + ), + ], style={'width': '30%', 'display': 'inline-block'}), + html.Div([ + html.Button("Toggle Style Panel", id="toggle-style-btn", n_clicks=0, + style={'margin': '10px', 'background-color': '#9B59B6', 'color': 'white', 'border': 'none', + 'padding': '10px 20px'}) + ], style={'width': '70%', 'display': 'inline-block', 'text-align': 'right'}) + ]), + + # Collapsible style panel + html.Div(id='style-panel', children=[create_style_controls()], style={'display': 'none'}), + + # Main cytoscape component cyto.Cytoscape( id='cytoscape', layout={'name': 'grid'}, style={'width': '100%', 'height': '500px'}, elements=elements, - stylesheet=cytoscape_stylesheet[ + stylesheet=default_cytoscape_stylesheet, + ), + + # Node data display + html.Div(id='node-data', style={ + 'white-space': 'pre-line', 'color': 'white', + 'background-color': '#2D3033', 'padding': '10px' + }), + + # Export button + html.Button("Export as Image", id="btn-image", n_clicks=0, + style={'margin': '10px', 'background-color': '#27AE60', 'color': 'white', 'border': 'none', + 'padding': '10px 20px'}) + ]) + + # Toggle style panel visibility + @app.callback( + Output('style-panel', 'style'), + Input('toggle-style-btn', 'n_clicks') + ) + def toggle_style_panel(n_clicks): + if n_clicks % 2 == 1: + return {'display': 'block'} + return {'display': 'none'} + + # Update stylesheet based on controls + @app.callback( + Output('cytoscape', 'stylesheet'), + [Input('color-scheme-dropdown', 'value'), + Input('bus-color-input', 'value'), + Input('source-color-input', 'value'), + Input('sink-color-input', 'value'), + Input('storage-color-input', 'value'), + Input('converter-color-input', 'value'), + Input('edge-color-input', 'value'), + Input('text-color-input', 'value'), + Input('text-outline-input', 'value'), + Input('text-valign-dropdown', 'value'), + Input('text-halign-dropdown', 'value'), + Input('node-size-slider', 'value'), + Input('font-size-slider', 'value'), + Input('edge-width-slider', 'value'), + Input('edge-curve-dropdown', 'value'), + Input('arrow-style-dropdown', 'value'), + Input('apply-custom-btn', 'n_clicks'), + Input('reset-style-btn', 'n_clicks'), + Input('load-style-dropdown', 'value')], + [State('custom-stylesheet-textarea', 'value')] + ) + def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage_color, + converter_color, edge_color, text_color, text_outline, + text_valign, text_halign, node_size, font_size, edge_width, + edge_curve, arrow_style, apply_clicks, reset_clicks, + load_style, custom_style): + ctx = callback_context + + if ctx.triggered: + button_id = ctx.triggered[0]['prop_id'].split('.')[0] + + # Reset to default + if button_id == 'reset-style-btn': + return default_cytoscape_stylesheet + + # Apply custom stylesheet + if button_id == 'apply-custom-btn': + try: + return json.loads(custom_style) + except json.JSONDecodeError: + return default_cytoscape_stylesheet + + # Update stylesheet based on controls + colors = color_presets[color_scheme] + + # Update elements with new colors (only for preset color schemes) + if color_scheme != 'Custom': + for element in elements: + if 'color' in element['data']: + # Get the actual node type from the graph or determine it from the element + node_id = element['data']['id'] + # Since we don't have direct access to the node type, we'll determine it from the shape + shape = element['data'].get('shape', 'rectangle') + + if shape == 'ellipse': + node_type = 'Bus' + elif shape == 'custom-source': + node_type = 'Source' + elif shape == 'custom-sink': + node_type = 'Sink' + elif 'storage' in node_id.lower(): + node_type = 'Storage' + elif 'converter' in node_id.lower(): + node_type = 'Converter' + else: + node_type = 'Other' + + if node_type in colors: + element['data']['color'] = colors[node_type] + + # Create updated stylesheet using individual color inputs + updated_stylesheet = [ { 'selector': 'node', 'style': { 'content': 'data(label)', 'background-color': 'data(color)', - 'font-size': 10, - 'color': 'white', - 'text-valign': 'center', - 'text-halign': 'center', - 'width': '90px', - 'height': '70px', + 'font-size': font_size, + 'color': text_color, + 'text-valign': text_valign, + 'text-halign': text_halign, + 'width': f'{node_size}px', + 'height': f'{node_size * 0.8}px', 'shape': 'data(shape)', - 'text-outline-color': 'black', + 'text-outline-color': text_outline, 'text-outline-width': 0.5, } }, @@ -136,24 +501,51 @@ def shownetwork(graph: networkx.DiGraph): { 'selector': 'edge', 'style': { - 'curve-style': 'straight', - 'width': 2, - 'line-color': 'gray', - 'target-arrow-color': 'gray', - 'target-arrow-shape': 'triangle', + 'curve-style': edge_curve, + 'width': edge_width, + 'line-color': edge_color, + 'target-arrow-color': edge_color, + 'target-arrow-shape': arrow_style, 'arrow-scale': 2, } } ] - ), - html.Div(id='node-data', style={ - 'white-space': 'pre-line', 'color': 'white', - 'background-color': '#2D3033', 'padding': '10px' - }), + # Update node colors based on individual inputs if not using preset + elif color_scheme == 'Custom' or any([bus_color, source_color, sink_color, storage_color, converter_color]): + custom_colors = { + 'Bus': bus_color or '#7F8C8D', + 'Source': source_color or '#F1C40F', + 'Sink': sink_color or '#F1C40F', + 'Storage': storage_color or '#2980B9', + 'Converter': converter_color or '#D35400', + 'Other': converter_color or '#27AE60' + } + + for element in elements: + if 'color' in element['data']: + # Determine node type from shape and id + node_id = element['data']['id'] + shape = element['data'].get('shape', 'rectangle') + + if shape == 'ellipse': + node_type = 'Bus' + elif shape == 'custom-source': + node_type = 'Source' + elif shape == 'custom-sink': + node_type = 'Sink' + elif 'storage' in node_id.lower(): + node_type = 'Storage' + elif 'converter' in node_id.lower(): + node_type = 'Converter' + else: + node_type = 'Other' + + if node_type in custom_colors: + element['data']['color'] = custom_colors[node_type] + + return updated_stylesheet - html.Button("Export as Image", id="btn-image", n_clicks=0) - ]) # Show node data on click @app.callback( @@ -169,6 +561,7 @@ def display_node_data(data): return html.Div(components) return html.P("Click on a node to see its parameters.", style={'color': textcolor}) + # Allow changing layout dynamically @app.callback( Output('cytoscape', 'layout'), @@ -177,6 +570,7 @@ def display_node_data(data): def update_layout(selected_layout): return {'name': selected_layout} + # Export graph as image app.clientside_callback( """ @@ -198,20 +592,23 @@ def update_layout(selected_layout): Input('btn-image', 'n_clicks') ) + # Find a free port def is_port_in_use(port): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: return s.connect_ex(('localhost', port)) == 0 + def find_free_port(start_port=8050, end_port=8100): for port in range(start_port, end_port): if not is_port_in_use(port): return port raise Exception("No free port found") + # Run app port = find_free_port(8050, 8100) - print(f'Starting Network on port {port}')# logger.info(f'Starting Network on port {port}') + print(f'Starting Network on port {port}') app.run(debug=True, port=port) return app \ No newline at end of file From 9aa1101129032053293ea61d66006feda427030b Mon Sep 17 00:00:00 2001 From: FBumann Date: Wed, 9 Jul 2025 12:44:05 +0200 Subject: [PATCH 03/21] Improve layout --- flixopt/network.py | 679 ++++++++++++++++++++++----------------------- 1 file changed, 339 insertions(+), 340 deletions(-) diff --git a/flixopt/network.py b/flixopt/network.py index 6bdc4f71b..a6551523d 100644 --- a/flixopt/network.py +++ b/flixopt/network.py @@ -128,200 +128,233 @@ def make_cytoscape_elements(graph: networkx.DiGraph): return nodes + edges -def create_style_controls(): - """Create UI controls for modifying the stylesheet""" +def create_style_section(title, children): + """Create a collapsible section for organizing controls""" return html.Div([ - html.H3("Style Controls", style={'color': 'white', 'margin-bottom': '10px'}), + html.H4(title, style={ + 'color': 'white', + 'margin-bottom': '10px', + 'border-bottom': '2px solid #3498DB', + 'padding-bottom': '5px' + }), + html.Div(children, style={'margin-bottom': '20px'}) + ]) - # Color scheme selector - html.Div([ - html.Label("Color Scheme:", style={'color': 'white', 'margin-right': '10px'}), + +def create_sidebar(): + """Create the sidebar with organized style controls""" + return html.Div([ + html.H3("Style Controls", style={ + 'color': 'white', + 'margin-bottom': '20px', + 'text-align': 'center', + 'border-bottom': '3px solid #9B59B6', + 'padding-bottom': '10px' + }), + + # Layout Controls + create_style_section("Layout", [ + dcc.Dropdown( + id='layout-dropdown', + options=[ + {'label': 'Klay (horizontal)', 'value': 'klay'}, + {'label': 'Dagre (vertical)', 'value': 'dagre'}, + {'label': 'Breadthfirst', 'value': 'breadthfirst'}, + {'label': 'Cose (force-directed)', 'value': 'cose'}, + {'label': 'Grid', 'value': 'grid'}, + {'label': 'Circle', 'value': 'circle'}, + ], + value='klay', + clearable=False, + style={'width': '100%'} + ) + ]), + + # Color Scheme Section + create_style_section("Color Scheme", [ dcc.Dropdown( id='color-scheme-dropdown', options=[{'label': k, 'value': k} for k in color_presets.keys()], value='Default', - style={'width': '200px', 'display': 'inline-block'} - ), - # Arrow styling - html.Div([ - html.Label("Arrow Style:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Dropdown( - id='arrow-style-dropdown', - options=[ - {'label': 'Triangle', 'value': 'triangle'}, - {'label': 'Triangle (Tee)', 'value': 'triangle-tee'}, - {'label': 'Circle', 'value': 'circle'}, - {'label': 'Square', 'value': 'square'}, - {'label': 'Diamond', 'value': 'diamond'}, - {'label': 'None', 'value': 'none'} - ], - value='triangle', - style={'width': '200px', 'display': 'inline-block'} - ) - ], style={'margin-bottom': '15px'}), + style={'width': '100%', 'margin-bottom': '10px'} + ) + ]), - # Individual color pickers for each node type + # Custom Colors Section + create_style_section("Custom Colors", [ html.Div([ - html.H4("Custom Colors:", style={'color': 'white', 'margin-bottom': '10px'}), - html.Div([ - html.Label("Bus Color:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Input(id='bus-color-input', type='text', value='#7F8C8D', - style={'width': '100px', 'margin-right': '20px'}), - html.Label("Source Color:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Input(id='source-color-input', type='text', value='#F1C40F', - style={'width': '100px', 'margin-right': '20px'}), - ], style={'margin-bottom': '10px'}), - html.Div([ - html.Label("Sink Color:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Input(id='sink-color-input', type='text', value='#F1C40F', - style={'width': '100px', 'margin-right': '20px'}), - html.Label("Storage Color:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Input(id='storage-color-input', type='text', value='#2980B9', - style={'width': '100px', 'margin-right': '20px'}), - ], style={'margin-bottom': '10px'}), - html.Div([ - html.Label("Converter Color:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Input(id='converter-color-input', type='text', value='#D35400', - style={'width': '100px', 'margin-right': '20px'}), - html.Label("Edge Color:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Input(id='edge-color-input', type='text', value='gray', - style={'width': '100px', 'margin-right': '20px'}), - ], style={'margin-bottom': '15px'}), + html.Label("Bus", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='bus-color-input', type='text', value='#7F8C8D', + style={'width': '100%', 'margin-bottom': '8px'}) + ]), + html.Div([ + html.Label("Source", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='source-color-input', type='text', value='#F1C40F', + style={'width': '100%', 'margin-bottom': '8px'}) + ]), + html.Div([ + html.Label("Sink", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='sink-color-input', type='text', value='#F1C40F', + style={'width': '100%', 'margin-bottom': '8px'}) + ]), + html.Div([ + html.Label("Storage", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='storage-color-input', type='text', value='#2980B9', + style={'width': '100%', 'margin-bottom': '8px'}) ]), - - # Text styling controls html.Div([ - html.H4("Text Styling:", style={'color': 'white', 'margin-bottom': '10px'}), - html.Div([ - html.Label("Text Color:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Input(id='text-color-input', type='text', value='white', - style={'width': '100px', 'margin-right': '20px'}), - html.Label("Text Outline:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Input(id='text-outline-input', type='text', value='black', - style={'width': '100px', 'margin-right': '20px'}), - ], style={'margin-bottom': '10px'}), - html.Div([ - html.Label("Text Position:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Dropdown( - id='text-valign-dropdown', - options=[ - {'label': 'Top', 'value': 'top'}, - {'label': 'Center', 'value': 'center'}, - {'label': 'Bottom', 'value': 'bottom'} - ], - value='center', - style={'width': '120px', 'margin-right': '20px', 'display': 'inline-block'} - ), - html.Label("Text Alignment:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Dropdown( - id='text-halign-dropdown', - options=[ - {'label': 'Left', 'value': 'left'}, - {'label': 'Center', 'value': 'center'}, - {'label': 'Right', 'value': 'right'} - ], - value='center', - style={'width': '120px', 'display': 'inline-block'} - ), - ], style={'margin-bottom': '15px'}), + html.Label("Converter", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='converter-color-input', type='text', value='#D35400', + style={'width': '100%', 'margin-bottom': '8px'}) ]), html.Div([ - html.Label("Node Size:", style={'color': 'white', 'margin-right': '10px'}), + html.Label("Edge", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='edge-color-input', type='text', value='gray', + style={'width': '100%', 'margin-bottom': '8px'}) + ]) + ]), + + # Node Styling Section + create_style_section("Node Styling", [ + html.Div([ + html.Label("Node Size", style={'color': 'white', 'font-size': '12px'}), dcc.Slider( id='node-size-slider', - min=50, - max=150, - step=10, - value=90, - marks={i: str(i) for i in range(50, 151, 25)}, + min=50, max=150, step=10, value=90, + marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(50, 151, 25)}, tooltip={"placement": "bottom", "always_visible": True} ) ], style={'margin-bottom': '15px'}), - - # Font size controls html.Div([ - html.Label("Font Size:", style={'color': 'white', 'margin-right': '10px'}), + html.Label("Font Size", style={'color': 'white', 'font-size': '12px'}), dcc.Slider( id='font-size-slider', - min=8, - max=20, - step=1, - value=10, - marks={i: str(i) for i in range(8, 21, 2)}, + min=8, max=20, step=1, value=10, + marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(8, 21, 2)}, + tooltip={"placement": "bottom", "always_visible": True} + ) + ], style={'margin-bottom': '15px'}) + ]), + + # Text Styling Section + create_style_section("Text Styling", [ + html.Div([ + html.Label("Text Color", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='text-color-input', type='text', value='white', + style={'width': '100%', 'margin-bottom': '8px'}) + ]), + html.Div([ + html.Label("Text Outline", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='text-outline-input', type='text', value='black', + style={'width': '100%', 'margin-bottom': '8px'}) + ]), + html.Div([ + html.Label("Text Position", style={'color': 'white', 'font-size': '12px'}), + dcc.Dropdown( + id='text-valign-dropdown', + options=[ + {'label': 'Top', 'value': 'top'}, + {'label': 'Center', 'value': 'center'}, + {'label': 'Bottom', 'value': 'bottom'} + ], + value='center', + style={'width': '100%', 'margin-bottom': '8px'} + ) + ]), + html.Div([ + html.Label("Text Alignment", style={'color': 'white', 'font-size': '12px'}), + dcc.Dropdown( + id='text-halign-dropdown', + options=[ + {'label': 'Left', 'value': 'left'}, + {'label': 'Center', 'value': 'center'}, + {'label': 'Right', 'value': 'right'} + ], + value='center', + style={'width': '100%', 'margin-bottom': '8px'} + ) + ]) + ]), + + # Edge Styling Section + create_style_section("Edge Styling", [ + html.Div([ + html.Label("Edge Width", style={'color': 'white', 'font-size': '12px'}), + dcc.Slider( + id='edge-width-slider', + min=1, max=10, step=1, value=2, + marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(1, 11)}, tooltip={"placement": "bottom", "always_visible": True} ) ], style={'margin-bottom': '15px'}), + html.Div([ + html.Label("Edge Curve", style={'color': 'white', 'font-size': '12px'}), + dcc.Dropdown( + id='edge-curve-dropdown', + options=[ + {'label': 'Straight', 'value': 'straight'}, + {'label': 'Bezier', 'value': 'bezier'}, + {'label': 'Unbundled Bezier', 'value': 'unbundled-bezier'}, + {'label': 'Segments', 'value': 'segments'} + ], + value='straight', + style={'width': '100%', 'margin-bottom': '8px'} + ) + ]), + html.Div([ + html.Label("Arrow Style", style={'color': 'white', 'font-size': '12px'}), + dcc.Dropdown( + id='arrow-style-dropdown', + options=[ + {'label': 'Triangle', 'value': 'triangle'}, + {'label': 'Triangle (Tee)', 'value': 'triangle-tee'}, + {'label': 'Circle', 'value': 'circle'}, + {'label': 'Square', 'value': 'square'}, + {'label': 'Diamond', 'value': 'diamond'}, + {'label': 'None', 'value': 'none'} + ], + value='triangle', + style={'width': '100%', 'margin-bottom': '8px'} + ) + ]) + ]), - # Edge styling controls + # Advanced Section + create_style_section("Advanced", [ html.Div([ - html.H4("Edge Styling:", style={'color': 'white', 'margin-bottom': '10px'}), - html.Div([ - html.Label("Edge Width:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Slider( - id='edge-width-slider', - min=1, - max=10, - step=1, - value=2, - marks={i: str(i) for i in range(1, 11)}, - tooltip={"placement": "bottom", "always_visible": True} - ) - ], style={'margin-bottom': '15px'}), - - # Edge curve style - html.Div([ - html.Label("Edge Curve Style:", style={'color': 'white', 'margin-right': '10px'}), - dcc.Dropdown( - id='edge-curve-dropdown', - options=[ - {'label': 'Straight', 'value': 'straight'}, - {'label': 'Bezier', 'value': 'bezier'}, - {'label': 'Unbundled Bezier', 'value': 'unbundled-bezier'}, - {'label': 'Segments', 'value': 'segments'} - ], - value='straight', - style={'width': '200px', 'display': 'inline-block'} - ) - ], style={'margin-bottom': '15px'}), - - # Save/Load custom styles - html.Div([ - html.H4("Style Presets:", style={'color': 'white', 'margin-bottom': '10px'}), - html.Div([ - dcc.Input(id='save-style-name', type='text', placeholder='Enter style name...', - style={'width': '200px', 'margin-right': '10px'}), - html.Button("Save Style", id="save-style-btn", n_clicks=0, - style={'margin-right': '10px', 'background-color': '#27AE60', 'color': 'white', - 'border': 'none', 'padding': '5px 10px'}), - dcc.Dropdown( - id='load-style-dropdown', - options=[], - placeholder="Load saved style...", - style={'width': '200px', 'display': 'inline-block'} - ) - ], style={'margin-bottom': '15px'}), - ]), - html.Div([ - html.Label("Custom Stylesheet (JSON):", style={'color': 'white', 'margin-bottom': '5px'}), - dcc.Textarea( - id='custom-stylesheet-textarea', - placeholder='Enter custom Cytoscape stylesheet as JSON...', - style={'width': '100%', 'height': '150px', 'background-color': '#34495E', 'color': 'white'}, - value=json.dumps(default_cytoscape_stylesheet, indent=2) - ) - ], style={'margin-bottom': '15px'}), - - # Control buttons - html.Div([ - html.Button("Apply Custom Style", id="apply-custom-btn", n_clicks=0, - style={'margin-right': '10px', 'background-color': '#3498DB', 'color': 'white', - 'border': 'none', 'padding': '5px 10px'}), - html.Button("Reset to Default", id="reset-style-btn", n_clicks=0, - style={'background-color': '#E74C3C', 'color': 'white', 'border': 'none', - 'padding': '5px 10px'}) - ]) - ], style={'background-color': '#2D3033', 'padding': '15px', 'margin': '10px', 'border-radius': '5px'}) - ])]) + html.Label("Custom Stylesheet (JSON)", style={'color': 'white', 'font-size': '12px'}), + dcc.Textarea( + id='custom-stylesheet-textarea', + placeholder='Enter custom Cytoscape stylesheet as JSON...', + style={'width': '100%', 'height': '120px', 'background-color': '#34495E', + 'color': 'white', 'font-size': '11px', 'margin-bottom': '10px'}, + value=json.dumps(default_cytoscape_stylesheet, indent=2) + ) + ]), + html.Div([ + html.Button("Apply Custom", id="apply-custom-btn", n_clicks=0, + style={'width': '48%', 'margin-right': '4%', 'background-color': '#3498DB', + 'color': 'white', 'border': 'none', 'padding': '8px', 'border-radius': '3px'}), + html.Button("Reset Default", id="reset-style-btn", n_clicks=0, + style={'width': '48%', 'background-color': '#E74C3C', + 'color': 'white', 'border': 'none', 'padding': '8px', 'border-radius': '3px'}) + ]) + ]) + ], style={ + 'width': '280px', + 'height': '100vh', + 'background-color': '#2C3E50', + 'padding': '20px', + 'position': 'fixed', + 'left': '0', + 'top': '0', + 'overflow-y': 'auto', + 'border-right': '3px solid #34495E', + 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)' + }) def shownetwork(graph: networkx.DiGraph): @@ -333,66 +366,64 @@ def shownetwork(graph: networkx.DiGraph): textcolor = 'white' app.layout = html.Div([ - # Main controls row + # Sidebar + create_sidebar(), + + # Main content area html.Div([ + # Top toolbar html.Div([ - dcc.Dropdown( - id='layout-dropdown', - options=[ - {'label': 'Klay (horizontal)', 'value': 'klay'}, - {'label': 'Dagre (vertical)', 'value': 'dagre'}, - {'label': 'Breadthfirst', 'value': 'breadthfirst'}, - {'label': 'Cose (force-directed)', 'value': 'cose'}, - {'label': 'Grid', 'value': 'grid'}, - {'label': 'Circle', 'value': 'circle'}, - ], - value='klay', - clearable=False, - style={'width': '300px', 'margin': '10px'} - ), - ], style={'width': '30%', 'display': 'inline-block'}), + html.H2("Network Visualization", style={ + 'color': 'white', + 'margin': '0', + 'text-align': 'center' + }), + html.Button("Export as Image", id="btn-image", n_clicks=0, + style={'position': 'absolute', 'right': '20px', 'top': '15px', + 'background-color': '#27AE60', 'color': 'white', 'border': 'none', + 'padding': '10px 20px', 'border-radius': '5px', 'cursor': 'pointer'}) + ], style={ + 'background-color': '#34495E', + 'padding': '15px 20px', + 'margin-bottom': '0', + 'position': 'relative', + 'border-bottom': '2px solid #3498DB' + }), + + # Main cytoscape component + cyto.Cytoscape( + id='cytoscape', + layout={'name': 'klay'}, + style={'width': '100%', 'height': '70vh'}, + elements=elements, + stylesheet=default_cytoscape_stylesheet, + ), + # Bottom panel for node information html.Div([ - html.Button("Toggle Style Panel", id="toggle-style-btn", n_clicks=0, - style={'margin': '10px', 'background-color': '#9B59B6', 'color': 'white', 'border': 'none', - 'padding': '10px 20px'}) - ], style={'width': '70%', 'display': 'inline-block', 'text-align': 'right'}) - ]), - - # Collapsible style panel - html.Div(id='style-panel', children=[create_style_controls()], style={'display': 'none'}), - - # Main cytoscape component - cyto.Cytoscape( - id='cytoscape', - layout={'name': 'grid'}, - style={'width': '100%', 'height': '500px'}, - elements=elements, - stylesheet=default_cytoscape_stylesheet, - ), - - # Node data display - html.Div(id='node-data', style={ - 'white-space': 'pre-line', 'color': 'white', - 'background-color': '#2D3033', 'padding': '10px' - }), - - # Export button - html.Button("Export as Image", id="btn-image", n_clicks=0, - style={'margin': '10px', 'background-color': '#27AE60', 'color': 'white', 'border': 'none', - 'padding': '10px 20px'}) + html.H4("Node Information", style={ + 'color': 'white', + 'margin': '0 0 10px 0', + 'border-bottom': '2px solid #3498DB', + 'padding-bottom': '5px' + }), + html.Div(id='node-data', children=[ + html.P("Click on a node to see its parameters.", style={'color': 'white', 'margin': '0'}) + ]) + ], style={ + 'background-color': '#2C3E50', + 'padding': '15px', + 'height': '25vh', + 'overflow-y': 'auto', + 'border-top': '2px solid #34495E' + }) + ], style={ + 'margin-left': '280px', + 'background-color': '#1A252F', + 'min-height': '100vh' + }) ]) - # Toggle style panel visibility - @app.callback( - Output('style-panel', 'style'), - Input('toggle-style-btn', 'n_clicks') - ) - def toggle_style_panel(n_clicks): - if n_clicks % 2 == 1: - return {'display': 'block'} - return {'display': 'none'} - # Update stylesheet based on controls @app.callback( Output('cytoscape', 'stylesheet'), @@ -413,15 +444,13 @@ def toggle_style_panel(n_clicks): Input('edge-curve-dropdown', 'value'), Input('arrow-style-dropdown', 'value'), Input('apply-custom-btn', 'n_clicks'), - Input('reset-style-btn', 'n_clicks'), - Input('load-style-dropdown', 'value')], + Input('reset-style-btn', 'n_clicks')], [State('custom-stylesheet-textarea', 'value')] ) def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage_color, converter_color, edge_color, text_color, text_outline, text_valign, text_halign, node_size, font_size, edge_width, - edge_curve, arrow_style, apply_clicks, reset_clicks, - load_style, custom_style): + edge_curve, arrow_style, apply_clicks, reset_clicks, custom_style): ctx = callback_context if ctx.triggered: @@ -438,82 +467,11 @@ def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage except json.JSONDecodeError: return default_cytoscape_stylesheet - # Update stylesheet based on controls - colors = color_presets[color_scheme] - - # Update elements with new colors (only for preset color schemes) - if color_scheme != 'Custom': - for element in elements: - if 'color' in element['data']: - # Get the actual node type from the graph or determine it from the element - node_id = element['data']['id'] - # Since we don't have direct access to the node type, we'll determine it from the shape - shape = element['data'].get('shape', 'rectangle') - - if shape == 'ellipse': - node_type = 'Bus' - elif shape == 'custom-source': - node_type = 'Source' - elif shape == 'custom-sink': - node_type = 'Sink' - elif 'storage' in node_id.lower(): - node_type = 'Storage' - elif 'converter' in node_id.lower(): - node_type = 'Converter' - else: - node_type = 'Other' - - if node_type in colors: - element['data']['color'] = colors[node_type] - - # Create updated stylesheet using individual color inputs - updated_stylesheet = [ - { - 'selector': 'node', - 'style': { - 'content': 'data(label)', - 'background-color': 'data(color)', - 'font-size': font_size, - 'color': text_color, - 'text-valign': text_valign, - 'text-halign': text_halign, - 'width': f'{node_size}px', - 'height': f'{node_size * 0.8}px', - 'shape': 'data(shape)', - 'text-outline-color': text_outline, - 'text-outline-width': 0.5, - } - }, - { - 'selector': '[shape = "custom-source"]', - 'style': { - 'shape': 'polygon', - 'shape-polygon-points': '-0.5 0.5, 0.5 0.5, 1 -0.5, -1 -0.5', - } - }, - { - 'selector': '[shape = "custom-sink"]', - 'style': { - 'shape': 'polygon', - 'shape-polygon-points': '-0.5 -0.5, 0.5 -0.5, 1 0.5, -1 0.5', - } - }, - { - 'selector': 'edge', - 'style': { - 'curve-style': edge_curve, - 'width': edge_width, - 'line-color': edge_color, - 'target-arrow-color': edge_color, - 'target-arrow-shape': arrow_style, - 'arrow-scale': 2, - } - } - ] - - # Update node colors based on individual inputs if not using preset - elif color_scheme == 'Custom' or any([bus_color, source_color, sink_color, storage_color, converter_color]): - custom_colors = { + # Determine colors to use + if color_scheme and color_scheme != 'Custom': + colors = color_presets[color_scheme] + else: + colors = { 'Bus': bus_color or '#7F8C8D', 'Source': source_color or '#F1C40F', 'Sink': sink_color or '#F1C40F', @@ -522,30 +480,72 @@ def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage 'Other': converter_color or '#27AE60' } - for element in elements: - if 'color' in element['data']: - # Determine node type from shape and id - node_id = element['data']['id'] - shape = element['data'].get('shape', 'rectangle') - - if shape == 'ellipse': - node_type = 'Bus' - elif shape == 'custom-source': - node_type = 'Source' - elif shape == 'custom-sink': - node_type = 'Sink' - elif 'storage' in node_id.lower(): - node_type = 'Storage' - elif 'converter' in node_id.lower(): - node_type = 'Converter' - else: - node_type = 'Other' - - if node_type in custom_colors: - element['data']['color'] = custom_colors[node_type] - - return updated_stylesheet - + # Update element colors + for element in elements: + if 'color' in element['data']: + node_id = element['data']['id'] + shape = element['data'].get('shape', 'rectangle') + + if shape == 'ellipse': + node_type = 'Bus' + elif shape == 'custom-source': + node_type = 'Source' + elif shape == 'custom-sink': + node_type = 'Sink' + elif 'storage' in node_id.lower(): + node_type = 'Storage' + elif 'converter' in node_id.lower(): + node_type = 'Converter' + else: + node_type = 'Other' + + if node_type in colors: + element['data']['color'] = colors[node_type] + + # Create updated stylesheet + return [ + { + 'selector': 'node', + 'style': { + 'content': 'data(label)', + 'background-color': 'data(color)', + 'font-size': font_size, + 'color': text_color, + 'text-valign': text_valign, + 'text-halign': text_halign, + 'width': f'{node_size}px', + 'height': f'{node_size * 0.8}px', + 'shape': 'data(shape)', + 'text-outline-color': text_outline, + 'text-outline-width': 0.5, + } + }, + { + 'selector': '[shape = "custom-source"]', + 'style': { + 'shape': 'polygon', + 'shape-polygon-points': '-0.5 0.5, 0.5 0.5, 1 -0.5, -1 -0.5', + } + }, + { + 'selector': '[shape = "custom-sink"]', + 'style': { + 'shape': 'polygon', + 'shape-polygon-points': '-0.5 -0.5, 0.5 -0.5, 1 0.5, -1 0.5', + } + }, + { + 'selector': 'edge', + 'style': { + 'curve-style': edge_curve, + 'width': edge_width, + 'line-color': edge_color, + 'target-arrow-color': edge_color, + 'target-arrow-shape': arrow_style, + 'arrow-scale': 2, + } + } + ] # Show node data on click @app.callback( @@ -555,14 +555,17 @@ def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage def display_node_data(data): if data: parameters = data.get('parameters', {}) - components = [html.H4(f"Node {data['id']} Parameters:", style={'color': textcolor})] - for k, v in parameters.items(): - components.append(html.P(f"{k}: {v}", style={'color': textcolor})) - return html.Div(components) - return html.P("Click on a node to see its parameters.", style={'color': textcolor}) - - - # Allow changing layout dynamically + if isinstance(parameters, dict) and parameters: + components = [html.H5(f"Node: {data['id']}", style={'color': 'white', 'margin-bottom': '10px'})] + for k, v in parameters.items(): + components.append(html.P(f"{k}: {v}", style={'color': '#BDC3C7', 'margin': '5px 0'})) + return components + else: + return [html.P(f"Node: {data['id']}", style={'color': 'white'}), + html.P(str(parameters), style={'color': '#BDC3C7'})] + return [html.P("Click on a node to see its parameters.", style={'color': '#95A5A6'})] + + # Update layout when dropdown changes @app.callback( Output('cytoscape', 'layout'), Input('layout-dropdown', 'value') @@ -570,7 +573,6 @@ def display_node_data(data): def update_layout(selected_layout): return {'name': selected_layout} - # Export graph as image app.clientside_callback( """ @@ -581,7 +583,7 @@ def update_layout(selected_layout): var png64 = cy.png({scale: 3, full: true}); var a = document.createElement('a'); a.href = png64; - a.download = 'Oemof_model.png'; + a.download = 'network_visualization.png'; a.click(); } } @@ -592,20 +594,17 @@ def update_layout(selected_layout): Input('btn-image', 'n_clicks') ) - # Find a free port def is_port_in_use(port): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: return s.connect_ex(('localhost', port)) == 0 - def find_free_port(start_port=8050, end_port=8100): for port in range(start_port, end_port): if not is_port_in_use(port): return port raise Exception("No free port found") - # Run app port = find_free_port(8050, 8100) print(f'Starting Network on port {port}') From 43bd0a708fda407e6cb88da3b7d08b06492beb19 Mon Sep 17 00:00:00 2001 From: FBumann Date: Wed, 9 Jul 2025 12:47:52 +0200 Subject: [PATCH 04/21] Improve reset --- flixopt/network.py | 101 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/flixopt/network.py b/flixopt/network.py index a6551523d..9a0bdec94 100644 --- a/flixopt/network.py +++ b/flixopt/network.py @@ -424,6 +424,57 @@ def shownetwork(graph: networkx.DiGraph): }) ]) + # Reset all controls to defaults + @app.callback( + [Output('color-scheme-dropdown', 'value'), + Output('bus-color-input', 'value'), + Output('source-color-input', 'value'), + Output('sink-color-input', 'value'), + Output('storage-color-input', 'value'), + Output('converter-color-input', 'value'), + Output('edge-color-input', 'value'), + Output('text-color-input', 'value'), + Output('text-outline-input', 'value'), + Output('text-valign-dropdown', 'value'), + Output('text-halign-dropdown', 'value'), + Output('node-size-slider', 'value'), + Output('font-size-slider', 'value'), + Output('edge-width-slider', 'value'), + Output('edge-curve-dropdown', 'value'), + Output('arrow-style-dropdown', 'value'), + Output('layout-dropdown', 'value'), + Output('custom-stylesheet-textarea', 'value')], + [Input('reset-style-btn', 'n_clicks')] + ) + def reset_all_controls(reset_clicks): + if reset_clicks and reset_clicks > 0: + return ( + 'Default', # color-scheme-dropdown + '#7F8C8D', # bus-color-input + '#F1C40F', # source-color-input + '#F1C40F', # sink-color-input + '#2980B9', # storage-color-input + '#D35400', # converter-color-input + 'gray', # edge-color-input + 'white', # text-color-input + 'black', # text-outline-input + 'center', # text-valign-dropdown + 'center', # text-halign-dropdown + 90, # node-size-slider + 10, # font-size-slider + 2, # edge-width-slider + 'straight', # edge-curve-dropdown + 'triangle', # arrow-style-dropdown + 'klay', # layout-dropdown + json.dumps(default_cytoscape_stylesheet, indent=2) # custom-stylesheet-textarea + ) + # Return current values if no reset + return ( + 'Default', '#7F8C8D', '#F1C40F', '#F1C40F', '#2980B9', '#D35400', 'gray', + 'white', 'black', 'center', 'center', 90, 10, 2, 'straight', 'triangle', 'klay', + json.dumps(default_cytoscape_stylesheet, indent=2) + ) + # Update stylesheet based on controls @app.callback( Output('cytoscape', 'stylesheet'), @@ -443,23 +494,18 @@ def shownetwork(graph: networkx.DiGraph): Input('edge-width-slider', 'value'), Input('edge-curve-dropdown', 'value'), Input('arrow-style-dropdown', 'value'), - Input('apply-custom-btn', 'n_clicks'), - Input('reset-style-btn', 'n_clicks')], + Input('apply-custom-btn', 'n_clicks')], [State('custom-stylesheet-textarea', 'value')] ) def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage_color, converter_color, edge_color, text_color, text_outline, text_valign, text_halign, node_size, font_size, edge_width, - edge_curve, arrow_style, apply_clicks, reset_clicks, custom_style): + edge_curve, arrow_style, apply_clicks, custom_style): ctx = callback_context if ctx.triggered: button_id = ctx.triggered[0]['prop_id'].split('.')[0] - # Reset to default - if button_id == 'reset-style-btn': - return default_cytoscape_stylesheet - # Apply custom stylesheet if button_id == 'apply-custom-btn': try: @@ -467,10 +513,17 @@ def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage except json.JSONDecodeError: return default_cytoscape_stylesheet - # Determine colors to use - if color_scheme and color_scheme != 'Custom': - colors = color_presets[color_scheme] - else: + # Always use custom colors if any are provided, otherwise use preset + use_custom_colors = any([ + bus_color and bus_color != '#7F8C8D', + source_color and source_color != '#F1C40F', + sink_color and sink_color != '#F1C40F', + storage_color and storage_color != '#2980B9', + converter_color and converter_color != '#D35400', + edge_color and edge_color != 'gray' + ]) + + if use_custom_colors or color_scheme == 'Custom': colors = { 'Bus': bus_color or '#7F8C8D', 'Source': source_color or '#F1C40F', @@ -479,6 +532,8 @@ def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage 'Converter': converter_color or '#D35400', 'Other': converter_color or '#27AE60' } + else: + colors = color_presets.get(color_scheme, color_presets['Default']) # Update element colors for element in elements: @@ -509,14 +564,14 @@ def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage 'style': { 'content': 'data(label)', 'background-color': 'data(color)', - 'font-size': font_size, - 'color': text_color, - 'text-valign': text_valign, - 'text-halign': text_halign, - 'width': f'{node_size}px', - 'height': f'{node_size * 0.8}px', + 'font-size': font_size or 10, + 'color': text_color or 'white', + 'text-valign': text_valign or 'center', + 'text-halign': text_halign or 'center', + 'width': f'{node_size or 90}px', + 'height': f'{(node_size or 90) * 0.8}px', 'shape': 'data(shape)', - 'text-outline-color': text_outline, + 'text-outline-color': text_outline or 'black', 'text-outline-width': 0.5, } }, @@ -537,11 +592,11 @@ def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage { 'selector': 'edge', 'style': { - 'curve-style': edge_curve, - 'width': edge_width, - 'line-color': edge_color, - 'target-arrow-color': edge_color, - 'target-arrow-shape': arrow_style, + 'curve-style': edge_curve or 'straight', + 'width': edge_width or 2, + 'line-color': edge_color or 'gray', + 'target-arrow-color': edge_color or 'gray', + 'target-arrow-shape': arrow_style or 'triangle', 'arrow-scale': 2, } } From 3e4c2d20c067e9d90275360864af3283788ef7de Mon Sep 17 00:00:00 2001 From: FBumann Date: Wed, 9 Jul 2025 12:50:15 +0200 Subject: [PATCH 05/21] Improve reactivity of styles --- flixopt/network.py | 78 ++++++++++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/flixopt/network.py b/flixopt/network.py index 9a0bdec94..aef464c4a 100644 --- a/flixopt/network.py +++ b/flixopt/network.py @@ -366,6 +366,9 @@ def shownetwork(graph: networkx.DiGraph): textcolor = 'white' app.layout = html.Div([ + # Hidden div to store elements data + html.Div(id='elements-store', style={'display': 'none'}), + # Sidebar create_sidebar(), @@ -475,9 +478,10 @@ def reset_all_controls(reset_clicks): json.dumps(default_cytoscape_stylesheet, indent=2) ) - # Update stylesheet based on controls + # Update elements and stylesheet based on controls @app.callback( - Output('cytoscape', 'stylesheet'), + [Output('cytoscape', 'elements'), + Output('cytoscape', 'stylesheet')], [Input('color-scheme-dropdown', 'value'), Input('bus-color-input', 'value'), Input('source-color-input', 'value'), @@ -497,10 +501,10 @@ def reset_all_controls(reset_clicks): Input('apply-custom-btn', 'n_clicks')], [State('custom-stylesheet-textarea', 'value')] ) - def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage_color, - converter_color, edge_color, text_color, text_outline, - text_valign, text_halign, node_size, font_size, edge_width, - edge_curve, arrow_style, apply_clicks, custom_style): + def update_elements_and_stylesheet(color_scheme, bus_color, source_color, sink_color, storage_color, + converter_color, edge_color, text_color, text_outline, + text_valign, text_halign, node_size, font_size, edge_width, + edge_curve, arrow_style, apply_clicks, custom_style): ctx = callback_context if ctx.triggered: @@ -509,11 +513,11 @@ def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage # Apply custom stylesheet if button_id == 'apply-custom-btn': try: - return json.loads(custom_style) + return elements, json.loads(custom_style) except json.JSONDecodeError: - return default_cytoscape_stylesheet + return elements, default_cytoscape_stylesheet - # Always use custom colors if any are provided, otherwise use preset + # Determine which colors to use use_custom_colors = any([ bus_color and bus_color != '#7F8C8D', source_color and source_color != '#F1C40F', @@ -523,7 +527,7 @@ def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage edge_color and edge_color != 'gray' ]) - if use_custom_colors or color_scheme == 'Custom': + if use_custom_colors: colors = { 'Bus': bus_color or '#7F8C8D', 'Source': source_color or '#F1C40F', @@ -535,30 +539,40 @@ def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage else: colors = color_presets.get(color_scheme, color_presets['Default']) - # Update element colors + # Create updated elements with new colors + updated_elements = [] for element in elements: - if 'color' in element['data']: - node_id = element['data']['id'] - shape = element['data'].get('shape', 'rectangle') - - if shape == 'ellipse': - node_type = 'Bus' - elif shape == 'custom-source': - node_type = 'Source' - elif shape == 'custom-sink': - node_type = 'Sink' - elif 'storage' in node_id.lower(): - node_type = 'Storage' - elif 'converter' in node_id.lower(): - node_type = 'Converter' - else: - node_type = 'Other' - - if node_type in colors: - element['data']['color'] = colors[node_type] + if 'data' in element: + element_copy = element.copy() + element_copy['data'] = element['data'].copy() + + # Update node colors + if 'color' in element_copy['data']: + node_id = element_copy['data']['id'] + shape = element_copy['data'].get('shape', 'rectangle') + + if shape == 'ellipse': + node_type = 'Bus' + elif shape == 'custom-source': + node_type = 'Source' + elif shape == 'custom-sink': + node_type = 'Sink' + elif 'storage' in node_id.lower(): + node_type = 'Storage' + elif 'converter' in node_id.lower(): + node_type = 'Converter' + else: + node_type = 'Other' + + if node_type in colors: + element_copy['data']['color'] = colors[node_type] + + updated_elements.append(element_copy) + else: + updated_elements.append(element) # Create updated stylesheet - return [ + stylesheet = [ { 'selector': 'node', 'style': { @@ -602,6 +616,8 @@ def update_stylesheet(color_scheme, bus_color, source_color, sink_color, storage } ] + return updated_elements, stylesheet + # Show node data on click @app.callback( Output('node-data', 'children'), From cae7f66786056af422a51ea95d528ba20cc02c7e Mon Sep 17 00:00:00 2001 From: FBumann Date: Wed, 9 Jul 2025 13:03:07 +0200 Subject: [PATCH 06/21] Make sidebar collapsible --- flixopt/network.py | 486 ++++++++++++++++++++++++++------------------- 1 file changed, 282 insertions(+), 204 deletions(-) diff --git a/flixopt/network.py b/flixopt/network.py index aef464c4a..768f0104a 100644 --- a/flixopt/network.py +++ b/flixopt/network.py @@ -141,220 +141,226 @@ def create_style_section(title, children): ]) -def create_sidebar(): - """Create the sidebar with organized style controls""" +def create_collapsible_sidebar(): + """Create a collapsible sidebar with toggle functionality""" return html.Div([ - html.H3("Style Controls", style={ - 'color': 'white', - 'margin-bottom': '20px', - 'text-align': 'center', - 'border-bottom': '3px solid #9B59B6', - 'padding-bottom': '10px' - }), - - # Layout Controls - create_style_section("Layout", [ - dcc.Dropdown( - id='layout-dropdown', - options=[ - {'label': 'Klay (horizontal)', 'value': 'klay'}, - {'label': 'Dagre (vertical)', 'value': 'dagre'}, - {'label': 'Breadthfirst', 'value': 'breadthfirst'}, - {'label': 'Cose (force-directed)', 'value': 'cose'}, - {'label': 'Grid', 'value': 'grid'}, - {'label': 'Circle', 'value': 'circle'}, - ], - value='klay', - clearable=False, - style={'width': '100%'} - ) - ]), - - # Color Scheme Section - create_style_section("Color Scheme", [ - dcc.Dropdown( - id='color-scheme-dropdown', - options=[{'label': k, 'value': k} for k in color_presets.keys()], - value='Default', - style={'width': '100%', 'margin-bottom': '10px'} - ) - ]), - - # Custom Colors Section - create_style_section("Custom Colors", [ - html.Div([ - html.Label("Bus", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='bus-color-input', type='text', value='#7F8C8D', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Source", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='source-color-input', type='text', value='#F1C40F', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Sink", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='sink-color-input', type='text', value='#F1C40F', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Storage", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='storage-color-input', type='text', value='#2980B9', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Converter", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='converter-color-input', type='text', value='#D35400', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Edge", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='edge-color-input', type='text', value='gray', - style={'width': '100%', 'margin-bottom': '8px'}) - ]) - ]), - - # Node Styling Section - create_style_section("Node Styling", [ - html.Div([ - html.Label("Node Size", style={'color': 'white', 'font-size': '12px'}), - dcc.Slider( - id='node-size-slider', - min=50, max=150, step=10, value=90, - marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} - for i in range(50, 151, 25)}, - tooltip={"placement": "bottom", "always_visible": True} - ) - ], style={'margin-bottom': '15px'}), - html.Div([ - html.Label("Font Size", style={'color': 'white', 'font-size': '12px'}), - dcc.Slider( - id='font-size-slider', - min=8, max=20, step=1, value=10, - marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} - for i in range(8, 21, 2)}, - tooltip={"placement": "bottom", "always_visible": True} - ) - ], style={'margin-bottom': '15px'}) - ]), + # Sidebar content + html.Div([ + html.H3("Style Controls", style={ + 'color': 'white', + 'margin-bottom': '20px', + 'text-align': 'center', + 'border-bottom': '3px solid #9B59B6', + 'padding-bottom': '10px' + }), - # Text Styling Section - create_style_section("Text Styling", [ - html.Div([ - html.Label("Text Color", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='text-color-input', type='text', value='white', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Text Outline", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='text-outline-input', type='text', value='black', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Text Position", style={'color': 'white', 'font-size': '12px'}), + # Layout Controls + create_style_section("Layout", [ dcc.Dropdown( - id='text-valign-dropdown', + id='layout-dropdown', options=[ - {'label': 'Top', 'value': 'top'}, - {'label': 'Center', 'value': 'center'}, - {'label': 'Bottom', 'value': 'bottom'} + {'label': 'Klay (horizontal)', 'value': 'klay'}, + {'label': 'Dagre (vertical)', 'value': 'dagre'}, + {'label': 'Breadthfirst', 'value': 'breadthfirst'}, + {'label': 'Cose (force-directed)', 'value': 'cose'}, + {'label': 'Grid', 'value': 'grid'}, + {'label': 'Circle', 'value': 'circle'}, ], - value='center', - style={'width': '100%', 'margin-bottom': '8px'} + value='klay', + clearable=False, + style={'width': '100%'} ) ]), - html.Div([ - html.Label("Text Alignment", style={'color': 'white', 'font-size': '12px'}), - dcc.Dropdown( - id='text-halign-dropdown', - options=[ - {'label': 'Left', 'value': 'left'}, - {'label': 'Center', 'value': 'center'}, - {'label': 'Right', 'value': 'right'} - ], - value='center', - style={'width': '100%', 'margin-bottom': '8px'} - ) - ]) - ]), - # Edge Styling Section - create_style_section("Edge Styling", [ - html.Div([ - html.Label("Edge Width", style={'color': 'white', 'font-size': '12px'}), - dcc.Slider( - id='edge-width-slider', - min=1, max=10, step=1, value=2, - marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} - for i in range(1, 11)}, - tooltip={"placement": "bottom", "always_visible": True} - ) - ], style={'margin-bottom': '15px'}), - html.Div([ - html.Label("Edge Curve", style={'color': 'white', 'font-size': '12px'}), + # Color Scheme Section + create_style_section("Color Scheme", [ dcc.Dropdown( - id='edge-curve-dropdown', - options=[ - {'label': 'Straight', 'value': 'straight'}, - {'label': 'Bezier', 'value': 'bezier'}, - {'label': 'Unbundled Bezier', 'value': 'unbundled-bezier'}, - {'label': 'Segments', 'value': 'segments'} - ], - value='straight', - style={'width': '100%', 'margin-bottom': '8px'} + id='color-scheme-dropdown', + options=[{'label': k, 'value': k} for k in color_presets.keys()], + value='Default', + style={'width': '100%', 'margin-bottom': '10px'} ) ]), - html.Div([ - html.Label("Arrow Style", style={'color': 'white', 'font-size': '12px'}), - dcc.Dropdown( - id='arrow-style-dropdown', - options=[ - {'label': 'Triangle', 'value': 'triangle'}, - {'label': 'Triangle (Tee)', 'value': 'triangle-tee'}, - {'label': 'Circle', 'value': 'circle'}, - {'label': 'Square', 'value': 'square'}, - {'label': 'Diamond', 'value': 'diamond'}, - {'label': 'None', 'value': 'none'} - ], - value='triangle', - style={'width': '100%', 'margin-bottom': '8px'} - ) - ]) - ]), - # Advanced Section - create_style_section("Advanced", [ - html.Div([ - html.Label("Custom Stylesheet (JSON)", style={'color': 'white', 'font-size': '12px'}), - dcc.Textarea( - id='custom-stylesheet-textarea', - placeholder='Enter custom Cytoscape stylesheet as JSON...', - style={'width': '100%', 'height': '120px', 'background-color': '#34495E', - 'color': 'white', 'font-size': '11px', 'margin-bottom': '10px'}, - value=json.dumps(default_cytoscape_stylesheet, indent=2) - ) + # Custom Colors Section + create_style_section("Custom Colors", [ + html.Div([ + html.Label("Bus", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='bus-color-input', type='text', value='#7F8C8D', + style={'width': '100%', 'margin-bottom': '8px'}) + ]), + html.Div([ + html.Label("Source", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='source-color-input', type='text', value='#F1C40F', + style={'width': '100%', 'margin-bottom': '8px'}) + ]), + html.Div([ + html.Label("Sink", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='sink-color-input', type='text', value='#F1C40F', + style={'width': '100%', 'margin-bottom': '8px'}) + ]), + html.Div([ + html.Label("Storage", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='storage-color-input', type='text', value='#2980B9', + style={'width': '100%', 'margin-bottom': '8px'}) + ]), + html.Div([ + html.Label("Converter", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='converter-color-input', type='text', value='#D35400', + style={'width': '100%', 'margin-bottom': '8px'}) + ]), + html.Div([ + html.Label("Edge", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='edge-color-input', type='text', value='gray', + style={'width': '100%', 'margin-bottom': '8px'}) + ]) ]), - html.Div([ - html.Button("Apply Custom", id="apply-custom-btn", n_clicks=0, - style={'width': '48%', 'margin-right': '4%', 'background-color': '#3498DB', - 'color': 'white', 'border': 'none', 'padding': '8px', 'border-radius': '3px'}), - html.Button("Reset Default", id="reset-style-btn", n_clicks=0, - style={'width': '48%', 'background-color': '#E74C3C', - 'color': 'white', 'border': 'none', 'padding': '8px', 'border-radius': '3px'}) + + # Node Styling Section + create_style_section("Node Styling", [ + html.Div([ + html.Label("Node Size", style={'color': 'white', 'font-size': '12px'}), + dcc.Slider( + id='node-size-slider', + min=50, max=150, step=10, value=90, + marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(50, 151, 25)}, + tooltip={"placement": "bottom", "always_visible": True} + ) + ], style={'margin-bottom': '15px'}), + html.Div([ + html.Label("Font Size", style={'color': 'white', 'font-size': '12px'}), + dcc.Slider( + id='font-size-slider', + min=8, max=20, step=1, value=10, + marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(8, 21, 2)}, + tooltip={"placement": "bottom", "always_visible": True} + ) + ], style={'margin-bottom': '15px'}) + ]), + + # Text Styling Section + create_style_section("Text Styling", [ + html.Div([ + html.Label("Text Color", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='text-color-input', type='text', value='white', + style={'width': '100%', 'margin-bottom': '8px'}) + ]), + html.Div([ + html.Label("Text Outline", style={'color': 'white', 'font-size': '12px'}), + dcc.Input(id='text-outline-input', type='text', value='black', + style={'width': '100%', 'margin-bottom': '8px'}) + ]), + html.Div([ + html.Label("Text Position", style={'color': 'white', 'font-size': '12px'}), + dcc.Dropdown( + id='text-valign-dropdown', + options=[ + {'label': 'Top', 'value': 'top'}, + {'label': 'Center', 'value': 'center'}, + {'label': 'Bottom', 'value': 'bottom'} + ], + value='center', + style={'width': '100%', 'margin-bottom': '8px'} + ) + ]), + html.Div([ + html.Label("Text Alignment", style={'color': 'white', 'font-size': '12px'}), + dcc.Dropdown( + id='text-halign-dropdown', + options=[ + {'label': 'Left', 'value': 'left'}, + {'label': 'Center', 'value': 'center'}, + {'label': 'Right', 'value': 'right'} + ], + value='center', + style={'width': '100%', 'margin-bottom': '8px'} + ) + ]) + ]), + + # Edge Styling Section + create_style_section("Edge Styling", [ + html.Div([ + html.Label("Edge Width", style={'color': 'white', 'font-size': '12px'}), + dcc.Slider( + id='edge-width-slider', + min=1, max=10, step=1, value=2, + marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(1, 11)}, + tooltip={"placement": "bottom", "always_visible": True} + ) + ], style={'margin-bottom': '15px'}), + html.Div([ + html.Label("Edge Curve", style={'color': 'white', 'font-size': '12px'}), + dcc.Dropdown( + id='edge-curve-dropdown', + options=[ + {'label': 'Straight', 'value': 'straight'}, + {'label': 'Bezier', 'value': 'bezier'}, + {'label': 'Unbundled Bezier', 'value': 'unbundled-bezier'}, + {'label': 'Segments', 'value': 'segments'} + ], + value='straight', + style={'width': '100%', 'margin-bottom': '8px'} + ) + ]), + html.Div([ + html.Label("Arrow Style", style={'color': 'white', 'font-size': '12px'}), + dcc.Dropdown( + id='arrow-style-dropdown', + options=[ + {'label': 'Triangle', 'value': 'triangle'}, + {'label': 'Triangle (Tee)', 'value': 'triangle-tee'}, + {'label': 'Circle', 'value': 'circle'}, + {'label': 'Square', 'value': 'square'}, + {'label': 'Diamond', 'value': 'diamond'}, + {'label': 'None', 'value': 'none'} + ], + value='triangle', + style={'width': '100%', 'margin-bottom': '8px'} + ) + ]) + ]), + + # Advanced Section + create_style_section("Advanced", [ + html.Div([ + html.Label("Custom Stylesheet (JSON)", style={'color': 'white', 'font-size': '12px'}), + dcc.Textarea( + id='custom-stylesheet-textarea', + placeholder='Enter custom Cytoscape stylesheet as JSON...', + style={'width': '100%', 'height': '120px', 'background-color': '#34495E', + 'color': 'white', 'font-size': '11px', 'margin-bottom': '10px'}, + value=json.dumps(default_cytoscape_stylesheet, indent=2) + ) + ]), + html.Div([ + html.Button("Apply Custom", id="apply-custom-btn", n_clicks=0, + style={'width': '48%', 'margin-right': '4%', 'background-color': '#3498DB', + 'color': 'white', 'border': 'none', 'padding': '8px', 'border-radius': '3px'}), + html.Button("Reset Default", id="reset-style-btn", n_clicks=0, + style={'width': '48%', 'background-color': '#E74C3C', + 'color': 'white', 'border': 'none', 'padding': '8px', 'border-radius': '3px'}) + ]) ]) - ]) - ], style={ - 'width': '280px', - 'height': '100vh', - 'background-color': '#2C3E50', - 'padding': '20px', - 'position': 'fixed', - 'left': '0', - 'top': '0', - 'overflow-y': 'auto', - 'border-right': '3px solid #34495E', - 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)' - }) + ], id='sidebar-content', style={ + 'width': '280px', + 'height': '100vh', + 'background-color': '#2C3E50', + 'padding': '20px', + 'position': 'fixed', + 'left': '0', + 'top': '0', + 'overflow-y': 'auto', + 'border-right': '3px solid #34495E', + 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', + 'transform': 'translateX(-100%)', # Initially hidden + 'transition': 'transform 0.3s ease', + 'z-index': '999' + }) + ]) def shownetwork(graph: networkx.DiGraph): @@ -366,11 +372,28 @@ def shownetwork(graph: networkx.DiGraph): textcolor = 'white' app.layout = html.Div([ + # Toggle button + html.Button("☰", id="toggle-sidebar", n_clicks=0, + style={ + 'position': 'fixed', + 'top': '20px', + 'left': '20px', + 'z-index': '1000', + 'background-color': '#3498DB', + 'color': 'white', + 'border': 'none', + 'padding': '10px 15px', + 'border-radius': '5px', + 'cursor': 'pointer', + 'font-size': '18px', + 'box-shadow': '0 2px 5px rgba(0,0,0,0.3)' + }), + # Hidden div to store elements data html.Div(id='elements-store', style={'display': 'none'}), # Sidebar - create_sidebar(), + create_collapsible_sidebar(), # Main content area html.Div([ @@ -420,13 +443,68 @@ def shownetwork(graph: networkx.DiGraph): 'overflow-y': 'auto', 'border-top': '2px solid #34495E' }) - ], style={ - 'margin-left': '280px', + ], id='main-content', style={ + 'margin-left': '0', # Initially no margin 'background-color': '#1A252F', - 'min-height': '100vh' + 'min-height': '100vh', + 'transition': 'margin-left 0.3s ease' }) ]) + # Toggle sidebar visibility + @app.callback( + [Output('sidebar-content', 'style'), + Output('main-content', 'style')], + [Input('toggle-sidebar', 'n_clicks')] + ) + def toggle_sidebar(n_clicks): + if n_clicks % 2 == 1: # Sidebar is open + sidebar_style = { + 'width': '280px', + 'height': '100vh', + 'background-color': '#2C3E50', + 'padding': '20px', + 'position': 'fixed', + 'left': '0', + 'top': '0', + 'overflow-y': 'auto', + 'border-right': '3px solid #34495E', + 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', + 'transform': 'translateX(0)', + 'transition': 'transform 0.3s ease', + 'z-index': '999' + } + main_style = { + 'margin-left': '280px', + 'background-color': '#1A252F', + 'min-height': '100vh', + 'transition': 'margin-left 0.3s ease' + } + else: # Sidebar is closed + sidebar_style = { + 'width': '280px', + 'height': '100vh', + 'background-color': '#2C3E50', + 'padding': '20px', + 'position': 'fixed', + 'left': '0', + 'top': '0', + 'overflow-y': 'auto', + 'border-right': '3px solid #34495E', + 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', + 'transform': 'translateX(-100%)', + 'transition': 'transform 0.3s ease', + 'z-index': '999' + } + main_style = { + 'margin-left': '0', + 'background-color': '#1A252F', + 'min-height': '100vh', + 'transition': 'margin-left 0.3s ease' + } + + return sidebar_style, main_style + # Reset all controls to defaults @app.callback( [Output('color-scheme-dropdown', 'value'), From de532eac984d85eabb3b5dffccacb31217f5764d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 7 Aug 2025 22:49:14 +0200 Subject: [PATCH 07/21] Use threading for network.py --- flixopt/flow_system.py | 13 +++++++++++-- flixopt/network.py | 22 +++++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 16348dfd5..2f4a30dc4 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -61,6 +61,8 @@ def __init__( self._connected = False + self._network_app = None + @classmethod def from_dataset(cls, ds: xr.Dataset): timesteps_extra = pd.DatetimeIndex(ds.attrs['timesteps_extra'], name='time') @@ -241,9 +243,16 @@ def plot_network( node_infos, edge_infos = self.network_infos() return plotting.plot_network(node_infos, edge_infos, path, controls, show) - def plot_network_dash(self): + def start_network_app(self): from .network import shownetwork, flow_graph - return shownetwork(flow_graph(self)) + self._network_app = shownetwork(flow_graph(self)) + + def stop_network_app(self): + try: + self._network_app.server_instance.shutdown() + logger.info('Network App stopped.') + except Exception as e: + logger.critical(f'Failed to stop the network visualization app: {e}') def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: if not self._connected: diff --git a/flixopt/network.py b/flixopt/network.py index 768f0104a..b6cf9082e 100644 --- a/flixopt/network.py +++ b/flixopt/network.py @@ -4,6 +4,8 @@ import socket import logging import json +import threading +from werkzeug.serving import make_server from .flow_system import FlowSystem from .elements import Bus, Flow, Component @@ -752,11 +754,21 @@ def find_free_port(start_port=8050, end_port=8100): for port in range(start_port, end_port): if not is_port_in_use(port): return port - raise Exception("No free port found") + raise Exception('No free port found') - # Run app port = find_free_port(8050, 8100) - print(f'Starting Network on port {port}') - app.run(debug=True, port=port) + server = make_server('127.0.0.1', port, app.server) - return app \ No newline at end of file + # Start server in background thread + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + print(f'Network visualization started on port {port}') + print(f'Access it at: http://127.0.0.1:{port}/') + print('The app is running in the background. You can continue using the console.') + + # Store the actual server instance for shutdown + app.server_instance = server + app.port = port + + return app From 195de39a6cf2ae28dee4d8925fb60904c3403068 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 8 Aug 2025 14:50:25 +0200 Subject: [PATCH 08/21] Improve info display --- flixopt/flow_system.py | 4 + flixopt/network.py | 420 +++++++++++++++++++++++++++++++---------- 2 files changed, 323 insertions(+), 101 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 2f4a30dc4..342ede14c 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -245,6 +245,10 @@ def plot_network( def start_network_app(self): from .network import shownetwork, flow_graph + + if not self._connected: + self._connect_network() + self._network_app = shownetwork(flow_graph(self)) def stop_network_app(self): diff --git a/flixopt/network.py b/flixopt/network.py index b6cf9082e..8250305bb 100644 --- a/flixopt/network.py +++ b/flixopt/network.py @@ -1,15 +1,16 @@ -from dash import Dash, html, dcc, Input, Output, State, callback_context -import dash_cytoscape as cyto -import networkx -import socket -import logging import json +import logging +import socket import threading + +import dash_cytoscape as cyto +import networkx +from dash import Dash, Input, Output, State, callback_context, dcc, html from werkzeug.serving import make_server +from .components import LinearConverter, Sink, Source, SourceAndSink, Storage +from .elements import Bus, Component, Flow from .flow_system import FlowSystem -from .elements import Bus, Flow, Component -from .components import Sink, Source, SourceAndSink, Storage, LinearConverter logger = logging.getLogger('flixopt') @@ -126,7 +127,15 @@ def make_cytoscape_elements(graph: networkx.DiGraph): 'shape': graph.nodes[node]['shape'], 'parameters': graph.nodes[node].get('parameters', {})}} for node in graph.nodes()] - edges = [{'data': {'source': u, 'target': v}} for u, v in graph.edges()] + + # Enhanced edges with parameters and labels + edges = [{'data': {'source': u, + 'target': v, + 'id': f"{u}-{v}", # Add unique edge ID + 'label': graph.edges[u, v].get('label', ''), + 'parameters': graph.edges[u, v].get('parameters', '')}} + for u, v in graph.edges()] + return nodes + edges @@ -371,87 +380,115 @@ def shownetwork(graph: networkx.DiGraph): elements = make_cytoscape_elements(graph) cyto.load_extra_layouts() - textcolor = 'white' - - app.layout = html.Div([ - # Toggle button - html.Button("☰", id="toggle-sidebar", n_clicks=0, - style={ - 'position': 'fixed', - 'top': '20px', - 'left': '20px', - 'z-index': '1000', - 'background-color': '#3498DB', - 'color': 'white', - 'border': 'none', - 'padding': '10px 15px', - 'border-radius': '5px', - 'cursor': 'pointer', - 'font-size': '18px', - 'box-shadow': '0 2px 5px rgba(0,0,0,0.3)' - }), - - # Hidden div to store elements data - html.Div(id='elements-store', style={'display': 'none'}), - - # Sidebar - create_collapsible_sidebar(), - - # Main content area - html.Div([ - # Top toolbar - html.Div([ - html.H2("Network Visualization", style={ + app.layout = html.Div( + [ + # Toggle button + html.Button( + '☰', + id='toggle-sidebar', + n_clicks=0, + style={ + 'position': 'fixed', + 'top': '20px', + 'left': '20px', + 'z-index': '1000', + 'background-color': '#3498DB', 'color': 'white', - 'margin': '0', - 'text-align': 'center' - }), - html.Button("Export as Image", id="btn-image", n_clicks=0, - style={'position': 'absolute', 'right': '20px', 'top': '15px', - 'background-color': '#27AE60', 'color': 'white', 'border': 'none', - 'padding': '10px 20px', 'border-radius': '5px', 'cursor': 'pointer'}) - ], style={ - 'background-color': '#34495E', - 'padding': '15px 20px', - 'margin-bottom': '0', - 'position': 'relative', - 'border-bottom': '2px solid #3498DB' - }), - - # Main cytoscape component - cyto.Cytoscape( - id='cytoscape', - layout={'name': 'klay'}, - style={'width': '100%', 'height': '70vh'}, - elements=elements, - stylesheet=default_cytoscape_stylesheet, + 'border': 'none', + 'padding': '10px 15px', + 'border-radius': '5px', + 'cursor': 'pointer', + 'font-size': '18px', + 'box-shadow': '0 2px 5px rgba(0,0,0,0.3)', + }, ), - - # Bottom panel for node information - html.Div([ - html.H4("Node Information", style={ - 'color': 'white', - 'margin': '0 0 10px 0', - 'border-bottom': '2px solid #3498DB', - 'padding-bottom': '5px' - }), - html.Div(id='node-data', children=[ - html.P("Click on a node to see its parameters.", style={'color': 'white', 'margin': '0'}) - ]) - ], style={ - 'background-color': '#2C3E50', - 'padding': '15px', - 'height': '25vh', - 'overflow-y': 'auto', - 'border-top': '2px solid #34495E' - }) - ], id='main-content', style={ - 'margin-left': '0', # Initially no margin - 'background-color': '#1A252F', - 'min-height': '100vh', - 'transition': 'margin-left 0.3s ease' - }) - ]) + # Hidden div to store elements data + html.Div(id='elements-store', style={'display': 'none'}), + # Sidebar + create_collapsible_sidebar(), + # Main content area + html.Div( + [ + # Top toolbar + html.Div( + [ + html.H2( + 'Network Visualization', style={'color': 'white', 'margin': '0', 'text-align': 'center'} + ), + html.Button( + 'Export as Image', + id='btn-image', + n_clicks=0, + style={ + 'position': 'absolute', + 'right': '20px', + 'top': '15px', + 'background-color': '#27AE60', + 'color': 'white', + 'border': 'none', + 'padding': '10px 20px', + 'border-radius': '5px', + 'cursor': 'pointer', + }, + ), + ], + style={ + 'background-color': '#34495E', + 'padding': '15px 20px', + 'margin-bottom': '0', + 'position': 'relative', + 'border-bottom': '2px solid #3498DB', + }, + ), + # Main cytoscape component + cyto.Cytoscape( + id='cytoscape', + layout={'name': 'klay'}, + style={'width': '100%', 'height': '70vh'}, + elements=elements, + stylesheet=default_cytoscape_stylesheet, + ), + # Bottom panel for node information + html.Div( + [ + html.H4( + 'Element Information', + style={ + 'color': 'white', + 'margin': '0 0 10px 0', + 'border-bottom': '2px solid #3498DB', + 'padding-bottom': '5px', + }, + ), + html.Div( + id='node-data', + children=[ + html.P( + 'Click on a node or edge to see its parameters.', + style={'color': 'white', 'margin': '0'}, + ) + ], + ), + ], + style={ + 'background-color': '#2C3E50', + 'padding': '15px', + 'height': '25vh', + 'overflow-y': 'auto', + 'border-top': '2px solid #34495E', + }, + ), + ], + id='main-content', + style={ + 'margin-left': '0', # Initially no margin + 'background-color': '#1A252F', + 'min-height': '100vh', + 'transition': 'margin-left 0.3s ease', + }, + ), + ] + ) # Toggle sidebar visibility @app.callback( @@ -699,22 +736,203 @@ def update_elements_and_stylesheet(color_scheme, bus_color, source_color, sink_c return updated_elements, stylesheet # Show node data on click + # Replace your display callback with this consistently formatted version: + @app.callback( - Output('node-data', 'children'), - Input('cytoscape', 'tapNodeData') + Output('node-data', 'children'), [Input('cytoscape', 'tapNodeData'), Input('cytoscape', 'tapEdgeData')] ) - def display_node_data(data): - if data: - parameters = data.get('parameters', {}) - if isinstance(parameters, dict) and parameters: - components = [html.H5(f"Node: {data['id']}", style={'color': 'white', 'margin-bottom': '10px'})] - for k, v in parameters.items(): - components.append(html.P(f"{k}: {v}", style={'color': '#BDC3C7', 'margin': '5px 0'})) - return components - else: - return [html.P(f"Node: {data['id']}", style={'color': 'white'}), - html.P(str(parameters), style={'color': '#BDC3C7'})] - return [html.P("Click on a node to see its parameters.", style={'color': '#95A5A6'})] + def display_element_data(node_data, edge_data): + ctx = callback_context + + def display_node_info(data): + """Display node information""" + node_id = data['id'] + label = data.get('label', node_id) # Use label if available + parameters = data.get('parameters', '') + + components = [ + html.H5( + f'Node: {label}', + style={ + 'color': 'white', + 'margin-bottom': '15px', + 'border-bottom': '1px solid #3498DB', + 'padding-bottom': '5px', + }, + ) + ] + + # Add parameters section + if parameters: + components.append( + html.Div( + [ + html.Strong( + 'Parameters:', style={'color': '#3498DB', 'display': 'block', 'margin-bottom': '5px'} + ) + ] + ) + ) + + if isinstance(parameters, str): + lines = parameters.split('\n') + for line in lines: + if line.strip(): + components.append( + html.Div( + line, + style={ + 'color': '#BDC3C7', + 'margin': '3px 0', + 'font-family': 'monospace', + 'font-size': '12px', + 'line-height': '1.3', + 'white-space': 'pre-wrap', + 'padding-left': '10px', + }, + ) + ) + else: + components.append(html.Div(style={'height': '8px'})) + elif isinstance(parameters, dict): + for k, v in parameters.items(): + components.append( + html.Div( + f'{k}: {v}', + style={ + 'color': '#BDC3C7', + 'margin': '3px 0', + 'font-family': 'monospace', + 'font-size': '12px', + 'line-height': '1.3', + 'padding-left': '10px', + }, + ) + ) + else: + components.append( + html.Div( + str(parameters), + style={ + 'color': '#BDC3C7', + 'font-family': 'monospace', + 'font-size': '12px', + 'white-space': 'pre-wrap', + 'padding-left': '10px', + }, + ) + ) + + return components + + def display_edge_info(data): + """Display edge information""" + source = data.get('source', '') + target = data.get('target', '') + label = data.get('label', '') + parameters = data.get('parameters', '') + + components = [ + html.H5( + f'Edge: {label} ({source} → {target}) ', + style={ + 'color': 'white', + 'margin-bottom': '15px', + 'border-bottom': '1px solid #E67E22', + 'padding-bottom': '5px', + }, + ) + ] + + # Add parameters section (same formatting as nodes) + if parameters: + components.append( + html.Div( + [ + html.Strong( + 'Parameters:', style={'color': '#E67E22', 'display': 'block', 'margin-bottom': '5px'} + ) + ] + ) + ) + + if isinstance(parameters, str): + lines = parameters.split('\n') + for line in lines: + if line.strip(): + components.append( + html.Div( + line, + style={ + 'color': '#BDC3C7', + 'margin': '3px 0', + 'font-family': 'monospace', + 'font-size': '12px', + 'line-height': '1.3', + 'white-space': 'pre-wrap', + 'padding-left': '10px', + }, + ) + ) + else: + components.append(html.Div(style={'height': '8px'})) + elif isinstance(parameters, dict): + for k, v in parameters.items(): + components.append( + html.Div( + f'{k}: {v}', + style={ + 'color': '#BDC3C7', + 'margin': '3px 0', + 'font-family': 'monospace', + 'font-size': '12px', + 'line-height': '1.3', + 'padding-left': '10px', + }, + ) + ) + else: + components.append( + html.Div( + str(parameters), + style={ + 'color': '#BDC3C7', + 'font-family': 'monospace', + 'font-size': '12px', + 'white-space': 'pre-wrap', + 'padding-left': '10px', + }, + ) + ) + + return components + + # Check which input triggered the callback + if not ctx.triggered: + return [ + html.P( + 'Click on a node or edge to see its parameters.', + style={'color': '#95A5A6', 'font-style': 'italic', 'text-align': 'center', 'margin-top': '20px'}, + ) + ] + + trigger_id = ctx.triggered[0]['prop_id'].split('.')[0] + + # Handle the appropriate trigger + if trigger_id == 'cytoscape' and ctx.triggered[0]['prop_id'] == 'cytoscape.tapEdgeData': + if edge_data: + return display_edge_info(edge_data) + elif trigger_id == 'cytoscape' and ctx.triggered[0]['prop_id'] == 'cytoscape.tapNodeData': + if node_data: + return display_node_info(node_data) + + # Fallback + return [ + html.P( + 'Click on a node or edge to see its parameters.', + style={'color': '#95A5A6', 'font-style': 'italic', 'text-align': 'center', 'margin-top': '20px'}, + ) + ] # Update layout when dropdown changes @app.callback( From 41cfd06ca4466ae8eaf7dd766f349b1f88c9a006 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 8 Aug 2025 18:14:17 +0200 Subject: [PATCH 09/21] FInalize new network.py --- CHANGELOG.md | 3 +++ examples/02_Complex/complex_example.py | 1 + flixopt/flow_system.py | 36 +++++++++++++++++++++++--- flixopt/network.py | 20 +++++++++----- pyproject.toml | 11 +++++++- 5 files changed, 60 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c52c66a2a..905b746ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added `FlowSystem.start_netowk_app()` and `FlowSystem.stop_network_app()` to easily visualize the network structure of a flow system in an interactive web app. This is still experimental and might change in the future. + ## [2.1.5] - 2025-07-08 ### Fixed diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 44ef496d7..175211c26 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -182,6 +182,7 @@ flow_system.add_elements(bhkw_2) if use_chp_with_piecewise_conversion else flow_system.add_elements(bhkw) pprint(flow_system) # Get a string representation of the FlowSystem + flow_system.start_network_app() # Start the network app. DOes only work with extra dependencies installed # --- Solve FlowSystem --- calculation = fx.FullCalculation('complex example', flow_system, time_indices) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 342ede14c..2ec75bc7f 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -244,19 +244,49 @@ def plot_network( return plotting.plot_network(node_infos, edge_infos, path, controls, show) def start_network_app(self): - from .network import shownetwork, flow_graph + """Visualizes the network structure of a FlowSystem using Dash, Cytoscape, and networkx. + Requires optional dependencies: dash, dash-cytoscape, networkx, werkzeug. + """ + from .network import flow_graph, shownetwork, DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR + + if not DASH_CYTOSCAPE_AVAILABLE: + raise ImportError( + f"Network visualization requires optional dependencies. " + f"Install with: pip install flixopt[viz], flixopt[full] or pip install dash dash_cytoscape networkx werkzeug. " + f"Original error: {VISUALIZATION_ERROR}" + ) if not self._connected: self._connect_network() + if self._network_app is not None: + logger.warning('The network app is already running. Restarting it.') + self.stop_network_app() + self._network_app = shownetwork(flow_graph(self)) def stop_network_app(self): + """Stop the network visualization server.""" + from .network import flow_graph, shownetwork, DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR + if not DASH_CYTOSCAPE_AVAILABLE: + raise ImportError( + f'Network visualization requires optional dependencies. ' + f'Install with: pip install flixopt[viz]. ' + f'Original error: {VISUALIZATION_ERROR}' + ) + + if self._network_app is None: + logger.warning('No network app is currently running. Cant stop it') + return + try: + logger.info('Stopping network visualization server...') self._network_app.server_instance.shutdown() - logger.info('Network App stopped.') + logger.info('Network visualization stopped.') except Exception as e: - logger.critical(f'Failed to stop the network visualization app: {e}') + logger.error(f'Failed to stop the network visualization app: {e}') + finally: + self._network_app = None def network_infos(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, str]]]: if not self._connected: diff --git a/flixopt/network.py b/flixopt/network.py index 8250305bb..b07387b6b 100644 --- a/flixopt/network.py +++ b/flixopt/network.py @@ -3,10 +3,16 @@ import socket import threading -import dash_cytoscape as cyto -import networkx -from dash import Dash, Input, Output, State, callback_context, dcc, html -from werkzeug.serving import make_server +try: + import networkx + from werkzeug.serving import make_server + from dash import Dash, Input, Output, State, callback_context, dcc, html + import dash_cytoscape as cyto + DASH_CYTOSCAPE_AVAILABLE = True + VISUALIZATION_ERROR = None +except ImportError as e: + DASH_CYTOSCAPE_AVAILABLE = False + VISUALIZATION_ERROR = str(e) from .components import LinearConverter, Sink, Source, SourceAndSink, Storage from .elements import Bus, Component, Flow @@ -72,7 +78,7 @@ } -def flow_graph(flow_system: FlowSystem) -> networkx.DiGraph: +def flow_graph(flow_system: FlowSystem) -> 'networkx.DiGraph': nodes = list(flow_system.components.values()) + list(flow_system.buses.values()) edges = list(flow_system.flows.values()) @@ -120,7 +126,7 @@ def get_shape(element): return graph -def make_cytoscape_elements(graph: networkx.DiGraph): +def make_cytoscape_elements(graph: 'networkx.DiGraph'): nodes = [{'data': {'id': node, 'label': node, 'color': graph.nodes[node]['color'], @@ -374,7 +380,7 @@ def create_collapsible_sidebar(): ]) -def shownetwork(graph: networkx.DiGraph): +def shownetwork(graph: 'networkx.DiGraph'): app = Dash(__name__, suppress_callback_exceptions=True) elements = make_cytoscape_elements(graph) diff --git a/pyproject.toml b/pyproject.toml index 8c846dc03..7495d133b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,14 +62,23 @@ dev = [ "tsam >= 2.3.1, < 3.0.0", # Time series aggregation "scipy >= 1.15.1, < 2.0.0", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0", + "dash >= 2.0.0", + "dash-cytoscape >= 2.0.0", + "networkx >= 2.8.0", + "werkzeug >= 2.2.3", ] +viz = ["dash >= 2.0.0", "dash-cytoscape >= 2.0.0", "networkx >= 2.8.0", "werkzeug >= 2.2.3"] + full = [ "pyvis == 0.3.1", # Visualizing FlowSystem Network "tsam >= 2.3.1, < 3.0.0", # Time series aggregation "scipy >= 1.15.1, < 2.0.0", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 - "streamlit >= 1.44.0, < 2.0.0", "gurobipy >= 10.0.0", + "dash >= 3.0.0", # Visualizing FlowSystem Network as app + "dash-cytoscape >= 1.0.0", # Visualizing FlowSystem Network as app + "networkx >= 3.0.0", # Visualizing FlowSystem Network as app + "werkzeug >= 3.0.0", # Visualizing FlowSystem Network as app ] docs = [ From 0f05a6d6fd8f5d1b57a2e8a9d95a4b1f6cebb4c2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 8 Aug 2025 18:15:40 +0200 Subject: [PATCH 10/21] Rename module to network_app.py --- flixopt/flow_system.py | 4 ++-- flixopt/{network.py => network_app.py} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename flixopt/{network.py => network_app.py} (100%) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 2ec75bc7f..056494682 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -247,7 +247,7 @@ def start_network_app(self): """Visualizes the network structure of a FlowSystem using Dash, Cytoscape, and networkx. Requires optional dependencies: dash, dash-cytoscape, networkx, werkzeug. """ - from .network import flow_graph, shownetwork, DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR + from .network_app import flow_graph, shownetwork, DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR if not DASH_CYTOSCAPE_AVAILABLE: raise ImportError( @@ -267,7 +267,7 @@ def start_network_app(self): def stop_network_app(self): """Stop the network visualization server.""" - from .network import flow_graph, shownetwork, DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR + from .network_app import DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR if not DASH_CYTOSCAPE_AVAILABLE: raise ImportError( f'Network visualization requires optional dependencies. ' diff --git a/flixopt/network.py b/flixopt/network_app.py similarity index 100% rename from flixopt/network.py rename to flixopt/network_app.py From 04a3ea65e22bedfd9344b9dfd1f7e2b0d0597f2e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 8 Aug 2025 18:23:11 +0200 Subject: [PATCH 11/21] ruff check and format --- flixopt/flow_system.py | 2 +- flixopt/network_app.py | 855 +++++++++++++++++++++++++---------------- 2 files changed, 533 insertions(+), 324 deletions(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 056494682..ffbce538f 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -247,7 +247,7 @@ def start_network_app(self): """Visualizes the network structure of a FlowSystem using Dash, Cytoscape, and networkx. Requires optional dependencies: dash, dash-cytoscape, networkx, werkzeug. """ - from .network_app import flow_graph, shownetwork, DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR + from .network_app import DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR, flow_graph, shownetwork if not DASH_CYTOSCAPE_AVAILABLE: raise ImportError( diff --git a/flixopt/network_app.py b/flixopt/network_app.py index b07387b6b..bcc73007c 100644 --- a/flixopt/network_app.py +++ b/flixopt/network_app.py @@ -4,10 +4,11 @@ import threading try: + import dash_cytoscape as cyto import networkx - from werkzeug.serving import make_server from dash import Dash, Input, Output, State, callback_context, dcc, html - import dash_cytoscape as cyto + from werkzeug.serving import make_server + DASH_CYTOSCAPE_AVAILABLE = True VISUALIZATION_ERROR = None except ImportError as e: @@ -36,21 +37,21 @@ 'shape': 'data(shape)', 'text-outline-color': 'black', 'text-outline-width': 0.5, - } + }, }, { 'selector': '[shape = "custom-source"]', 'style': { 'shape': 'polygon', 'shape-polygon-points': '-0.5 0.5, 0.5 0.5, 1 -0.5, -1 -0.5', - } + }, }, { 'selector': '[shape = "custom-sink"]', 'style': { 'shape': 'polygon', 'shape-polygon-points': '-0.5 -0.5, 0.5 -0.5, 1 0.5, -1 0.5', - } + }, }, { 'selector': 'edge', @@ -61,20 +62,44 @@ 'target-arrow-color': 'gray', 'target-arrow-shape': 'triangle', 'arrow-scale': 2, - } - } + }, + }, ] # Color presets for different node types color_presets = { - 'Default': {'Bus': '#7F8C8D', 'Source': '#F1C40F', 'Sink': '#F1C40F', 'Storage': '#2980B9', 'Converter': '#D35400', - 'Other': '#27AE60'}, - 'Vibrant': {'Bus': '#FF6B6B', 'Source': '#4ECDC4', 'Sink': '#45B7D1', 'Storage': '#96CEB4', 'Converter': '#FFEAA7', - 'Other': '#DDA0DD'}, - 'Dark': {'Bus': '#2C3E50', 'Source': '#34495E', 'Sink': '#7F8C8D', 'Storage': '#95A5A6', 'Converter': '#BDC3C7', - 'Other': '#ECF0F1'}, - 'Pastel': {'Bus': '#FFB3BA', 'Source': '#BAFFC9', 'Sink': '#BAE1FF', 'Storage': '#FFFFBA', 'Converter': '#FFDFBA', - 'Other': '#E0BBE4'} + 'Default': { + 'Bus': '#7F8C8D', + 'Source': '#F1C40F', + 'Sink': '#F1C40F', + 'Storage': '#2980B9', + 'Converter': '#D35400', + 'Other': '#27AE60', + }, + 'Vibrant': { + 'Bus': '#FF6B6B', + 'Source': '#4ECDC4', + 'Sink': '#45B7D1', + 'Storage': '#96CEB4', + 'Converter': '#FFEAA7', + 'Other': '#DDA0DD', + }, + 'Dark': { + 'Bus': '#2C3E50', + 'Source': '#34495E', + 'Sink': '#7F8C8D', + 'Storage': '#95A5A6', + 'Converter': '#BDC3C7', + 'Other': '#ECF0F1', + }, + 'Pastel': { + 'Bus': '#FFB3BA', + 'Source': '#BAFFC9', + 'Sink': '#BAE1FF', + 'Storage': '#FFFFBA', + 'Converter': '#FFDFBA', + 'Other': '#E0BBE4', + }, } @@ -127,257 +152,410 @@ def get_shape(element): def make_cytoscape_elements(graph: 'networkx.DiGraph'): - nodes = [{'data': {'id': node, - 'label': node, - 'color': graph.nodes[node]['color'], - 'shape': graph.nodes[node]['shape'], - 'parameters': graph.nodes[node].get('parameters', {})}} - for node in graph.nodes()] + nodes = [ + { + 'data': { + 'id': node, + 'label': node, + 'color': graph.nodes[node]['color'], + 'shape': graph.nodes[node]['shape'], + 'parameters': graph.nodes[node].get('parameters', {}), + } + } + for node in graph.nodes() + ] # Enhanced edges with parameters and labels - edges = [{'data': {'source': u, - 'target': v, - 'id': f"{u}-{v}", # Add unique edge ID - 'label': graph.edges[u, v].get('label', ''), - 'parameters': graph.edges[u, v].get('parameters', '')}} - for u, v in graph.edges()] + edges = [ + { + 'data': { + 'source': u, + 'target': v, + 'id': f'{u}-{v}', # Add unique edge ID + 'label': graph.edges[u, v].get('label', ''), + 'parameters': graph.edges[u, v].get('parameters', ''), + } + } + for u, v in graph.edges() + ] return nodes + edges def create_style_section(title, children): """Create a collapsible section for organizing controls""" - return html.Div([ - html.H4(title, style={ - 'color': 'white', - 'margin-bottom': '10px', - 'border-bottom': '2px solid #3498DB', - 'padding-bottom': '5px' - }), - html.Div(children, style={'margin-bottom': '20px'}) - ]) + return html.Div( + [ + html.H4( + title, + style={ + 'color': 'white', + 'margin-bottom': '10px', + 'border-bottom': '2px solid #3498DB', + 'padding-bottom': '5px', + }, + ), + html.Div(children, style={'margin-bottom': '20px'}), + ] + ) def create_collapsible_sidebar(): """Create a collapsible sidebar with toggle functionality""" - return html.Div([ - # Sidebar content - html.Div([ - html.H3("Style Controls", style={ - 'color': 'white', - 'margin-bottom': '20px', - 'text-align': 'center', - 'border-bottom': '3px solid #9B59B6', - 'padding-bottom': '10px' - }), - - # Layout Controls - create_style_section("Layout", [ - dcc.Dropdown( - id='layout-dropdown', - options=[ - {'label': 'Klay (horizontal)', 'value': 'klay'}, - {'label': 'Dagre (vertical)', 'value': 'dagre'}, - {'label': 'Breadthfirst', 'value': 'breadthfirst'}, - {'label': 'Cose (force-directed)', 'value': 'cose'}, - {'label': 'Grid', 'value': 'grid'}, - {'label': 'Circle', 'value': 'circle'}, - ], - value='klay', - clearable=False, - style={'width': '100%'} - ) - ]), - - # Color Scheme Section - create_style_section("Color Scheme", [ - dcc.Dropdown( - id='color-scheme-dropdown', - options=[{'label': k, 'value': k} for k in color_presets.keys()], - value='Default', - style={'width': '100%', 'margin-bottom': '10px'} - ) - ]), - - # Custom Colors Section - create_style_section("Custom Colors", [ - html.Div([ - html.Label("Bus", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='bus-color-input', type='text', value='#7F8C8D', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Source", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='source-color-input', type='text', value='#F1C40F', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Sink", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='sink-color-input', type='text', value='#F1C40F', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Storage", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='storage-color-input', type='text', value='#2980B9', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Converter", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='converter-color-input', type='text', value='#D35400', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Edge", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='edge-color-input', type='text', value='gray', - style={'width': '100%', 'margin-bottom': '8px'}) - ]) - ]), - - # Node Styling Section - create_style_section("Node Styling", [ - html.Div([ - html.Label("Node Size", style={'color': 'white', 'font-size': '12px'}), - dcc.Slider( - id='node-size-slider', - min=50, max=150, step=10, value=90, - marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} - for i in range(50, 151, 25)}, - tooltip={"placement": "bottom", "always_visible": True} - ) - ], style={'margin-bottom': '15px'}), - html.Div([ - html.Label("Font Size", style={'color': 'white', 'font-size': '12px'}), - dcc.Slider( - id='font-size-slider', - min=8, max=20, step=1, value=10, - marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} - for i in range(8, 21, 2)}, - tooltip={"placement": "bottom", "always_visible": True} - ) - ], style={'margin-bottom': '15px'}) - ]), - - # Text Styling Section - create_style_section("Text Styling", [ - html.Div([ - html.Label("Text Color", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='text-color-input', type='text', value='white', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Text Outline", style={'color': 'white', 'font-size': '12px'}), - dcc.Input(id='text-outline-input', type='text', value='black', - style={'width': '100%', 'margin-bottom': '8px'}) - ]), - html.Div([ - html.Label("Text Position", style={'color': 'white', 'font-size': '12px'}), - dcc.Dropdown( - id='text-valign-dropdown', - options=[ - {'label': 'Top', 'value': 'top'}, - {'label': 'Center', 'value': 'center'}, - {'label': 'Bottom', 'value': 'bottom'} + return html.Div( + [ + # Sidebar content + html.Div( + [ + html.H3( + 'Style Controls', + style={ + 'color': 'white', + 'margin-bottom': '20px', + 'text-align': 'center', + 'border-bottom': '3px solid #9B59B6', + 'padding-bottom': '10px', + }, + ), + # Layout Controls + create_style_section( + 'Layout', + [ + dcc.Dropdown( + id='layout-dropdown', + options=[ + {'label': 'Klay (horizontal)', 'value': 'klay'}, + {'label': 'Dagre (vertical)', 'value': 'dagre'}, + {'label': 'Breadthfirst', 'value': 'breadthfirst'}, + {'label': 'Cose (force-directed)', 'value': 'cose'}, + {'label': 'Grid', 'value': 'grid'}, + {'label': 'Circle', 'value': 'circle'}, + ], + value='klay', + clearable=False, + style={'width': '100%'}, + ) ], - value='center', - style={'width': '100%', 'margin-bottom': '8px'} - ) - ]), - html.Div([ - html.Label("Text Alignment", style={'color': 'white', 'font-size': '12px'}), - dcc.Dropdown( - id='text-halign-dropdown', - options=[ - {'label': 'Left', 'value': 'left'}, - {'label': 'Center', 'value': 'center'}, - {'label': 'Right', 'value': 'right'} + ), + # Color Scheme Section + create_style_section( + 'Color Scheme', + [ + dcc.Dropdown( + id='color-scheme-dropdown', + options=[{'label': k, 'value': k} for k in color_presets.keys()], + value='Default', + style={'width': '100%', 'margin-bottom': '10px'}, + ) ], - value='center', - style={'width': '100%', 'margin-bottom': '8px'} - ) - ]) - ]), - - # Edge Styling Section - create_style_section("Edge Styling", [ - html.Div([ - html.Label("Edge Width", style={'color': 'white', 'font-size': '12px'}), - dcc.Slider( - id='edge-width-slider', - min=1, max=10, step=1, value=2, - marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} - for i in range(1, 11)}, - tooltip={"placement": "bottom", "always_visible": True} - ) - ], style={'margin-bottom': '15px'}), - html.Div([ - html.Label("Edge Curve", style={'color': 'white', 'font-size': '12px'}), - dcc.Dropdown( - id='edge-curve-dropdown', - options=[ - {'label': 'Straight', 'value': 'straight'}, - {'label': 'Bezier', 'value': 'bezier'}, - {'label': 'Unbundled Bezier', 'value': 'unbundled-bezier'}, - {'label': 'Segments', 'value': 'segments'} + ), + # Custom Colors Section + create_style_section( + 'Custom Colors', + [ + html.Div( + [ + html.Label('Bus', style={'color': 'white', 'font-size': '12px'}), + dcc.Input( + id='bus-color-input', + type='text', + value='#7F8C8D', + style={'width': '100%', 'margin-bottom': '8px'}, + ), + ] + ), + html.Div( + [ + html.Label('Source', style={'color': 'white', 'font-size': '12px'}), + dcc.Input( + id='source-color-input', + type='text', + value='#F1C40F', + style={'width': '100%', 'margin-bottom': '8px'}, + ), + ] + ), + html.Div( + [ + html.Label('Sink', style={'color': 'white', 'font-size': '12px'}), + dcc.Input( + id='sink-color-input', + type='text', + value='#F1C40F', + style={'width': '100%', 'margin-bottom': '8px'}, + ), + ] + ), + html.Div( + [ + html.Label('Storage', style={'color': 'white', 'font-size': '12px'}), + dcc.Input( + id='storage-color-input', + type='text', + value='#2980B9', + style={'width': '100%', 'margin-bottom': '8px'}, + ), + ] + ), + html.Div( + [ + html.Label('Converter', style={'color': 'white', 'font-size': '12px'}), + dcc.Input( + id='converter-color-input', + type='text', + value='#D35400', + style={'width': '100%', 'margin-bottom': '8px'}, + ), + ] + ), + html.Div( + [ + html.Label('Edge', style={'color': 'white', 'font-size': '12px'}), + dcc.Input( + id='edge-color-input', + type='text', + value='gray', + style={'width': '100%', 'margin-bottom': '8px'}, + ), + ] + ), ], - value='straight', - style={'width': '100%', 'margin-bottom': '8px'} - ) - ]), - html.Div([ - html.Label("Arrow Style", style={'color': 'white', 'font-size': '12px'}), - dcc.Dropdown( - id='arrow-style-dropdown', - options=[ - {'label': 'Triangle', 'value': 'triangle'}, - {'label': 'Triangle (Tee)', 'value': 'triangle-tee'}, - {'label': 'Circle', 'value': 'circle'}, - {'label': 'Square', 'value': 'square'}, - {'label': 'Diamond', 'value': 'diamond'}, - {'label': 'None', 'value': 'none'} + ), + # Node Styling Section + create_style_section( + 'Node Styling', + [ + html.Div( + [ + html.Label('Node Size', style={'color': 'white', 'font-size': '12px'}), + dcc.Slider( + id='node-size-slider', + min=50, + max=150, + step=10, + value=90, + marks={ + i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(50, 151, 25) + }, + tooltip={'placement': 'bottom', 'always_visible': True}, + ), + ], + style={'margin-bottom': '15px'}, + ), + html.Div( + [ + html.Label('Font Size', style={'color': 'white', 'font-size': '12px'}), + dcc.Slider( + id='font-size-slider', + min=8, + max=20, + step=1, + value=10, + marks={ + i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(8, 21, 2) + }, + tooltip={'placement': 'bottom', 'always_visible': True}, + ), + ], + style={'margin-bottom': '15px'}, + ), ], - value='triangle', - style={'width': '100%', 'margin-bottom': '8px'} - ) - ]) - ]), - - # Advanced Section - create_style_section("Advanced", [ - html.Div([ - html.Label("Custom Stylesheet (JSON)", style={'color': 'white', 'font-size': '12px'}), - dcc.Textarea( - id='custom-stylesheet-textarea', - placeholder='Enter custom Cytoscape stylesheet as JSON...', - style={'width': '100%', 'height': '120px', 'background-color': '#34495E', - 'color': 'white', 'font-size': '11px', 'margin-bottom': '10px'}, - value=json.dumps(default_cytoscape_stylesheet, indent=2) - ) - ]), - html.Div([ - html.Button("Apply Custom", id="apply-custom-btn", n_clicks=0, - style={'width': '48%', 'margin-right': '4%', 'background-color': '#3498DB', - 'color': 'white', 'border': 'none', 'padding': '8px', 'border-radius': '3px'}), - html.Button("Reset Default", id="reset-style-btn", n_clicks=0, - style={'width': '48%', 'background-color': '#E74C3C', - 'color': 'white', 'border': 'none', 'padding': '8px', 'border-radius': '3px'}) - ]) - ]) - ], id='sidebar-content', style={ - 'width': '280px', - 'height': '100vh', - 'background-color': '#2C3E50', - 'padding': '20px', - 'position': 'fixed', - 'left': '0', - 'top': '0', - 'overflow-y': 'auto', - 'border-right': '3px solid #34495E', - 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', - 'transform': 'translateX(-100%)', # Initially hidden - 'transition': 'transform 0.3s ease', - 'z-index': '999' - }) - ]) + ), + # Text Styling Section + create_style_section( + 'Text Styling', + [ + html.Div( + [ + html.Label('Text Color', style={'color': 'white', 'font-size': '12px'}), + dcc.Input( + id='text-color-input', + type='text', + value='white', + style={'width': '100%', 'margin-bottom': '8px'}, + ), + ] + ), + html.Div( + [ + html.Label('Text Outline', style={'color': 'white', 'font-size': '12px'}), + dcc.Input( + id='text-outline-input', + type='text', + value='black', + style={'width': '100%', 'margin-bottom': '8px'}, + ), + ] + ), + html.Div( + [ + html.Label('Text Position', style={'color': 'white', 'font-size': '12px'}), + dcc.Dropdown( + id='text-valign-dropdown', + options=[ + {'label': 'Top', 'value': 'top'}, + {'label': 'Center', 'value': 'center'}, + {'label': 'Bottom', 'value': 'bottom'}, + ], + value='center', + style={'width': '100%', 'margin-bottom': '8px'}, + ), + ] + ), + html.Div( + [ + html.Label('Text Alignment', style={'color': 'white', 'font-size': '12px'}), + dcc.Dropdown( + id='text-halign-dropdown', + options=[ + {'label': 'Left', 'value': 'left'}, + {'label': 'Center', 'value': 'center'}, + {'label': 'Right', 'value': 'right'}, + ], + value='center', + style={'width': '100%', 'margin-bottom': '8px'}, + ), + ] + ), + ], + ), + # Edge Styling Section + create_style_section( + 'Edge Styling', + [ + html.Div( + [ + html.Label('Edge Width', style={'color': 'white', 'font-size': '12px'}), + dcc.Slider( + id='edge-width-slider', + min=1, + max=10, + step=1, + value=2, + marks={ + i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(1, 11) + }, + tooltip={'placement': 'bottom', 'always_visible': True}, + ), + ], + style={'margin-bottom': '15px'}, + ), + html.Div( + [ + html.Label('Edge Curve', style={'color': 'white', 'font-size': '12px'}), + dcc.Dropdown( + id='edge-curve-dropdown', + options=[ + {'label': 'Straight', 'value': 'straight'}, + {'label': 'Bezier', 'value': 'bezier'}, + {'label': 'Unbundled Bezier', 'value': 'unbundled-bezier'}, + {'label': 'Segments', 'value': 'segments'}, + ], + value='straight', + style={'width': '100%', 'margin-bottom': '8px'}, + ), + ] + ), + html.Div( + [ + html.Label('Arrow Style', style={'color': 'white', 'font-size': '12px'}), + dcc.Dropdown( + id='arrow-style-dropdown', + options=[ + {'label': 'Triangle', 'value': 'triangle'}, + {'label': 'Triangle (Tee)', 'value': 'triangle-tee'}, + {'label': 'Circle', 'value': 'circle'}, + {'label': 'Square', 'value': 'square'}, + {'label': 'Diamond', 'value': 'diamond'}, + {'label': 'None', 'value': 'none'}, + ], + value='triangle', + style={'width': '100%', 'margin-bottom': '8px'}, + ), + ] + ), + ], + ), + # Advanced Section + create_style_section( + 'Advanced', + [ + html.Div( + [ + html.Label( + 'Custom Stylesheet (JSON)', style={'color': 'white', 'font-size': '12px'} + ), + dcc.Textarea( + id='custom-stylesheet-textarea', + placeholder='Enter custom Cytoscape stylesheet as JSON...', + style={ + 'width': '100%', + 'height': '120px', + 'background-color': '#34495E', + 'color': 'white', + 'font-size': '11px', + 'margin-bottom': '10px', + }, + value=json.dumps(default_cytoscape_stylesheet, indent=2), + ), + ] + ), + html.Div( + [ + html.Button( + 'Apply Custom', + id='apply-custom-btn', + n_clicks=0, + style={ + 'width': '48%', + 'margin-right': '4%', + 'background-color': '#3498DB', + 'color': 'white', + 'border': 'none', + 'padding': '8px', + 'border-radius': '3px', + }, + ), + html.Button( + 'Reset Default', + id='reset-style-btn', + n_clicks=0, + style={ + 'width': '48%', + 'background-color': '#E74C3C', + 'color': 'white', + 'border': 'none', + 'padding': '8px', + 'border-radius': '3px', + }, + ), + ] + ), + ], + ), + ], + id='sidebar-content', + style={ + 'width': '280px', + 'height': '100vh', + 'background-color': '#2C3E50', + 'padding': '20px', + 'position': 'fixed', + 'left': '0', + 'top': '0', + 'overflow-y': 'auto', + 'border-right': '3px solid #34495E', + 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', + 'transform': 'translateX(-100%)', # Initially hidden + 'transition': 'transform 0.3s ease', + 'z-index': '999', + }, + ) + ] + ) def shownetwork(graph: 'networkx.DiGraph'): @@ -498,9 +676,7 @@ def shownetwork(graph: 'networkx.DiGraph'): # Toggle sidebar visibility @app.callback( - [Output('sidebar-content', 'style'), - Output('main-content', 'style')], - [Input('toggle-sidebar', 'n_clicks')] + [Output('sidebar-content', 'style'), Output('main-content', 'style')], [Input('toggle-sidebar', 'n_clicks')] ) def toggle_sidebar(n_clicks): if n_clicks % 2 == 1: # Sidebar is open @@ -517,13 +693,13 @@ def toggle_sidebar(n_clicks): 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', 'transform': 'translateX(0)', 'transition': 'transform 0.3s ease', - 'z-index': '999' + 'z-index': '999', } main_style = { 'margin-left': '280px', 'background-color': '#1A252F', 'min-height': '100vh', - 'transition': 'margin-left 0.3s ease' + 'transition': 'margin-left 0.3s ease', } else: # Sidebar is closed sidebar_style = { @@ -539,38 +715,40 @@ def toggle_sidebar(n_clicks): 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', 'transform': 'translateX(-100%)', 'transition': 'transform 0.3s ease', - 'z-index': '999' + 'z-index': '999', } main_style = { 'margin-left': '0', 'background-color': '#1A252F', 'min-height': '100vh', - 'transition': 'margin-left 0.3s ease' + 'transition': 'margin-left 0.3s ease', } return sidebar_style, main_style # Reset all controls to defaults @app.callback( - [Output('color-scheme-dropdown', 'value'), - Output('bus-color-input', 'value'), - Output('source-color-input', 'value'), - Output('sink-color-input', 'value'), - Output('storage-color-input', 'value'), - Output('converter-color-input', 'value'), - Output('edge-color-input', 'value'), - Output('text-color-input', 'value'), - Output('text-outline-input', 'value'), - Output('text-valign-dropdown', 'value'), - Output('text-halign-dropdown', 'value'), - Output('node-size-slider', 'value'), - Output('font-size-slider', 'value'), - Output('edge-width-slider', 'value'), - Output('edge-curve-dropdown', 'value'), - Output('arrow-style-dropdown', 'value'), - Output('layout-dropdown', 'value'), - Output('custom-stylesheet-textarea', 'value')], - [Input('reset-style-btn', 'n_clicks')] + [ + Output('color-scheme-dropdown', 'value'), + Output('bus-color-input', 'value'), + Output('source-color-input', 'value'), + Output('sink-color-input', 'value'), + Output('storage-color-input', 'value'), + Output('converter-color-input', 'value'), + Output('edge-color-input', 'value'), + Output('text-color-input', 'value'), + Output('text-outline-input', 'value'), + Output('text-valign-dropdown', 'value'), + Output('text-halign-dropdown', 'value'), + Output('node-size-slider', 'value'), + Output('font-size-slider', 'value'), + Output('edge-width-slider', 'value'), + Output('edge-curve-dropdown', 'value'), + Output('arrow-style-dropdown', 'value'), + Output('layout-dropdown', 'value'), + Output('custom-stylesheet-textarea', 'value'), + ], + [Input('reset-style-btn', 'n_clicks')], ) def reset_all_controls(reset_clicks): if reset_clicks and reset_clicks > 0: @@ -592,42 +770,74 @@ def reset_all_controls(reset_clicks): 'straight', # edge-curve-dropdown 'triangle', # arrow-style-dropdown 'klay', # layout-dropdown - json.dumps(default_cytoscape_stylesheet, indent=2) # custom-stylesheet-textarea + json.dumps(default_cytoscape_stylesheet, indent=2), # custom-stylesheet-textarea ) # Return current values if no reset return ( - 'Default', '#7F8C8D', '#F1C40F', '#F1C40F', '#2980B9', '#D35400', 'gray', - 'white', 'black', 'center', 'center', 90, 10, 2, 'straight', 'triangle', 'klay', - json.dumps(default_cytoscape_stylesheet, indent=2) + 'Default', + '#7F8C8D', + '#F1C40F', + '#F1C40F', + '#2980B9', + '#D35400', + 'gray', + 'white', + 'black', + 'center', + 'center', + 90, + 10, + 2, + 'straight', + 'triangle', + 'klay', + json.dumps(default_cytoscape_stylesheet, indent=2), ) # Update elements and stylesheet based on controls @app.callback( - [Output('cytoscape', 'elements'), - Output('cytoscape', 'stylesheet')], - [Input('color-scheme-dropdown', 'value'), - Input('bus-color-input', 'value'), - Input('source-color-input', 'value'), - Input('sink-color-input', 'value'), - Input('storage-color-input', 'value'), - Input('converter-color-input', 'value'), - Input('edge-color-input', 'value'), - Input('text-color-input', 'value'), - Input('text-outline-input', 'value'), - Input('text-valign-dropdown', 'value'), - Input('text-halign-dropdown', 'value'), - Input('node-size-slider', 'value'), - Input('font-size-slider', 'value'), - Input('edge-width-slider', 'value'), - Input('edge-curve-dropdown', 'value'), - Input('arrow-style-dropdown', 'value'), - Input('apply-custom-btn', 'n_clicks')], - [State('custom-stylesheet-textarea', 'value')] + [Output('cytoscape', 'elements'), Output('cytoscape', 'stylesheet')], + [ + Input('color-scheme-dropdown', 'value'), + Input('bus-color-input', 'value'), + Input('source-color-input', 'value'), + Input('sink-color-input', 'value'), + Input('storage-color-input', 'value'), + Input('converter-color-input', 'value'), + Input('edge-color-input', 'value'), + Input('text-color-input', 'value'), + Input('text-outline-input', 'value'), + Input('text-valign-dropdown', 'value'), + Input('text-halign-dropdown', 'value'), + Input('node-size-slider', 'value'), + Input('font-size-slider', 'value'), + Input('edge-width-slider', 'value'), + Input('edge-curve-dropdown', 'value'), + Input('arrow-style-dropdown', 'value'), + Input('apply-custom-btn', 'n_clicks'), + ], + [State('custom-stylesheet-textarea', 'value')], ) - def update_elements_and_stylesheet(color_scheme, bus_color, source_color, sink_color, storage_color, - converter_color, edge_color, text_color, text_outline, - text_valign, text_halign, node_size, font_size, edge_width, - edge_curve, arrow_style, apply_clicks, custom_style): + def update_elements_and_stylesheet( + color_scheme, + bus_color, + source_color, + sink_color, + storage_color, + converter_color, + edge_color, + text_color, + text_outline, + text_valign, + text_halign, + node_size, + font_size, + edge_width, + edge_curve, + arrow_style, + apply_clicks, + custom_style, + ): ctx = callback_context if ctx.triggered: @@ -641,14 +851,16 @@ def update_elements_and_stylesheet(color_scheme, bus_color, source_color, sink_c return elements, default_cytoscape_stylesheet # Determine which colors to use - use_custom_colors = any([ - bus_color and bus_color != '#7F8C8D', - source_color and source_color != '#F1C40F', - sink_color and sink_color != '#F1C40F', - storage_color and storage_color != '#2980B9', - converter_color and converter_color != '#D35400', - edge_color and edge_color != 'gray' - ]) + use_custom_colors = any( + [ + bus_color and bus_color != '#7F8C8D', + source_color and source_color != '#F1C40F', + sink_color and sink_color != '#F1C40F', + storage_color and storage_color != '#2980B9', + converter_color and converter_color != '#D35400', + edge_color and edge_color != 'gray', + ] + ) if use_custom_colors: colors = { @@ -657,7 +869,7 @@ def update_elements_and_stylesheet(color_scheme, bus_color, source_color, sink_c 'Sink': sink_color or '#F1C40F', 'Storage': storage_color or '#2980B9', 'Converter': converter_color or '#D35400', - 'Other': converter_color or '#27AE60' + 'Other': converter_color or '#27AE60', } else: colors = color_presets.get(color_scheme, color_presets['Default']) @@ -710,21 +922,21 @@ def update_elements_and_stylesheet(color_scheme, bus_color, source_color, sink_c 'shape': 'data(shape)', 'text-outline-color': text_outline or 'black', 'text-outline-width': 0.5, - } + }, }, { 'selector': '[shape = "custom-source"]', 'style': { 'shape': 'polygon', 'shape-polygon-points': '-0.5 0.5, 0.5 0.5, 1 -0.5, -1 -0.5', - } + }, }, { 'selector': '[shape = "custom-sink"]', 'style': { 'shape': 'polygon', 'shape-polygon-points': '-0.5 -0.5, 0.5 -0.5, 1 0.5, -1 0.5', - } + }, }, { 'selector': 'edge', @@ -735,8 +947,8 @@ def update_elements_and_stylesheet(color_scheme, bus_color, source_color, sink_c 'target-arrow-color': edge_color or 'gray', 'target-arrow-shape': arrow_style or 'triangle', 'arrow-scale': 2, - } - } + }, + }, ] return updated_elements, stylesheet @@ -941,10 +1153,7 @@ def display_edge_info(data): ] # Update layout when dropdown changes - @app.callback( - Output('cytoscape', 'layout'), - Input('layout-dropdown', 'value') - ) + @app.callback(Output('cytoscape', 'layout'), Input('layout-dropdown', 'value')) def update_layout(selected_layout): return {'name': selected_layout} @@ -966,7 +1175,7 @@ def update_layout(selected_layout): } """, Output('btn-image', 'children'), - Input('btn-image', 'n_clicks') + Input('btn-image', 'n_clicks'), ) # Find a free port From afb0affb0a7a8535e96307129c8eb7c7cbfafa32 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 8 Aug 2025 18:55:07 +0200 Subject: [PATCH 12/21] fix dependencies --- pyproject.toml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7495d133b..01b85340e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,23 +62,23 @@ dev = [ "tsam >= 2.3.1, < 3.0.0", # Time series aggregation "scipy >= 1.15.1, < 2.0.0", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0", - "dash >= 2.0.0", - "dash-cytoscape >= 2.0.0", - "networkx >= 2.8.0", - "werkzeug >= 2.2.3", + "dash >= 3", + "dash-cytoscape >= 1", + "networkx >= 3", + "werkzeug >= 3", ] -viz = ["dash >= 2.0.0", "dash-cytoscape >= 2.0.0", "networkx >= 2.8.0", "werkzeug >= 2.2.3"] +viz = ["dash >= 3", "dash-cytoscape >= 1", "networkx >= 3", "werkzeug >= 3"] full = [ "pyvis == 0.3.1", # Visualizing FlowSystem Network "tsam >= 2.3.1, < 3.0.0", # Time series aggregation "scipy >= 1.15.1, < 2.0.0", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0", - "dash >= 3.0.0", # Visualizing FlowSystem Network as app - "dash-cytoscape >= 1.0.0", # Visualizing FlowSystem Network as app - "networkx >= 3.0.0", # Visualizing FlowSystem Network as app - "werkzeug >= 3.0.0", # Visualizing FlowSystem Network as app + "dash >= 3", # Visualizing FlowSystem Network as app + "dash-cytoscape >= 1", # Visualizing FlowSystem Network as app + "networkx >= 3", # Visualizing FlowSystem Network as app + "werkzeug >= 3", # Visualizing FlowSystem Network as app ] docs = [ From e80d2cbbc4d1a1c63f6b98150ca0e2874ada43fb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:11:01 +0200 Subject: [PATCH 13/21] Improve network app --- flixopt/network_app.py | 1487 ++++++++++++---------------------------- pyproject.toml | 9 +- 2 files changed, 449 insertions(+), 1047 deletions(-) diff --git a/flixopt/network_app.py b/flixopt/network_app.py index bcc73007c..419c02692 100644 --- a/flixopt/network_app.py +++ b/flixopt/network_app.py @@ -2,11 +2,13 @@ import logging import socket import threading +from typing import Any, Dict, List try: import dash_cytoscape as cyto + import dash_daq as daq import networkx - from dash import Dash, Input, Output, State, callback_context, dcc, html + from dash import Dash, Input, Output, State, callback_context, dcc, html, no_update from werkzeug.serving import make_server DASH_CYTOSCAPE_AVAILABLE = True @@ -21,892 +23,433 @@ logger = logging.getLogger('flixopt') -# Default stylesheet (can be reset to this) -default_cytoscape_stylesheet = [ - { - 'selector': 'node', - 'style': { - 'content': 'data(label)', - 'background-color': 'data(color)', - 'font-size': 10, - 'color': 'white', - 'text-valign': 'center', - 'text-halign': 'center', - 'width': '90px', - 'height': '70px', - 'shape': 'data(shape)', - 'text-outline-color': 'black', - 'text-outline-width': 0.5, - }, - }, - { - 'selector': '[shape = "custom-source"]', - 'style': { - 'shape': 'polygon', - 'shape-polygon-points': '-0.5 0.5, 0.5 0.5, 1 -0.5, -1 -0.5', - }, - }, - { - 'selector': '[shape = "custom-sink"]', - 'style': { - 'shape': 'polygon', - 'shape-polygon-points': '-0.5 -0.5, 0.5 -0.5, 1 0.5, -1 0.5', - }, - }, - { - 'selector': 'edge', - 'style': { - 'curve-style': 'straight', - 'width': 2, - 'line-color': 'gray', - 'target-arrow-color': 'gray', - 'target-arrow-shape': 'triangle', - 'arrow-scale': 2, - }, - }, -] +# Configuration class for better organization +class VisualizationConfig: + """Configuration constants for the visualization""" -# Color presets for different node types -color_presets = { - 'Default': { + DEFAULT_COLORS = { 'Bus': '#7F8C8D', 'Source': '#F1C40F', 'Sink': '#F1C40F', 'Storage': '#2980B9', 'Converter': '#D35400', 'Other': '#27AE60', - }, - 'Vibrant': { - 'Bus': '#FF6B6B', - 'Source': '#4ECDC4', - 'Sink': '#45B7D1', - 'Storage': '#96CEB4', - 'Converter': '#FFEAA7', - 'Other': '#DDA0DD', - }, - 'Dark': { - 'Bus': '#2C3E50', - 'Source': '#34495E', - 'Sink': '#7F8C8D', - 'Storage': '#95A5A6', - 'Converter': '#BDC3C7', - 'Other': '#ECF0F1', - }, - 'Pastel': { - 'Bus': '#FFB3BA', - 'Source': '#BAFFC9', - 'Sink': '#BAE1FF', - 'Storage': '#FFFFBA', - 'Converter': '#FFDFBA', - 'Other': '#E0BBE4', - }, -} + } + COLOR_PRESETS = { + 'Default': DEFAULT_COLORS, + 'Vibrant': { + 'Bus': '#FF6B6B', 'Source': '#4ECDC4', 'Sink': '#45B7D1', + 'Storage': '#96CEB4', 'Converter': '#FFEAA7', 'Other': '#DDA0DD', + }, + 'Dark': { + 'Bus': '#2C3E50', 'Source': '#34495E', 'Sink': '#7F8C8D', + 'Storage': '#95A5A6', 'Converter': '#BDC3C7', 'Other': '#ECF0F1', + }, + 'Pastel': { + 'Bus': '#FFB3BA', 'Source': '#BAFFC9', 'Sink': '#BAE1FF', + 'Storage': '#FFFFBA', 'Converter': '#FFDFBA', 'Other': '#E0BBE4', + }, + } -def flow_graph(flow_system: FlowSystem) -> 'networkx.DiGraph': + DEFAULT_STYLESHEET = [ + { + 'selector': 'node', + 'style': { + 'content': 'data(label)', + 'background-color': 'data(color)', + 'font-size': 10, + 'color': 'white', + 'text-valign': 'center', + 'text-halign': 'center', + 'width': '90px', + 'height': '70px', + 'shape': 'data(shape)', + 'text-outline-color': 'black', + 'text-outline-width': 0.5, + }, + }, + { + 'selector': '[shape = "custom-source"]', + 'style': { + 'shape': 'polygon', + 'shape-polygon-points': '-0.5 0.5, 0.5 0.5, 1 -0.5, -1 -0.5', + }, + }, + { + 'selector': '[shape = "custom-sink"]', + 'style': { + 'shape': 'polygon', + 'shape-polygon-points': '-0.5 -0.5, 0.5 -0.5, 1 0.5, -1 0.5', + }, + }, + { + 'selector': 'edge', + 'style': { + 'curve-style': 'straight', + 'width': 2, + 'line-color': 'gray', + 'target-arrow-color': 'gray', + 'target-arrow-shape': 'triangle', + 'arrow-scale': 2, + }, + }, + ] + +def flow_graph(flow_system: FlowSystem) -> networkx.DiGraph: + """Convert FlowSystem to NetworkX graph - simplified and more robust""" nodes = list(flow_system.components.values()) + list(flow_system.buses.values()) edges = list(flow_system.flows.values()) - def get_color(element, color_scheme='Default'): - colors = color_presets[color_scheme] - if isinstance(element, Flow): - raise TypeError('Flow graph shape not yet implemented') + def get_element_type(element): + """Determine element type for coloring""" if isinstance(element, Bus): - return colors['Bus'] - if isinstance(element, (Sink, Source, SourceAndSink)): - return colors['Source'] if isinstance(element, Source) else colors['Sink'] - if isinstance(element, Storage): - return colors['Storage'] - if isinstance(element, LinearConverter): - return colors['Converter'] - return colors['Other'] + return 'Bus' + elif isinstance(element, Source): + return 'Source' + elif isinstance(element, (Sink, SourceAndSink)): + return 'Sink' + elif isinstance(element, Storage): + return 'Storage' + elif isinstance(element, LinearConverter): + return 'Converter' + else: + return 'Other' def get_shape(element): + """Determine node shape""" if isinstance(element, Bus): return 'ellipse' - if isinstance(element, (Source)): + elif isinstance(element, Source): return 'custom-source' - if isinstance(element, (Sink, SourceAndSink)): + elif isinstance(element, (Sink, SourceAndSink)): return 'custom-sink' - return 'rectangle' + else: + return 'rectangle' graph = networkx.DiGraph() + # Add nodes with attributes for node in nodes: graph.add_node( node.label_full, - color=get_color(node), + color=VisualizationConfig.DEFAULT_COLORS[get_element_type(node)], shape=get_shape(node), - parameters=node.__str__(), + element_type=get_element_type(node), + parameters=str(node), ) + # Add edges for edge in edges: - graph.add_edge( - u_of_edge=edge.bus if edge.is_input_in_component else edge.component, - v_of_edge=edge.component if edge.is_input_in_component else edge.bus, - label=edge.label_full, - parameters=edge.__str__().replace(')', '\n)'), - ) + try: + graph.add_edge( + u_of_edge=edge.bus if edge.is_input_in_component else edge.component, + v_of_edge=edge.component if edge.is_input_in_component else edge.bus, + label=edge.label_full, + parameters=edge.__str__().replace(')', '\n)'), + ) + except Exception as e: + logger.error(f"Failed to add edge {edge}: {e}") return graph +def make_cytoscape_elements(graph: networkx.DiGraph) -> List[Dict[str, Any]]: + """Convert NetworkX graph to Cytoscape elements""" + elements = [] -def make_cytoscape_elements(graph: 'networkx.DiGraph'): - nodes = [ - { + # Add nodes + for node_id in graph.nodes(): + node_data = graph.nodes[node_id] + elements.append({ 'data': { - 'id': node, - 'label': node, - 'color': graph.nodes[node]['color'], - 'shape': graph.nodes[node]['shape'], - 'parameters': graph.nodes[node].get('parameters', {}), + 'id': node_id, + 'label': node_id, + 'color': node_data.get('color', '#7F8C8D'), + 'shape': node_data.get('shape', 'rectangle'), + 'element_type': node_data.get('element_type', 'Other'), + 'parameters': node_data.get('parameters', ''), } - } - for node in graph.nodes() - ] + }) - # Enhanced edges with parameters and labels - edges = [ - { + # Add edges + for u, v in graph.edges(): + edge_data = graph.edges[u, v] + elements.append({ 'data': { 'source': u, 'target': v, - 'id': f'{u}-{v}', # Add unique edge ID - 'label': graph.edges[u, v].get('label', ''), - 'parameters': graph.edges[u, v].get('parameters', ''), + 'id': f'{u}-{v}', + 'label': edge_data.get('label', ''), + 'parameters': edge_data.get('parameters', ''), } - } - for u, v in graph.edges() - ] - - return nodes + edges - - -def create_style_section(title, children): + }) + + return elements + +def create_color_picker_input(label: str, input_id: str, default_color: str): + """Create a compact color picker with DAQ ColorPicker""" + return html.Div([ + html.Label(label, style={ + 'color': 'white', 'font-size': '12px', 'margin-bottom': '5px', + 'display': 'block' + }), + daq.ColorPicker( + id=input_id, + label="", + value={'hex': default_color}, + size=200, + theme={'dark': True}, + style={'margin-bottom': '10px'} + ), + ]) + +def create_style_section(title: str, children: List): """Create a collapsible section for organizing controls""" - return html.Div( - [ - html.H4( - title, - style={ - 'color': 'white', - 'margin-bottom': '10px', - 'border-bottom': '2px solid #3498DB', - 'padding-bottom': '5px', - }, - ), - html.Div(children, style={'margin-bottom': '20px'}), - ] - ) - - -def create_collapsible_sidebar(): - """Create a collapsible sidebar with toggle functionality""" - return html.Div( - [ - # Sidebar content - html.Div( - [ - html.H3( - 'Style Controls', - style={ - 'color': 'white', - 'margin-bottom': '20px', - 'text-align': 'center', - 'border-bottom': '3px solid #9B59B6', - 'padding-bottom': '10px', - }, - ), - # Layout Controls - create_style_section( - 'Layout', - [ - dcc.Dropdown( - id='layout-dropdown', - options=[ - {'label': 'Klay (horizontal)', 'value': 'klay'}, - {'label': 'Dagre (vertical)', 'value': 'dagre'}, - {'label': 'Breadthfirst', 'value': 'breadthfirst'}, - {'label': 'Cose (force-directed)', 'value': 'cose'}, - {'label': 'Grid', 'value': 'grid'}, - {'label': 'Circle', 'value': 'circle'}, - ], - value='klay', - clearable=False, - style={'width': '100%'}, - ) - ], - ), - # Color Scheme Section - create_style_section( - 'Color Scheme', - [ - dcc.Dropdown( - id='color-scheme-dropdown', - options=[{'label': k, 'value': k} for k in color_presets.keys()], - value='Default', - style={'width': '100%', 'margin-bottom': '10px'}, - ) - ], - ), - # Custom Colors Section - create_style_section( - 'Custom Colors', - [ - html.Div( - [ - html.Label('Bus', style={'color': 'white', 'font-size': '12px'}), - dcc.Input( - id='bus-color-input', - type='text', - value='#7F8C8D', - style={'width': '100%', 'margin-bottom': '8px'}, - ), - ] - ), - html.Div( - [ - html.Label('Source', style={'color': 'white', 'font-size': '12px'}), - dcc.Input( - id='source-color-input', - type='text', - value='#F1C40F', - style={'width': '100%', 'margin-bottom': '8px'}, - ), - ] - ), - html.Div( - [ - html.Label('Sink', style={'color': 'white', 'font-size': '12px'}), - dcc.Input( - id='sink-color-input', - type='text', - value='#F1C40F', - style={'width': '100%', 'margin-bottom': '8px'}, - ), - ] - ), - html.Div( - [ - html.Label('Storage', style={'color': 'white', 'font-size': '12px'}), - dcc.Input( - id='storage-color-input', - type='text', - value='#2980B9', - style={'width': '100%', 'margin-bottom': '8px'}, - ), - ] - ), - html.Div( - [ - html.Label('Converter', style={'color': 'white', 'font-size': '12px'}), - dcc.Input( - id='converter-color-input', - type='text', - value='#D35400', - style={'width': '100%', 'margin-bottom': '8px'}, - ), - ] - ), - html.Div( - [ - html.Label('Edge', style={'color': 'white', 'font-size': '12px'}), - dcc.Input( - id='edge-color-input', - type='text', - value='gray', - style={'width': '100%', 'margin-bottom': '8px'}, - ), - ] - ), - ], - ), - # Node Styling Section - create_style_section( - 'Node Styling', - [ - html.Div( - [ - html.Label('Node Size', style={'color': 'white', 'font-size': '12px'}), - dcc.Slider( - id='node-size-slider', - min=50, - max=150, - step=10, - value=90, - marks={ - i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} - for i in range(50, 151, 25) - }, - tooltip={'placement': 'bottom', 'always_visible': True}, - ), - ], - style={'margin-bottom': '15px'}, - ), - html.Div( - [ - html.Label('Font Size', style={'color': 'white', 'font-size': '12px'}), - dcc.Slider( - id='font-size-slider', - min=8, - max=20, - step=1, - value=10, - marks={ - i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} - for i in range(8, 21, 2) - }, - tooltip={'placement': 'bottom', 'always_visible': True}, - ), - ], - style={'margin-bottom': '15px'}, - ), - ], - ), - # Text Styling Section - create_style_section( - 'Text Styling', - [ - html.Div( - [ - html.Label('Text Color', style={'color': 'white', 'font-size': '12px'}), - dcc.Input( - id='text-color-input', - type='text', - value='white', - style={'width': '100%', 'margin-bottom': '8px'}, - ), - ] - ), - html.Div( - [ - html.Label('Text Outline', style={'color': 'white', 'font-size': '12px'}), - dcc.Input( - id='text-outline-input', - type='text', - value='black', - style={'width': '100%', 'margin-bottom': '8px'}, - ), - ] - ), - html.Div( - [ - html.Label('Text Position', style={'color': 'white', 'font-size': '12px'}), - dcc.Dropdown( - id='text-valign-dropdown', - options=[ - {'label': 'Top', 'value': 'top'}, - {'label': 'Center', 'value': 'center'}, - {'label': 'Bottom', 'value': 'bottom'}, - ], - value='center', - style={'width': '100%', 'margin-bottom': '8px'}, - ), - ] - ), - html.Div( - [ - html.Label('Text Alignment', style={'color': 'white', 'font-size': '12px'}), - dcc.Dropdown( - id='text-halign-dropdown', - options=[ - {'label': 'Left', 'value': 'left'}, - {'label': 'Center', 'value': 'center'}, - {'label': 'Right', 'value': 'right'}, - ], - value='center', - style={'width': '100%', 'margin-bottom': '8px'}, - ), - ] - ), - ], - ), - # Edge Styling Section - create_style_section( - 'Edge Styling', - [ - html.Div( - [ - html.Label('Edge Width', style={'color': 'white', 'font-size': '12px'}), - dcc.Slider( - id='edge-width-slider', - min=1, - max=10, - step=1, - value=2, - marks={ - i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} - for i in range(1, 11) - }, - tooltip={'placement': 'bottom', 'always_visible': True}, - ), - ], - style={'margin-bottom': '15px'}, - ), - html.Div( - [ - html.Label('Edge Curve', style={'color': 'white', 'font-size': '12px'}), - dcc.Dropdown( - id='edge-curve-dropdown', - options=[ - {'label': 'Straight', 'value': 'straight'}, - {'label': 'Bezier', 'value': 'bezier'}, - {'label': 'Unbundled Bezier', 'value': 'unbundled-bezier'}, - {'label': 'Segments', 'value': 'segments'}, - ], - value='straight', - style={'width': '100%', 'margin-bottom': '8px'}, - ), - ] - ), - html.Div( - [ - html.Label('Arrow Style', style={'color': 'white', 'font-size': '12px'}), - dcc.Dropdown( - id='arrow-style-dropdown', - options=[ - {'label': 'Triangle', 'value': 'triangle'}, - {'label': 'Triangle (Tee)', 'value': 'triangle-tee'}, - {'label': 'Circle', 'value': 'circle'}, - {'label': 'Square', 'value': 'square'}, - {'label': 'Diamond', 'value': 'diamond'}, - {'label': 'None', 'value': 'none'}, - ], - value='triangle', - style={'width': '100%', 'margin-bottom': '8px'}, - ), - ] - ), - ], - ), - # Advanced Section - create_style_section( - 'Advanced', - [ - html.Div( - [ - html.Label( - 'Custom Stylesheet (JSON)', style={'color': 'white', 'font-size': '12px'} - ), - dcc.Textarea( - id='custom-stylesheet-textarea', - placeholder='Enter custom Cytoscape stylesheet as JSON...', - style={ - 'width': '100%', - 'height': '120px', - 'background-color': '#34495E', - 'color': 'white', - 'font-size': '11px', - 'margin-bottom': '10px', - }, - value=json.dumps(default_cytoscape_stylesheet, indent=2), - ), - ] - ), - html.Div( - [ - html.Button( - 'Apply Custom', - id='apply-custom-btn', - n_clicks=0, - style={ - 'width': '48%', - 'margin-right': '4%', - 'background-color': '#3498DB', - 'color': 'white', - 'border': 'none', - 'padding': '8px', - 'border-radius': '3px', - }, - ), - html.Button( - 'Reset Default', - id='reset-style-btn', - n_clicks=0, - style={ - 'width': '48%', - 'background-color': '#E74C3C', - 'color': 'white', - 'border': 'none', - 'padding': '8px', - 'border-radius': '3px', - }, - ), - ] - ), - ], - ), - ], - id='sidebar-content', - style={ - 'width': '280px', - 'height': '100vh', - 'background-color': '#2C3E50', - 'padding': '20px', - 'position': 'fixed', - 'left': '0', - 'top': '0', - 'overflow-y': 'auto', - 'border-right': '3px solid #34495E', - 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', - 'transform': 'translateX(-100%)', # Initially hidden - 'transition': 'transform 0.3s ease', - 'z-index': '999', - }, - ) - ] - ) - + return html.Div([ + html.H4(title, style={ + 'color': 'white', 'margin-bottom': '10px', + 'border-bottom': '2px solid #3498DB', 'padding-bottom': '5px', + }), + html.Div(children, style={'margin-bottom': '20px'}), + ]) + +def create_sidebar(): + """Create the main sidebar with improved organization""" + return html.Div([ + html.Div([ + html.H3('Style Controls', style={ + 'color': 'white', 'margin-bottom': '20px', 'text-align': 'center', + 'border-bottom': '3px solid #9B59B6', 'padding-bottom': '10px', + }), + + # Layout Section + create_style_section('Layout', [ + dcc.Dropdown( + id='layout-dropdown', + options=[ + {'label': 'Klay (horizontal)', 'value': 'klay'}, + {'label': 'Dagre (vertical)', 'value': 'dagre'}, + {'label': 'Breadthfirst', 'value': 'breadthfirst'}, + {'label': 'Cose (force-directed)', 'value': 'cose'}, + {'label': 'Grid', 'value': 'grid'}, + {'label': 'Circle', 'value': 'circle'}, + ], + value='klay', + clearable=False, + style={'width': '100%'}, + ), + ]), + + # Color Scheme Section + create_style_section('Color Scheme', [ + dcc.Dropdown( + id='color-scheme-dropdown', + options=[{'label': k, 'value': k} for k in VisualizationConfig.COLOR_PRESETS.keys()], + value='Default', + style={'width': '100%', 'margin-bottom': '10px'}, + ), + ]), + + # Color Pickers Section + create_style_section('Custom Colors', [ + create_color_picker_input('Bus', 'bus-color-picker', '#7F8C8D'), + create_color_picker_input('Source', 'source-color-picker', '#F1C40F'), + create_color_picker_input('Sink', 'sink-color-picker', '#F1C40F'), + create_color_picker_input('Storage', 'storage-color-picker', '#2980B9'), + create_color_picker_input('Converter', 'converter-color-picker', '#D35400'), + create_color_picker_input('Edge', 'edge-color-picker', '#808080'), + ]), + + # Node Settings + create_style_section('Node Settings', [ + html.Label('Size', style={'color': 'white', 'font-size': '12px'}), + dcc.Slider( + id='node-size-slider', min=50, max=150, step=10, value=90, + marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(50, 151, 25)}, + tooltip={'placement': 'bottom', 'always_visible': True}, + ), + html.Br(), + html.Label('Font Size', style={'color': 'white', 'font-size': '12px'}), + dcc.Slider( + id='font-size-slider', min=8, max=20, step=1, value=10, + marks={i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}} + for i in range(8, 21, 2)}, + tooltip={'placement': 'bottom', 'always_visible': True}, + ), + ]), + + # Reset Button + html.Div([ + html.Button('Reset to Defaults', id='reset-btn', n_clicks=0, style={ + 'width': '100%', 'background-color': '#E74C3C', 'color': 'white', + 'border': 'none', 'padding': '10px', 'border-radius': '5px', + 'cursor': 'pointer', 'margin-top': '20px', + }), + ]), + ], id='sidebar-content', style={ + 'width': '280px', 'height': '100vh', 'background-color': '#2C3E50', + 'padding': '20px', 'position': 'fixed', 'left': '0', 'top': '0', + 'overflow-y': 'auto', 'border-right': '3px solid #34495E', + 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', 'z-index': '999', + 'transform': 'translateX(-100%)', 'transition': 'transform 0.3s ease', + }) + ]) + +def shownetwork(graph: networkx.DiGraph): + """Main function to create and run the network visualization""" + if not DASH_CYTOSCAPE_AVAILABLE: + raise ImportError(f"Required packages not available: {VISUALIZATION_ERROR}") -def shownetwork(graph: 'networkx.DiGraph'): app = Dash(__name__, suppress_callback_exceptions=True) - elements = make_cytoscape_elements(graph) + # Load extra layouts cyto.load_extra_layouts() - app.layout = html.Div( - [ - # Toggle button - html.Button( - '☰', - id='toggle-sidebar', - n_clicks=0, - style={ - 'position': 'fixed', - 'top': '20px', - 'left': '20px', - 'z-index': '1000', - 'background-color': '#3498DB', - 'color': 'white', - 'border': 'none', - 'padding': '10px 15px', - 'border-radius': '5px', - 'cursor': 'pointer', - 'font-size': '18px', - 'box-shadow': '0 2px 5px rgba(0,0,0,0.3)', - }, - ), - # Hidden div to store elements data - html.Div(id='elements-store', style={'display': 'none'}), - # Sidebar - create_collapsible_sidebar(), - # Main content area - html.Div( - [ - # Top toolbar - html.Div( - [ - html.H2( - 'Network Visualization', style={'color': 'white', 'margin': '0', 'text-align': 'center'} - ), - html.Button( - 'Export as Image', - id='btn-image', - n_clicks=0, - style={ - 'position': 'absolute', - 'right': '20px', - 'top': '15px', - 'background-color': '#27AE60', - 'color': 'white', - 'border': 'none', - 'padding': '10px 20px', - 'border-radius': '5px', - 'cursor': 'pointer', - }, - ), - ], - style={ - 'background-color': '#34495E', - 'padding': '15px 20px', - 'margin-bottom': '0', - 'position': 'relative', - 'border-bottom': '2px solid #3498DB', - }, - ), - # Main cytoscape component - cyto.Cytoscape( - id='cytoscape', - layout={'name': 'klay'}, - style={'width': '100%', 'height': '70vh'}, - elements=elements, - stylesheet=default_cytoscape_stylesheet, - ), - # Bottom panel for node information - html.Div( - [ - html.H4( - 'Element Information', - style={ - 'color': 'white', - 'margin': '0 0 10px 0', - 'border-bottom': '2px solid #3498DB', - 'padding-bottom': '5px', - }, - ), - html.Div( - id='node-data', - children=[ - html.P( - 'Click on a node or edge to see its parameters.', - style={'color': 'white', 'margin': '0'}, - ) - ], - ), - ], - style={ - 'background-color': '#2C3E50', - 'padding': '15px', - 'height': '25vh', - 'overflow-y': 'auto', - 'border-top': '2px solid #34495E', - }, - ), - ], - id='main-content', - style={ - 'margin-left': '0', # Initially no margin - 'background-color': '#1A252F', - 'min-height': '100vh', - 'transition': 'margin-left 0.3s ease', - }, + # Create initial elements + elements = make_cytoscape_elements(graph) + + # App Layout + app.layout = html.Div([ + # Toggle button + html.Button('☰', id='toggle-sidebar', n_clicks=0, style={ + 'position': 'fixed', 'top': '20px', 'left': '20px', 'z-index': '1000', + 'background-color': '#3498DB', 'color': 'white', 'border': 'none', + 'padding': '10px 15px', 'border-radius': '5px', 'cursor': 'pointer', + 'font-size': '18px', 'box-shadow': '0 2px 5px rgba(0,0,0,0.3)', + }), + + # Data storage + dcc.Store(id='elements-store', data=elements), + + # Sidebar + create_sidebar(), + + # Main content + html.Div([ + # Header + html.Div([ + html.H2('Network Visualization', style={ + 'color': 'white', 'margin': '0', 'text-align': 'center' + }), + html.Button('Export PNG', id='export-btn', n_clicks=0, style={ + 'position': 'absolute', 'right': '20px', 'top': '15px', + 'background-color': '#27AE60', 'color': 'white', 'border': 'none', + 'padding': '10px 20px', 'border-radius': '5px', 'cursor': 'pointer', + }), + ], style={ + 'background-color': '#34495E', 'padding': '15px 20px', + 'position': 'relative', 'border-bottom': '2px solid #3498DB', + }), + + # Cytoscape graph + cyto.Cytoscape( + id='cytoscape', + layout={'name': 'klay'}, + style={'width': '100%', 'height': '70vh'}, + elements=elements, + stylesheet=VisualizationConfig.DEFAULT_STYLESHEET, ), - ] - ) - # Toggle sidebar visibility + # Info panel + html.Div([ + html.H4('Element Information', style={ + 'color': 'white', 'margin': '0 0 10px 0', + 'border-bottom': '2px solid #3498DB', 'padding-bottom': '5px', + }), + html.Div(id='info-panel', children=[ + html.P('Click on a node or edge to see details.', + style={'color': '#95A5A6', 'font-style': 'italic'}) + ]), + ], style={ + 'background-color': '#2C3E50', 'padding': '15px', + 'height': '25vh', 'overflow-y': 'auto', + 'border-top': '2px solid #34495E', + }), + ], id='main-content', style={ + 'margin-left': '0', 'background-color': '#1A252F', + 'min-height': '100vh', 'transition': 'margin-left 0.3s ease', + }), + ]) + + # Callbacks @app.callback( - [Output('sidebar-content', 'style'), Output('main-content', 'style')], [Input('toggle-sidebar', 'n_clicks')] + [Output('sidebar-content', 'style'), Output('main-content', 'style')], + [Input('toggle-sidebar', 'n_clicks')] ) def toggle_sidebar(n_clicks): - if n_clicks % 2 == 1: # Sidebar is open - sidebar_style = { - 'width': '280px', - 'height': '100vh', - 'background-color': '#2C3E50', - 'padding': '20px', - 'position': 'fixed', - 'left': '0', - 'top': '0', - 'overflow-y': 'auto', - 'border-right': '3px solid #34495E', - 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', - 'transform': 'translateX(0)', - 'transition': 'transform 0.3s ease', - 'z-index': '999', - } - main_style = { - 'margin-left': '280px', - 'background-color': '#1A252F', - 'min-height': '100vh', - 'transition': 'margin-left 0.3s ease', - } - else: # Sidebar is closed - sidebar_style = { - 'width': '280px', - 'height': '100vh', - 'background-color': '#2C3E50', - 'padding': '20px', - 'position': 'fixed', - 'left': '0', - 'top': '0', - 'overflow-y': 'auto', - 'border-right': '3px solid #34495E', - 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', - 'transform': 'translateX(-100%)', - 'transition': 'transform 0.3s ease', - 'z-index': '999', - } - main_style = { - 'margin-left': '0', - 'background-color': '#1A252F', - 'min-height': '100vh', - 'transition': 'margin-left 0.3s ease', - } + is_open = (n_clicks or 0) % 2 == 1 + sidebar_transform = 'translateX(0)' if is_open else 'translateX(-100%)' + main_margin = '280px' if is_open else '0' + + sidebar_style = { + 'width': '280px', 'height': '100vh', 'background-color': '#2C3E50', + 'padding': '20px', 'position': 'fixed', 'left': '0', 'top': '0', + 'overflow-y': 'auto', 'border-right': '3px solid #34495E', + 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)', 'z-index': '999', + 'transform': sidebar_transform, 'transition': 'transform 0.3s ease', + } - return sidebar_style, main_style + main_style = { + 'margin-left': main_margin, 'background-color': '#1A252F', + 'min-height': '100vh', 'transition': 'margin-left 0.3s ease', + } - # Reset all controls to defaults - @app.callback( - [ - Output('color-scheme-dropdown', 'value'), - Output('bus-color-input', 'value'), - Output('source-color-input', 'value'), - Output('sink-color-input', 'value'), - Output('storage-color-input', 'value'), - Output('converter-color-input', 'value'), - Output('edge-color-input', 'value'), - Output('text-color-input', 'value'), - Output('text-outline-input', 'value'), - Output('text-valign-dropdown', 'value'), - Output('text-halign-dropdown', 'value'), - Output('node-size-slider', 'value'), - Output('font-size-slider', 'value'), - Output('edge-width-slider', 'value'), - Output('edge-curve-dropdown', 'value'), - Output('arrow-style-dropdown', 'value'), - Output('layout-dropdown', 'value'), - Output('custom-stylesheet-textarea', 'value'), - ], - [Input('reset-style-btn', 'n_clicks')], - ) - def reset_all_controls(reset_clicks): - if reset_clicks and reset_clicks > 0: - return ( - 'Default', # color-scheme-dropdown - '#7F8C8D', # bus-color-input - '#F1C40F', # source-color-input - '#F1C40F', # sink-color-input - '#2980B9', # storage-color-input - '#D35400', # converter-color-input - 'gray', # edge-color-input - 'white', # text-color-input - 'black', # text-outline-input - 'center', # text-valign-dropdown - 'center', # text-halign-dropdown - 90, # node-size-slider - 10, # font-size-slider - 2, # edge-width-slider - 'straight', # edge-curve-dropdown - 'triangle', # arrow-style-dropdown - 'klay', # layout-dropdown - json.dumps(default_cytoscape_stylesheet, indent=2), # custom-stylesheet-textarea - ) - # Return current values if no reset - return ( - 'Default', - '#7F8C8D', - '#F1C40F', - '#F1C40F', - '#2980B9', - '#D35400', - 'gray', - 'white', - 'black', - 'center', - 'center', - 90, - 10, - 2, - 'straight', - 'triangle', - 'klay', - json.dumps(default_cytoscape_stylesheet, indent=2), - ) + return sidebar_style, main_style - # Update elements and stylesheet based on controls @app.callback( [Output('cytoscape', 'elements'), Output('cytoscape', 'stylesheet')], [ Input('color-scheme-dropdown', 'value'), - Input('bus-color-input', 'value'), - Input('source-color-input', 'value'), - Input('sink-color-input', 'value'), - Input('storage-color-input', 'value'), - Input('converter-color-input', 'value'), - Input('edge-color-input', 'value'), - Input('text-color-input', 'value'), - Input('text-outline-input', 'value'), - Input('text-valign-dropdown', 'value'), - Input('text-halign-dropdown', 'value'), + Input('bus-color-picker', 'value'), + Input('source-color-picker', 'value'), + Input('sink-color-picker', 'value'), + Input('storage-color-picker', 'value'), + Input('converter-color-picker', 'value'), + Input('edge-color-picker', 'value'), Input('node-size-slider', 'value'), Input('font-size-slider', 'value'), - Input('edge-width-slider', 'value'), - Input('edge-curve-dropdown', 'value'), - Input('arrow-style-dropdown', 'value'), - Input('apply-custom-btn', 'n_clicks'), ], - [State('custom-stylesheet-textarea', 'value')], + [State('elements-store', 'data')] ) - def update_elements_and_stylesheet( - color_scheme, - bus_color, - source_color, - sink_color, - storage_color, - converter_color, - edge_color, - text_color, - text_outline, - text_valign, - text_halign, - node_size, - font_size, - edge_width, - edge_curve, - arrow_style, - apply_clicks, - custom_style, - ): - ctx = callback_context - - if ctx.triggered: - button_id = ctx.triggered[0]['prop_id'].split('.')[0] - - # Apply custom stylesheet - if button_id == 'apply-custom-btn': - try: - return elements, json.loads(custom_style) - except json.JSONDecodeError: - return elements, default_cytoscape_stylesheet - - # Determine which colors to use - use_custom_colors = any( - [ - bus_color and bus_color != '#7F8C8D', - source_color and source_color != '#F1C40F', - sink_color and sink_color != '#F1C40F', - storage_color and storage_color != '#2980B9', - converter_color and converter_color != '#D35400', - edge_color and edge_color != 'gray', - ] - ) - - if use_custom_colors: + def update_visualization(color_scheme, bus_color, source_color, sink_color, + storage_color, converter_color, edge_color, + node_size, font_size, stored_elements): + if not stored_elements: + return no_update, no_update + + # Determine colors to use + if any(picker for picker in [bus_color, source_color, sink_color, + storage_color, converter_color, edge_color]): + # Use custom colors from pickers colors = { - 'Bus': bus_color or '#7F8C8D', - 'Source': source_color or '#F1C40F', - 'Sink': sink_color or '#F1C40F', - 'Storage': storage_color or '#2980B9', - 'Converter': converter_color or '#D35400', - 'Other': converter_color or '#27AE60', + 'Bus': bus_color.get('hex') if bus_color else '#7F8C8D', + 'Source': source_color.get('hex') if source_color else '#F1C40F', + 'Sink': sink_color.get('hex') if sink_color else '#F1C40F', + 'Storage': storage_color.get('hex') if storage_color else '#2980B9', + 'Converter': converter_color.get('hex') if converter_color else '#D35400', + 'Other': '#27AE60', } else: - colors = color_presets.get(color_scheme, color_presets['Default']) + # Use preset scheme + colors = VisualizationConfig.COLOR_PRESETS.get(color_scheme, + VisualizationConfig.DEFAULT_COLORS) - # Create updated elements with new colors + # Update element colors updated_elements = [] - for element in elements: - if 'data' in element: + for element in stored_elements: + if 'data' in element and 'element_type' in element['data']: element_copy = element.copy() element_copy['data'] = element['data'].copy() - - # Update node colors - if 'color' in element_copy['data']: - node_id = element_copy['data']['id'] - shape = element_copy['data'].get('shape', 'rectangle') - - if shape == 'ellipse': - node_type = 'Bus' - elif shape == 'custom-source': - node_type = 'Source' - elif shape == 'custom-sink': - node_type = 'Sink' - elif 'storage' in node_id.lower(): - node_type = 'Storage' - elif 'converter' in node_id.lower(): - node_type = 'Converter' - else: - node_type = 'Other' - - if node_type in colors: - element_copy['data']['color'] = colors[node_type] - + element_type = element_copy['data']['element_type'] + if element_type in colors: + element_copy['data']['color'] = colors[element_type] updated_elements.append(element_copy) else: updated_elements.append(element) - # Create updated stylesheet + # Create stylesheet + edge_color_hex = edge_color.get('hex') if edge_color else 'gray' stylesheet = [ { 'selector': 'node', @@ -914,13 +457,13 @@ def update_elements_and_stylesheet( 'content': 'data(label)', 'background-color': 'data(color)', 'font-size': font_size or 10, - 'color': text_color or 'white', - 'text-valign': text_valign or 'center', - 'text-halign': text_halign or 'center', + 'color': 'white', + 'text-valign': 'center', + 'text-halign': 'center', 'width': f'{node_size or 90}px', - 'height': f'{(node_size or 90) * 0.8}px', + 'height': f'{int((node_size or 90) * 0.8)}px', 'shape': 'data(shape)', - 'text-outline-color': text_outline or 'black', + 'text-outline-color': 'black', 'text-outline-width': 0.5, }, }, @@ -941,11 +484,11 @@ def update_elements_and_stylesheet( { 'selector': 'edge', 'style': { - 'curve-style': edge_curve or 'straight', - 'width': edge_width or 2, - 'line-color': edge_color or 'gray', - 'target-arrow-color': edge_color or 'gray', - 'target-arrow-shape': arrow_style or 'triangle', + 'curve-style': 'straight', + 'width': 2, + 'line-color': edge_color_hex, + 'target-arrow-color': edge_color_hex, + 'target-arrow-shape': 'triangle', 'arrow-scale': 2, }, }, @@ -953,254 +496,116 @@ def update_elements_and_stylesheet( return updated_elements, stylesheet - # Show node data on click - # Replace your display callback with this consistently formatted version: - @app.callback( - Output('node-data', 'children'), [Input('cytoscape', 'tapNodeData'), Input('cytoscape', 'tapEdgeData')] + Output('info-panel', 'children'), + [Input('cytoscape', 'tapNodeData'), Input('cytoscape', 'tapEdgeData')] ) - def display_element_data(node_data, edge_data): + def display_element_info(node_data, edge_data): ctx = callback_context + if not ctx.triggered: + return [html.P('Click on a node or edge to see details.', + style={'color': '#95A5A6', 'font-style': 'italic'})] - def display_node_info(data): - """Display node information""" - node_id = data['id'] - label = data.get('label', node_id) # Use label if available - parameters = data.get('parameters', '') - - components = [ - html.H5( - f'Node: {label}', - style={ - 'color': 'white', - 'margin-bottom': '15px', - 'border-bottom': '1px solid #3498DB', - 'padding-bottom': '5px', - }, - ) - ] - - # Add parameters section - if parameters: - components.append( - html.Div( - [ - html.Strong( - 'Parameters:', style={'color': '#3498DB', 'display': 'block', 'margin-bottom': '5px'} - ) - ] - ) - ) - - if isinstance(parameters, str): - lines = parameters.split('\n') - for line in lines: - if line.strip(): - components.append( - html.Div( - line, - style={ - 'color': '#BDC3C7', - 'margin': '3px 0', - 'font-family': 'monospace', - 'font-size': '12px', - 'line-height': '1.3', - 'white-space': 'pre-wrap', - 'padding-left': '10px', - }, - ) - ) - else: - components.append(html.Div(style={'height': '8px'})) - elif isinstance(parameters, dict): - for k, v in parameters.items(): - components.append( - html.Div( - f'{k}: {v}', - style={ - 'color': '#BDC3C7', - 'margin': '3px 0', - 'font-family': 'monospace', - 'font-size': '12px', - 'line-height': '1.3', - 'padding-left': '10px', - }, - ) - ) - else: - components.append( - html.Div( - str(parameters), - style={ - 'color': '#BDC3C7', - 'font-family': 'monospace', - 'font-size': '12px', - 'white-space': 'pre-wrap', - 'padding-left': '10px', - }, - ) - ) - - return components - - def display_edge_info(data): - """Display edge information""" - source = data.get('source', '') - target = data.get('target', '') - label = data.get('label', '') - parameters = data.get('parameters', '') - - components = [ - html.H5( - f'Edge: {label} ({source} → {target}) ', - style={ - 'color': 'white', - 'margin-bottom': '15px', - 'border-bottom': '1px solid #E67E22', - 'padding-bottom': '5px', - }, - ) + # Determine what was clicked + if ctx.triggered[0]['prop_id'] == 'cytoscape.tapNodeData' and node_data: + return [ + html.H5(f"Node: {node_data.get('label', 'Unknown')}", + style={'color': 'white', 'margin-bottom': '10px'}), + html.P(f"Type: {node_data.get('element_type', 'Unknown')}", + style={'color': '#BDC3C7'}), + html.Pre(node_data.get('parameters', 'No parameters'), + style={'color': '#BDC3C7', 'font-size': '11px', + 'white-space': 'pre-wrap'}) ] - - # Add parameters section (same formatting as nodes) - if parameters: - components.append( - html.Div( - [ - html.Strong( - 'Parameters:', style={'color': '#E67E22', 'display': 'block', 'margin-bottom': '5px'} - ) - ] - ) - ) - - if isinstance(parameters, str): - lines = parameters.split('\n') - for line in lines: - if line.strip(): - components.append( - html.Div( - line, - style={ - 'color': '#BDC3C7', - 'margin': '3px 0', - 'font-family': 'monospace', - 'font-size': '12px', - 'line-height': '1.3', - 'white-space': 'pre-wrap', - 'padding-left': '10px', - }, - ) - ) - else: - components.append(html.Div(style={'height': '8px'})) - elif isinstance(parameters, dict): - for k, v in parameters.items(): - components.append( - html.Div( - f'{k}: {v}', - style={ - 'color': '#BDC3C7', - 'margin': '3px 0', - 'font-family': 'monospace', - 'font-size': '12px', - 'line-height': '1.3', - 'padding-left': '10px', - }, - ) - ) - else: - components.append( - html.Div( - str(parameters), - style={ - 'color': '#BDC3C7', - 'font-family': 'monospace', - 'font-size': '12px', - 'white-space': 'pre-wrap', - 'padding-left': '10px', - }, - ) - ) - - return components - - # Check which input triggered the callback - if not ctx.triggered: + elif ctx.triggered[0]['prop_id'] == 'cytoscape.tapEdgeData' and edge_data: return [ - html.P( - 'Click on a node or edge to see its parameters.', - style={'color': '#95A5A6', 'font-style': 'italic', 'text-align': 'center', 'margin-top': '20px'}, - ) + html.H5(f"Edge: {edge_data.get('label', 'Unknown')}", + style={'color': 'white', 'margin-bottom': '10px'}), + html.P(f"{edge_data.get('source', '')} → {edge_data.get('target', '')}", + style={'color': '#E67E22'}), + html.Pre(edge_data.get('parameters', 'No parameters'), + style={'color': '#BDC3C7', 'font-size': '11px', + 'white-space': 'pre-wrap'}) ] - trigger_id = ctx.triggered[0]['prop_id'].split('.')[0] - - # Handle the appropriate trigger - if trigger_id == 'cytoscape' and ctx.triggered[0]['prop_id'] == 'cytoscape.tapEdgeData': - if edge_data: - return display_edge_info(edge_data) - elif trigger_id == 'cytoscape' and ctx.triggered[0]['prop_id'] == 'cytoscape.tapNodeData': - if node_data: - return display_node_info(node_data) - - # Fallback - return [ - html.P( - 'Click on a node or edge to see its parameters.', - style={'color': '#95A5A6', 'font-style': 'italic', 'text-align': 'center', 'margin-top': '20px'}, - ) - ] + return [html.P('Click on a node or edge to see details.', + style={'color': '#95A5A6', 'font-style': 'italic'})] - # Update layout when dropdown changes - @app.callback(Output('cytoscape', 'layout'), Input('layout-dropdown', 'value')) + @app.callback( + Output('cytoscape', 'layout'), + Input('layout-dropdown', 'value') + ) def update_layout(selected_layout): return {'name': selected_layout} - # Export graph as image + # Reset callback + @app.callback( + [ + Output('color-scheme-dropdown', 'value'), + Output('bus-color-picker', 'value'), + Output('source-color-picker', 'value'), + Output('sink-color-picker', 'value'), + Output('storage-color-picker', 'value'), + Output('converter-color-picker', 'value'), + Output('edge-color-picker', 'value'), + Output('node-size-slider', 'value'), + Output('font-size-slider', 'value'), + Output('layout-dropdown', 'value'), + ], + [Input('reset-btn', 'n_clicks')] + ) + def reset_controls(n_clicks): + if n_clicks and n_clicks > 0: + return ( + 'Default', # color scheme + {'hex': '#7F8C8D'}, # bus + {'hex': '#F1C40F'}, # source + {'hex': '#F1C40F'}, # sink + {'hex': '#2980B9'}, # storage + {'hex': '#D35400'}, # converter + {'hex': '#808080'}, # edge + 90, # node size + 10, # font size + 'klay', # layout + ) + return no_update + + # Export functionality app.clientside_callback( """ function(n_clicks) { - if (n_clicks > 0) { - var cy = window.cy; - if (cy) { - var png64 = cy.png({scale: 3, full: true}); - var a = document.createElement('a'); - a.href = png64; - a.download = 'network_visualization.png'; - a.click(); - } + if (n_clicks > 0 && window.cy) { + var png64 = window.cy.png({scale: 3, full: true}); + var a = document.createElement('a'); + a.href = png64; + a.download = 'network_visualization.png'; + a.click(); } - return 'Export as Image'; + return 'Export PNG'; } """, - Output('btn-image', 'children'), - Input('btn-image', 'n_clicks'), + Output('export-btn', 'children'), + Input('export-btn', 'n_clicks'), ) - # Find a free port - def is_port_in_use(port): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - return s.connect_ex(('localhost', port)) == 0 - + # Start server def find_free_port(start_port=8050, end_port=8100): for port in range(start_port, end_port): - if not is_port_in_use(port): - return port + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex(('localhost', port)) != 0: + return port raise Exception('No free port found') - port = find_free_port(8050, 8100) + port = find_free_port() server = make_server('127.0.0.1', port, app.server) # Start server in background thread server_thread = threading.Thread(target=server.serve_forever, daemon=True) server_thread.start() - print(f'Network visualization started on port {port}') - print(f'Access it at: http://127.0.0.1:{port}/') - print('The app is running in the background. You can continue using the console.') + print(f'Network visualization started on http://127.0.0.1:{port}/') - # Store the actual server instance for shutdown + # Store server reference for cleanup app.server_instance = server app.port = port diff --git a/pyproject.toml b/pyproject.toml index 01b85340e..c3f837a0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,19 +36,15 @@ dependencies = [ # Core scientific computing "numpy >= 1.21.5, < 3", "pandas >= 2.0.0, < 3", - # Optimization and data handling "linopy >= 0.5.1, < 0.6.0", "netcdf4 >= 1.6.1, < 2", - # Utilities "PyYAML >= 6.0.0, < 7", "rich >= 13.0.0", - "tomli >= 2.0.1; python_version < '3.11'", #Only needed with python 3.10 or earlier - + "tomli >= 2.0.1; python_version < '3.11'", #Only needed with python 3.10 or earlier # Default solver "highspy >= 1.5.3", - # Visualization "matplotlib >= 3.5.2, < 4.0.0", "plotly >= 5.15.0, < 6.0.0", @@ -68,7 +64,7 @@ dev = [ "werkzeug >= 3", ] -viz = ["dash >= 3", "dash-cytoscape >= 1", "networkx >= 3", "werkzeug >= 3"] +viz = ["dash >= 3", "dash-cytoscape >= 1", "networkx >= 3", "werkzeug >= 3", "dash-daq>=0.6.0"] full = [ "pyvis == 0.3.1", # Visualizing FlowSystem Network @@ -79,6 +75,7 @@ full = [ "dash-cytoscape >= 1", # Visualizing FlowSystem Network as app "networkx >= 3", # Visualizing FlowSystem Network as app "werkzeug >= 3", # Visualizing FlowSystem Network as app + "dash-daq>=0.6.0", # Visualizing FlowSystem Network as app ] docs = [ From e054e4b24ad904d0bde4c4a8df1338c42c3f814e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:21:31 +0200 Subject: [PATCH 14/21] Add dependency --- pyproject.toml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c3f837a0b..ca20cbe95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,11 +60,12 @@ dev = [ "gurobipy >= 10.0.0", "dash >= 3", "dash-cytoscape >= 1", + "dash-daq >= 0.6.0", "networkx >= 3", "werkzeug >= 3", ] -viz = ["dash >= 3", "dash-cytoscape >= 1", "networkx >= 3", "werkzeug >= 3", "dash-daq>=0.6.0"] +network-viz = ["dash >= 3", "dash-cytoscape >= 1", "networkx >= 3", "werkzeug >= 3", "dash-daq>=0.6.0"] full = [ "pyvis == 0.3.1", # Visualizing FlowSystem Network @@ -75,7 +76,7 @@ full = [ "dash-cytoscape >= 1", # Visualizing FlowSystem Network as app "networkx >= 3", # Visualizing FlowSystem Network as app "werkzeug >= 3", # Visualizing FlowSystem Network as app - "dash-daq>=0.6.0", # Visualizing FlowSystem Network as app + "dash-daq >= 0.6.0", # Visualizing FlowSystem Network as app ] docs = [ @@ -136,4 +137,4 @@ extend-fixable = ["B"] # Enable fix for flake8-bugbear (`B`), on top of any ru [tool.ruff.format] quote-style = "single" indent-style = "space" -docstring-code-format = true +docstring-code-format = true \ No newline at end of file From 9e08f681bf0175f444aa2430272e2b5af79cad1a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:23:51 +0200 Subject: [PATCH 15/21] Improve dependencies --- pyproject.toml | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ca20cbe95..8e32ff922 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ classifiers = [ "Intended Audience :: Developers", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering", - "License :: OSI Approved :: MIT License", ] dependencies = [ # Core scientific computing @@ -42,15 +41,16 @@ dependencies = [ # Utilities "PyYAML >= 6.0.0, < 7", "rich >= 13.0.0", - "tomli >= 2.0.1; python_version < '3.11'", #Only needed with python 3.10 or earlier + "tomli >= 2.0.1; python_version < '3.11'", # Only needed with python 3.10 or earlier # Default solver "highspy >= 1.5.3", - # Visualization + # Default visualization "matplotlib >= 3.5.2, < 4.0.0", "plotly >= 5.15.0, < 6.0.0", ] [project.optional-dependencies] +# Development tools and testing dev = [ "pytest >= 7.0.0", "ruff >= 0.9.0", @@ -58,27 +58,36 @@ dev = [ "tsam >= 2.3.1, < 3.0.0", # Time series aggregation "scipy >= 1.15.1, < 2.0.0", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0", - "dash >= 3", - "dash-cytoscape >= 1", + "dash >= 3.0.0", + "dash-cytoscape >= 1.0.0", "dash-daq >= 0.6.0", - "networkx >= 3", - "werkzeug >= 3", + "networkx >= 3.0.0", + "werkzeug >= 3.0.0", ] -network-viz = ["dash >= 3", "dash-cytoscape >= 1", "networkx >= 3", "werkzeug >= 3", "dash-daq>=0.6.0"] +# Interactive network visualization with enhanced color picker +network-viz = [ + "dash >= 3.0.0", + "dash-cytoscape >= 1.0.0", + "dash-daq >= 0.6.0", + "networkx >= 3.0.0", + "werkzeug >= 3.0.0", +] +# Full feature set (everything except dev tools) full = [ "pyvis == 0.3.1", # Visualizing FlowSystem Network "tsam >= 2.3.1, < 3.0.0", # Time series aggregation "scipy >= 1.15.1, < 2.0.0", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 "gurobipy >= 10.0.0", - "dash >= 3", # Visualizing FlowSystem Network as app - "dash-cytoscape >= 1", # Visualizing FlowSystem Network as app - "networkx >= 3", # Visualizing FlowSystem Network as app - "werkzeug >= 3", # Visualizing FlowSystem Network as app + "dash >= 3.0.0", # Visualizing FlowSystem Network as app + "dash-cytoscape >= 1.0.0", # Visualizing FlowSystem Network as app "dash-daq >= 0.6.0", # Visualizing FlowSystem Network as app + "networkx >= 3.0.0", # Visualizing FlowSystem Network as app + "werkzeug >= 3.0.0", # Visualizing FlowSystem Network as app ] +# Documentation building docs = [ "mkdocs-material >= 9.0.0, < 10", "mkdocstrings-python >= 1.0.0", From 88ac9c542ec3bcaecd1c7110433cd4570c7848de Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:24:09 +0200 Subject: [PATCH 16/21] Improve dependencies --- pyproject.toml | 30 +++++++++++++++--------------- tests/test_network_app.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 15 deletions(-) create mode 100644 tests/test_network_app.py diff --git a/pyproject.toml b/pyproject.toml index 8e32ff922..db296d8ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,21 +50,6 @@ dependencies = [ ] [project.optional-dependencies] -# Development tools and testing -dev = [ - "pytest >= 7.0.0", - "ruff >= 0.9.0", - "pyvis == 0.3.1", # Visualizing FlowSystem - "tsam >= 2.3.1, < 3.0.0", # Time series aggregation - "scipy >= 1.15.1, < 2.0.0", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 - "gurobipy >= 10.0.0", - "dash >= 3.0.0", - "dash-cytoscape >= 1.0.0", - "dash-daq >= 0.6.0", - "networkx >= 3.0.0", - "werkzeug >= 3.0.0", -] - # Interactive network visualization with enhanced color picker network-viz = [ "dash >= 3.0.0", @@ -87,6 +72,21 @@ full = [ "werkzeug >= 3.0.0", # Visualizing FlowSystem Network as app ] +# Development tools and testing +dev = [ + "pytest >= 7.0.0", + "ruff >= 0.9.0", + "pyvis == 0.3.1", # Visualizing FlowSystem + "tsam >= 2.3.1, < 3.0.0", # Time series aggregation + "scipy >= 1.15.1, < 2.0.0", # Used by tsam. Prior versions have conflict with highspy. See https://github.com/scipy/scipy/issues/22257 + "gurobipy >= 10.0.0", + "dash >= 3.0.0", + "dash-cytoscape >= 1.0.0", + "dash-daq >= 0.6.0", + "networkx >= 3.0.0", + "werkzeug >= 3.0.0", +] + # Documentation building docs = [ "mkdocs-material >= 9.0.0, < 10", diff --git a/tests/test_network_app.py b/tests/test_network_app.py new file mode 100644 index 000000000..cc51a5a79 --- /dev/null +++ b/tests/test_network_app.py @@ -0,0 +1,28 @@ +from typing import Dict, List, Optional, Union + +import pytest + +import flixopt as fx +from flixopt.io import CalculationResultsPaths + +from .conftest import ( + assert_almost_equal_numeric, + flow_system_base, + flow_system_long, + flow_system_segments_of_flows_2, + simple_flow_system, +) + +@pytest.fixture(params=[simple_flow_system, flow_system_segments_of_flows_2, flow_system_long]) +def flow_system(request): + fs = request.getfixturevalue(request.param.__name__) + if isinstance(fs, fx.FlowSystem): + return fs + else: + return fs[0] + +def test_network_app(flow_system): + """Test that flow model constraints are correctly generated.""" + flow_system.start_network_app() + flow_system.stop_network_app() + From dfc9779df56fad7307dd5cafc48fbe8b5bc127a1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 13 Aug 2025 23:25:15 +0200 Subject: [PATCH 17/21] ruff check --- tests/test_network_app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_network_app.py b/tests/test_network_app.py index cc51a5a79..df9bb00a5 100644 --- a/tests/test_network_app.py +++ b/tests/test_network_app.py @@ -13,6 +13,7 @@ simple_flow_system, ) + @pytest.fixture(params=[simple_flow_system, flow_system_segments_of_flows_2, flow_system_long]) def flow_system(request): fs = request.getfixturevalue(request.param.__name__) From 41285bb574434bd5a99a1855d28b61268bc25b6f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:10:58 +0200 Subject: [PATCH 18/21] Add warning to network app --- flixopt/flow_system.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index ffbce538f..e836bd278 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -249,6 +249,12 @@ def start_network_app(self): """ from .network_app import DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR, flow_graph, shownetwork + warnings.warn( + f"The network visualization is still experimental and might change in the future.", + stacklevel=2, + category=UserWarning, + ) + if not DASH_CYTOSCAPE_AVAILABLE: raise ImportError( f"Network visualization requires optional dependencies. " From 4dbca922094cd2cdfd30d0c63f7a90375906cff7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:11:18 +0200 Subject: [PATCH 19/21] Improve CHANGELOG.md --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1431eafd..397d84115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `Sink`, `Source` and `SourceAndSink` now accept multiple `flows` as `inputs` and `outputs` instead of just one. This enables to model more use cases using these classes. -- Further, both `Sink` and `Source` now have a `prevent_simultaneous_flow_rates` argument to prevent simultaneous flow rates of more than one of their Flows. +- `Sink`, `Source` and `SourceAndSink` now accept multiple `flows` as `inputs` and `outputs` instead of just one. This enables to model more use cases using these classes. [[#291](https://github.com/flixOpt/flixopt/pull/291) by [@FBumann](https://github.com/FBumann)] +- Further, both `Sink` and `Source` now have a `prevent_simultaneous_flow_rates` argument to prevent simultaneous flow rates of more than one of their Flows. [[#291](https://github.com/flixOpt/flixopt/pull/291) by [@FBumann](https://github.com/FBumann)] ### Added -- Added `FlowSystem.start_netowk_app()` and `FlowSystem.stop_network_app()` to easily visualize the network structure of a flow system in an interactive web app. This is still experimental and might change in the future. +- Added `FlowSystem.start_network_app()` and `FlowSystem.stop_network_app()` to easily visualize the network structure of a flow system in an interactive dash web app. This is still experimental and might change in the future. [[#293](https://github.com/flixOpt/flixopt/pull/293) by [@FBumann](https://github.com/FBumann)] ### Deprecated -- For the classes `Sink`, `Source` and `SourceAndSink`: `.sink`, `.source` and `.prevent_simultaneous_sink_and_source` are deprecated in favor of the new arguments `inputs`, `outputs` and `prevent_simultaneous_flow_rates`. +- For the classes `Sink`, `Source` and `SourceAndSink`: `.sink`, `.source` and `.prevent_simultaneous_sink_and_source` are deprecated in favor of the new arguments `inputs`, `outputs` and `prevent_simultaneous_flow_rates`. [[#291](https://github.com/flixOpt/flixopt/pull/291) by [@FBumann](https://github.com/FBumann)] ## [2.1.5] - 2025-07-08 From c45648263bc99a2cff2d978526a1d379507eaf44 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:12:40 +0200 Subject: [PATCH 20/21] ruff check --- flixopt/flow_system.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index e836bd278..19f8a240f 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -250,7 +250,7 @@ def start_network_app(self): from .network_app import DASH_CYTOSCAPE_AVAILABLE, VISUALIZATION_ERROR, flow_graph, shownetwork warnings.warn( - f"The network visualization is still experimental and might change in the future.", + 'The network visualization is still experimental and might change in the future.', stacklevel=2, category=UserWarning, ) From 9e3897601255d3fc95b1d23dfead8dd5813eae64 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 2 Sep 2025 21:44:05 +0200 Subject: [PATCH 21/21] Fix CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba3deadd..70ae4227d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - For the classes `Sink`, `Source` and `SourceAndSink`: `.sink`, `.source` and `.prevent_simultaneous_sink_and_source` are deprecated in favor of the new arguments `inputs`, `outputs` and `prevent_simultaneous_flow_rates`. [[#291](https://github.com/flixOpt/flixopt/pull/291) by [@FBumann](https://github.com/FBumann)] ### Fixed -- Fixed testing issue with new `linopy` version 0.5.6 [[#296](https://github.com/PyPSA/linopy/pull/296) by [@FBumann](https://github.com/FBumann)] +- Fixed testing issue with new `linopy` version 0.5.6 [[#296](https://github.com/flixOpt/flixopt/pull/296) by [@FBumann](https://github.com/FBumann)] ## [2.1.5] - 2025-07-08