flixopt 2.1.5__py3-none-any.whl → 2.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of flixopt might be problematic. Click here for more details.

flixopt/network_app.py ADDED
@@ -0,0 +1,755 @@
1
+ import json
2
+ import logging
3
+ import socket
4
+ import threading
5
+ from typing import Any, Dict, List
6
+
7
+ try:
8
+ import dash_cytoscape as cyto
9
+ import dash_daq as daq
10
+ import networkx
11
+ from dash import Dash, Input, Output, State, callback_context, dcc, html, no_update
12
+ from werkzeug.serving import make_server
13
+
14
+ DASH_CYTOSCAPE_AVAILABLE = True
15
+ VISUALIZATION_ERROR = None
16
+ except ImportError as e:
17
+ DASH_CYTOSCAPE_AVAILABLE = False
18
+ VISUALIZATION_ERROR = str(e)
19
+
20
+ from .components import LinearConverter, Sink, Source, SourceAndSink, Storage
21
+ from .elements import Bus, Component, Flow
22
+ from .flow_system import FlowSystem
23
+
24
+ logger = logging.getLogger('flixopt')
25
+
26
+
27
+ # Configuration class for better organization
28
+ class VisualizationConfig:
29
+ """Configuration constants for the visualization"""
30
+
31
+ DEFAULT_COLORS = {
32
+ 'Bus': '#7F8C8D',
33
+ 'Source': '#F1C40F',
34
+ 'Sink': '#F1C40F',
35
+ 'Storage': '#2980B9',
36
+ 'Converter': '#D35400',
37
+ 'Other': '#27AE60',
38
+ }
39
+
40
+ COLOR_PRESETS = {
41
+ 'Default': DEFAULT_COLORS,
42
+ 'Vibrant': {
43
+ 'Bus': '#FF6B6B',
44
+ 'Source': '#4ECDC4',
45
+ 'Sink': '#45B7D1',
46
+ 'Storage': '#96CEB4',
47
+ 'Converter': '#FFEAA7',
48
+ 'Other': '#DDA0DD',
49
+ },
50
+ 'Dark': {
51
+ 'Bus': '#2C3E50',
52
+ 'Source': '#34495E',
53
+ 'Sink': '#7F8C8D',
54
+ 'Storage': '#95A5A6',
55
+ 'Converter': '#BDC3C7',
56
+ 'Other': '#ECF0F1',
57
+ },
58
+ 'Pastel': {
59
+ 'Bus': '#FFB3BA',
60
+ 'Source': '#BAFFC9',
61
+ 'Sink': '#BAE1FF',
62
+ 'Storage': '#FFFFBA',
63
+ 'Converter': '#FFDFBA',
64
+ 'Other': '#E0BBE4',
65
+ },
66
+ }
67
+
68
+ DEFAULT_STYLESHEET = [
69
+ {
70
+ 'selector': 'node',
71
+ 'style': {
72
+ 'content': 'data(label)',
73
+ 'background-color': 'data(color)',
74
+ 'font-size': 10,
75
+ 'color': 'white',
76
+ 'text-valign': 'center',
77
+ 'text-halign': 'center',
78
+ 'width': '90px',
79
+ 'height': '70px',
80
+ 'shape': 'data(shape)',
81
+ 'text-outline-color': 'black',
82
+ 'text-outline-width': 0.5,
83
+ },
84
+ },
85
+ {
86
+ 'selector': '[shape = "custom-source"]',
87
+ 'style': {
88
+ 'shape': 'polygon',
89
+ 'shape-polygon-points': '-0.5 0.5, 0.5 0.5, 1 -0.5, -1 -0.5',
90
+ },
91
+ },
92
+ {
93
+ 'selector': '[shape = "custom-sink"]',
94
+ 'style': {
95
+ 'shape': 'polygon',
96
+ 'shape-polygon-points': '-0.5 -0.5, 0.5 -0.5, 1 0.5, -1 0.5',
97
+ },
98
+ },
99
+ {
100
+ 'selector': 'edge',
101
+ 'style': {
102
+ 'curve-style': 'straight',
103
+ 'width': 2,
104
+ 'line-color': 'gray',
105
+ 'target-arrow-color': 'gray',
106
+ 'target-arrow-shape': 'triangle',
107
+ 'arrow-scale': 2,
108
+ },
109
+ },
110
+ ]
111
+
112
+
113
+ def flow_graph(flow_system: FlowSystem) -> networkx.DiGraph:
114
+ """Convert FlowSystem to NetworkX graph - simplified and more robust"""
115
+ nodes = list(flow_system.components.values()) + list(flow_system.buses.values())
116
+ edges = list(flow_system.flows.values())
117
+
118
+ def get_element_type(element):
119
+ """Determine element type for coloring"""
120
+ if isinstance(element, Bus):
121
+ return 'Bus'
122
+ elif isinstance(element, Source):
123
+ return 'Source'
124
+ elif isinstance(element, (Sink, SourceAndSink)):
125
+ return 'Sink'
126
+ elif isinstance(element, Storage):
127
+ return 'Storage'
128
+ elif isinstance(element, LinearConverter):
129
+ return 'Converter'
130
+ else:
131
+ return 'Other'
132
+
133
+ def get_shape(element):
134
+ """Determine node shape"""
135
+ if isinstance(element, Bus):
136
+ return 'ellipse'
137
+ elif isinstance(element, Source):
138
+ return 'custom-source'
139
+ elif isinstance(element, (Sink, SourceAndSink)):
140
+ return 'custom-sink'
141
+ else:
142
+ return 'rectangle'
143
+
144
+ graph = networkx.DiGraph()
145
+
146
+ # Add nodes with attributes
147
+ for node in nodes:
148
+ graph.add_node(
149
+ node.label_full,
150
+ color=VisualizationConfig.DEFAULT_COLORS[get_element_type(node)],
151
+ shape=get_shape(node),
152
+ element_type=get_element_type(node),
153
+ parameters=str(node),
154
+ )
155
+
156
+ # Add edges
157
+ for edge in edges:
158
+ try:
159
+ graph.add_edge(
160
+ u_of_edge=edge.bus if edge.is_input_in_component else edge.component,
161
+ v_of_edge=edge.component if edge.is_input_in_component else edge.bus,
162
+ label=edge.label_full,
163
+ parameters=edge.__str__().replace(')', '\n)'),
164
+ )
165
+ except Exception as e:
166
+ logger.error(f'Failed to add edge {edge}: {e}')
167
+
168
+ return graph
169
+
170
+
171
+ def make_cytoscape_elements(graph: networkx.DiGraph) -> List[Dict[str, Any]]:
172
+ """Convert NetworkX graph to Cytoscape elements"""
173
+ elements = []
174
+
175
+ # Add nodes
176
+ for node_id in graph.nodes():
177
+ node_data = graph.nodes[node_id]
178
+ elements.append(
179
+ {
180
+ 'data': {
181
+ 'id': node_id,
182
+ 'label': node_id,
183
+ 'color': node_data.get('color', '#7F8C8D'),
184
+ 'shape': node_data.get('shape', 'rectangle'),
185
+ 'element_type': node_data.get('element_type', 'Other'),
186
+ 'parameters': node_data.get('parameters', ''),
187
+ }
188
+ }
189
+ )
190
+
191
+ # Add edges
192
+ for u, v in graph.edges():
193
+ edge_data = graph.edges[u, v]
194
+ elements.append(
195
+ {
196
+ 'data': {
197
+ 'source': u,
198
+ 'target': v,
199
+ 'id': f'{u}-{v}',
200
+ 'label': edge_data.get('label', ''),
201
+ 'parameters': edge_data.get('parameters', ''),
202
+ }
203
+ }
204
+ )
205
+
206
+ return elements
207
+
208
+
209
+ def create_color_picker_input(label: str, input_id: str, default_color: str):
210
+ """Create a compact color picker with DAQ ColorPicker"""
211
+ return html.Div(
212
+ [
213
+ html.Label(
214
+ label, style={'color': 'white', 'font-size': '12px', 'margin-bottom': '5px', 'display': 'block'}
215
+ ),
216
+ daq.ColorPicker(
217
+ id=input_id,
218
+ label='',
219
+ value={'hex': default_color},
220
+ size=200,
221
+ theme={'dark': True},
222
+ style={'margin-bottom': '10px'},
223
+ ),
224
+ ]
225
+ )
226
+
227
+
228
+ def create_style_section(title: str, children: List):
229
+ """Create a collapsible section for organizing controls"""
230
+ return html.Div(
231
+ [
232
+ html.H4(
233
+ title,
234
+ style={
235
+ 'color': 'white',
236
+ 'margin-bottom': '10px',
237
+ 'border-bottom': '2px solid #3498DB',
238
+ 'padding-bottom': '5px',
239
+ },
240
+ ),
241
+ html.Div(children, style={'margin-bottom': '20px'}),
242
+ ]
243
+ )
244
+
245
+
246
+ def create_sidebar():
247
+ """Create the main sidebar with improved organization"""
248
+ return html.Div(
249
+ [
250
+ html.Div(
251
+ [
252
+ html.H3(
253
+ 'Style Controls',
254
+ style={
255
+ 'color': 'white',
256
+ 'margin-bottom': '20px',
257
+ 'text-align': 'center',
258
+ 'border-bottom': '3px solid #9B59B6',
259
+ 'padding-bottom': '10px',
260
+ },
261
+ ),
262
+ # Layout Section
263
+ create_style_section(
264
+ 'Layout',
265
+ [
266
+ dcc.Dropdown(
267
+ id='layout-dropdown',
268
+ options=[
269
+ {'label': 'Klay (horizontal)', 'value': 'klay'},
270
+ {'label': 'Dagre (vertical)', 'value': 'dagre'},
271
+ {'label': 'Breadthfirst', 'value': 'breadthfirst'},
272
+ {'label': 'Cose (force-directed)', 'value': 'cose'},
273
+ {'label': 'Grid', 'value': 'grid'},
274
+ {'label': 'Circle', 'value': 'circle'},
275
+ ],
276
+ value='klay',
277
+ clearable=False,
278
+ style={'width': '100%'},
279
+ ),
280
+ ],
281
+ ),
282
+ # Color Scheme Section
283
+ create_style_section(
284
+ 'Color Scheme',
285
+ [
286
+ dcc.Dropdown(
287
+ id='color-scheme-dropdown',
288
+ options=[{'label': k, 'value': k} for k in VisualizationConfig.COLOR_PRESETS.keys()],
289
+ value='Default',
290
+ style={'width': '100%', 'margin-bottom': '10px'},
291
+ ),
292
+ ],
293
+ ),
294
+ # Color Pickers Section
295
+ create_style_section(
296
+ 'Custom Colors',
297
+ [
298
+ create_color_picker_input('Bus', 'bus-color-picker', '#7F8C8D'),
299
+ create_color_picker_input('Source', 'source-color-picker', '#F1C40F'),
300
+ create_color_picker_input('Sink', 'sink-color-picker', '#F1C40F'),
301
+ create_color_picker_input('Storage', 'storage-color-picker', '#2980B9'),
302
+ create_color_picker_input('Converter', 'converter-color-picker', '#D35400'),
303
+ create_color_picker_input('Edge', 'edge-color-picker', '#808080'),
304
+ ],
305
+ ),
306
+ # Node Settings
307
+ create_style_section(
308
+ 'Node Settings',
309
+ [
310
+ html.Label('Size', style={'color': 'white', 'font-size': '12px'}),
311
+ dcc.Slider(
312
+ id='node-size-slider',
313
+ min=50,
314
+ max=150,
315
+ step=10,
316
+ value=90,
317
+ marks={
318
+ i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}}
319
+ for i in range(50, 151, 25)
320
+ },
321
+ tooltip={'placement': 'bottom', 'always_visible': True},
322
+ ),
323
+ html.Br(),
324
+ html.Label('Font Size', style={'color': 'white', 'font-size': '12px'}),
325
+ dcc.Slider(
326
+ id='font-size-slider',
327
+ min=8,
328
+ max=20,
329
+ step=1,
330
+ value=10,
331
+ marks={
332
+ i: {'label': str(i), 'style': {'color': 'white', 'font-size': '10px'}}
333
+ for i in range(8, 21, 2)
334
+ },
335
+ tooltip={'placement': 'bottom', 'always_visible': True},
336
+ ),
337
+ ],
338
+ ),
339
+ # Reset Button
340
+ html.Div(
341
+ [
342
+ html.Button(
343
+ 'Reset to Defaults',
344
+ id='reset-btn',
345
+ n_clicks=0,
346
+ style={
347
+ 'width': '100%',
348
+ 'background-color': '#E74C3C',
349
+ 'color': 'white',
350
+ 'border': 'none',
351
+ 'padding': '10px',
352
+ 'border-radius': '5px',
353
+ 'cursor': 'pointer',
354
+ 'margin-top': '20px',
355
+ },
356
+ ),
357
+ ]
358
+ ),
359
+ ],
360
+ id='sidebar-content',
361
+ style={
362
+ 'width': '280px',
363
+ 'height': '100vh',
364
+ 'background-color': '#2C3E50',
365
+ 'padding': '20px',
366
+ 'position': 'fixed',
367
+ 'left': '0',
368
+ 'top': '0',
369
+ 'overflow-y': 'auto',
370
+ 'border-right': '3px solid #34495E',
371
+ 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)',
372
+ 'z-index': '999',
373
+ 'transform': 'translateX(-100%)',
374
+ 'transition': 'transform 0.3s ease',
375
+ },
376
+ )
377
+ ]
378
+ )
379
+
380
+
381
+ def shownetwork(graph: networkx.DiGraph):
382
+ """Main function to create and run the network visualization"""
383
+ if not DASH_CYTOSCAPE_AVAILABLE:
384
+ raise ImportError(f'Required packages not available: {VISUALIZATION_ERROR}')
385
+
386
+ app = Dash(__name__, suppress_callback_exceptions=True)
387
+
388
+ # Load extra layouts
389
+ cyto.load_extra_layouts()
390
+
391
+ # Create initial elements
392
+ elements = make_cytoscape_elements(graph)
393
+
394
+ # App Layout
395
+ app.layout = html.Div(
396
+ [
397
+ # Toggle button
398
+ html.Button(
399
+ '☰',
400
+ id='toggle-sidebar',
401
+ n_clicks=0,
402
+ style={
403
+ 'position': 'fixed',
404
+ 'top': '20px',
405
+ 'left': '20px',
406
+ 'z-index': '1000',
407
+ 'background-color': '#3498DB',
408
+ 'color': 'white',
409
+ 'border': 'none',
410
+ 'padding': '10px 15px',
411
+ 'border-radius': '5px',
412
+ 'cursor': 'pointer',
413
+ 'font-size': '18px',
414
+ 'box-shadow': '0 2px 5px rgba(0,0,0,0.3)',
415
+ },
416
+ ),
417
+ # Data storage
418
+ dcc.Store(id='elements-store', data=elements),
419
+ # Sidebar
420
+ create_sidebar(),
421
+ # Main content
422
+ html.Div(
423
+ [
424
+ # Header
425
+ html.Div(
426
+ [
427
+ html.H2(
428
+ 'Network Visualization', style={'color': 'white', 'margin': '0', 'text-align': 'center'}
429
+ ),
430
+ html.Button(
431
+ 'Export PNG',
432
+ id='export-btn',
433
+ n_clicks=0,
434
+ style={
435
+ 'position': 'absolute',
436
+ 'right': '20px',
437
+ 'top': '15px',
438
+ 'background-color': '#27AE60',
439
+ 'color': 'white',
440
+ 'border': 'none',
441
+ 'padding': '10px 20px',
442
+ 'border-radius': '5px',
443
+ 'cursor': 'pointer',
444
+ },
445
+ ),
446
+ ],
447
+ style={
448
+ 'background-color': '#34495E',
449
+ 'padding': '15px 20px',
450
+ 'position': 'relative',
451
+ 'border-bottom': '2px solid #3498DB',
452
+ },
453
+ ),
454
+ # Cytoscape graph
455
+ cyto.Cytoscape(
456
+ id='cytoscape',
457
+ layout={'name': 'klay'},
458
+ style={'width': '100%', 'height': '70vh'},
459
+ elements=elements,
460
+ stylesheet=VisualizationConfig.DEFAULT_STYLESHEET,
461
+ ),
462
+ # Info panel
463
+ html.Div(
464
+ [
465
+ html.H4(
466
+ 'Element Information',
467
+ style={
468
+ 'color': 'white',
469
+ 'margin': '0 0 10px 0',
470
+ 'border-bottom': '2px solid #3498DB',
471
+ 'padding-bottom': '5px',
472
+ },
473
+ ),
474
+ html.Div(
475
+ id='info-panel',
476
+ children=[
477
+ html.P(
478
+ 'Click on a node or edge to see details.',
479
+ style={'color': '#95A5A6', 'font-style': 'italic'},
480
+ )
481
+ ],
482
+ ),
483
+ ],
484
+ style={
485
+ 'background-color': '#2C3E50',
486
+ 'padding': '15px',
487
+ 'height': '25vh',
488
+ 'overflow-y': 'auto',
489
+ 'border-top': '2px solid #34495E',
490
+ },
491
+ ),
492
+ ],
493
+ id='main-content',
494
+ style={
495
+ 'margin-left': '0',
496
+ 'background-color': '#1A252F',
497
+ 'min-height': '100vh',
498
+ 'transition': 'margin-left 0.3s ease',
499
+ },
500
+ ),
501
+ ]
502
+ )
503
+
504
+ # Callbacks
505
+ @app.callback(
506
+ [Output('sidebar-content', 'style'), Output('main-content', 'style')], [Input('toggle-sidebar', 'n_clicks')]
507
+ )
508
+ def toggle_sidebar(n_clicks):
509
+ is_open = (n_clicks or 0) % 2 == 1
510
+ sidebar_transform = 'translateX(0)' if is_open else 'translateX(-100%)'
511
+ main_margin = '280px' if is_open else '0'
512
+
513
+ sidebar_style = {
514
+ 'width': '280px',
515
+ 'height': '100vh',
516
+ 'background-color': '#2C3E50',
517
+ 'padding': '20px',
518
+ 'position': 'fixed',
519
+ 'left': '0',
520
+ 'top': '0',
521
+ 'overflow-y': 'auto',
522
+ 'border-right': '3px solid #34495E',
523
+ 'box-shadow': '2px 0 5px rgba(0,0,0,0.1)',
524
+ 'z-index': '999',
525
+ 'transform': sidebar_transform,
526
+ 'transition': 'transform 0.3s ease',
527
+ }
528
+
529
+ main_style = {
530
+ 'margin-left': main_margin,
531
+ 'background-color': '#1A252F',
532
+ 'min-height': '100vh',
533
+ 'transition': 'margin-left 0.3s ease',
534
+ }
535
+
536
+ return sidebar_style, main_style
537
+
538
+ @app.callback(
539
+ [Output('cytoscape', 'elements'), Output('cytoscape', 'stylesheet')],
540
+ [
541
+ Input('color-scheme-dropdown', 'value'),
542
+ Input('bus-color-picker', 'value'),
543
+ Input('source-color-picker', 'value'),
544
+ Input('sink-color-picker', 'value'),
545
+ Input('storage-color-picker', 'value'),
546
+ Input('converter-color-picker', 'value'),
547
+ Input('edge-color-picker', 'value'),
548
+ Input('node-size-slider', 'value'),
549
+ Input('font-size-slider', 'value'),
550
+ ],
551
+ [State('elements-store', 'data')],
552
+ )
553
+ def update_visualization(
554
+ color_scheme,
555
+ bus_color,
556
+ source_color,
557
+ sink_color,
558
+ storage_color,
559
+ converter_color,
560
+ edge_color,
561
+ node_size,
562
+ font_size,
563
+ stored_elements,
564
+ ):
565
+ if not stored_elements:
566
+ return no_update, no_update
567
+
568
+ # Determine colors to use
569
+ if any(picker for picker in [bus_color, source_color, sink_color, storage_color, converter_color, edge_color]):
570
+ # Use custom colors from pickers
571
+ colors = {
572
+ 'Bus': bus_color.get('hex') if bus_color else '#7F8C8D',
573
+ 'Source': source_color.get('hex') if source_color else '#F1C40F',
574
+ 'Sink': sink_color.get('hex') if sink_color else '#F1C40F',
575
+ 'Storage': storage_color.get('hex') if storage_color else '#2980B9',
576
+ 'Converter': converter_color.get('hex') if converter_color else '#D35400',
577
+ 'Other': '#27AE60',
578
+ }
579
+ else:
580
+ # Use preset scheme
581
+ colors = VisualizationConfig.COLOR_PRESETS.get(color_scheme, VisualizationConfig.DEFAULT_COLORS)
582
+
583
+ # Update element colors
584
+ updated_elements = []
585
+ for element in stored_elements:
586
+ if 'data' in element and 'element_type' in element['data']:
587
+ element_copy = element.copy()
588
+ element_copy['data'] = element['data'].copy()
589
+ element_type = element_copy['data']['element_type']
590
+ if element_type in colors:
591
+ element_copy['data']['color'] = colors[element_type]
592
+ updated_elements.append(element_copy)
593
+ else:
594
+ updated_elements.append(element)
595
+
596
+ # Create stylesheet
597
+ edge_color_hex = edge_color.get('hex') if edge_color else 'gray'
598
+ stylesheet = [
599
+ {
600
+ 'selector': 'node',
601
+ 'style': {
602
+ 'content': 'data(label)',
603
+ 'background-color': 'data(color)',
604
+ 'font-size': font_size or 10,
605
+ 'color': 'white',
606
+ 'text-valign': 'center',
607
+ 'text-halign': 'center',
608
+ 'width': f'{node_size or 90}px',
609
+ 'height': f'{int((node_size or 90) * 0.8)}px',
610
+ 'shape': 'data(shape)',
611
+ 'text-outline-color': 'black',
612
+ 'text-outline-width': 0.5,
613
+ },
614
+ },
615
+ {
616
+ 'selector': '[shape = "custom-source"]',
617
+ 'style': {
618
+ 'shape': 'polygon',
619
+ 'shape-polygon-points': '-0.5 0.5, 0.5 0.5, 1 -0.5, -1 -0.5',
620
+ },
621
+ },
622
+ {
623
+ 'selector': '[shape = "custom-sink"]',
624
+ 'style': {
625
+ 'shape': 'polygon',
626
+ 'shape-polygon-points': '-0.5 -0.5, 0.5 -0.5, 1 0.5, -1 0.5',
627
+ },
628
+ },
629
+ {
630
+ 'selector': 'edge',
631
+ 'style': {
632
+ 'curve-style': 'straight',
633
+ 'width': 2,
634
+ 'line-color': edge_color_hex,
635
+ 'target-arrow-color': edge_color_hex,
636
+ 'target-arrow-shape': 'triangle',
637
+ 'arrow-scale': 2,
638
+ },
639
+ },
640
+ ]
641
+
642
+ return updated_elements, stylesheet
643
+
644
+ @app.callback(
645
+ Output('info-panel', 'children'), [Input('cytoscape', 'tapNodeData'), Input('cytoscape', 'tapEdgeData')]
646
+ )
647
+ def display_element_info(node_data, edge_data):
648
+ ctx = callback_context
649
+ if not ctx.triggered:
650
+ return [
651
+ html.P('Click on a node or edge to see details.', style={'color': '#95A5A6', 'font-style': 'italic'})
652
+ ]
653
+
654
+ # Determine what was clicked
655
+ if ctx.triggered[0]['prop_id'] == 'cytoscape.tapNodeData' and node_data:
656
+ return [
657
+ html.H5(
658
+ f'Node: {node_data.get("label", "Unknown")}', style={'color': 'white', 'margin-bottom': '10px'}
659
+ ),
660
+ html.P(f'Type: {node_data.get("element_type", "Unknown")}', style={'color': '#BDC3C7'}),
661
+ html.Pre(
662
+ node_data.get('parameters', 'No parameters'),
663
+ style={'color': '#BDC3C7', 'font-size': '11px', 'white-space': 'pre-wrap'},
664
+ ),
665
+ ]
666
+ elif ctx.triggered[0]['prop_id'] == 'cytoscape.tapEdgeData' and edge_data:
667
+ return [
668
+ html.H5(
669
+ f'Edge: {edge_data.get("label", "Unknown")}', style={'color': 'white', 'margin-bottom': '10px'}
670
+ ),
671
+ html.P(f'{edge_data.get("source", "")} → {edge_data.get("target", "")}', style={'color': '#E67E22'}),
672
+ html.Pre(
673
+ edge_data.get('parameters', 'No parameters'),
674
+ style={'color': '#BDC3C7', 'font-size': '11px', 'white-space': 'pre-wrap'},
675
+ ),
676
+ ]
677
+
678
+ return [html.P('Click on a node or edge to see details.', style={'color': '#95A5A6', 'font-style': 'italic'})]
679
+
680
+ @app.callback(Output('cytoscape', 'layout'), Input('layout-dropdown', 'value'))
681
+ def update_layout(selected_layout):
682
+ return {'name': selected_layout}
683
+
684
+ # Reset callback
685
+ @app.callback(
686
+ [
687
+ Output('color-scheme-dropdown', 'value'),
688
+ Output('bus-color-picker', 'value'),
689
+ Output('source-color-picker', 'value'),
690
+ Output('sink-color-picker', 'value'),
691
+ Output('storage-color-picker', 'value'),
692
+ Output('converter-color-picker', 'value'),
693
+ Output('edge-color-picker', 'value'),
694
+ Output('node-size-slider', 'value'),
695
+ Output('font-size-slider', 'value'),
696
+ Output('layout-dropdown', 'value'),
697
+ ],
698
+ [Input('reset-btn', 'n_clicks')],
699
+ )
700
+ def reset_controls(n_clicks):
701
+ if n_clicks and n_clicks > 0:
702
+ return (
703
+ 'Default', # color scheme
704
+ {'hex': '#7F8C8D'}, # bus
705
+ {'hex': '#F1C40F'}, # source
706
+ {'hex': '#F1C40F'}, # sink
707
+ {'hex': '#2980B9'}, # storage
708
+ {'hex': '#D35400'}, # converter
709
+ {'hex': '#808080'}, # edge
710
+ 90, # node size
711
+ 10, # font size
712
+ 'klay', # layout
713
+ )
714
+ return no_update
715
+
716
+ # Export functionality
717
+ app.clientside_callback(
718
+ """
719
+ function(n_clicks) {
720
+ if (n_clicks > 0 && window.cy) {
721
+ var png64 = window.cy.png({scale: 3, full: true});
722
+ var a = document.createElement('a');
723
+ a.href = png64;
724
+ a.download = 'network_visualization.png';
725
+ a.click();
726
+ }
727
+ return 'Export PNG';
728
+ }
729
+ """,
730
+ Output('export-btn', 'children'),
731
+ Input('export-btn', 'n_clicks'),
732
+ )
733
+
734
+ # Start server
735
+ def find_free_port(start_port=8050, end_port=8100):
736
+ for port in range(start_port, end_port):
737
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
738
+ if s.connect_ex(('localhost', port)) != 0:
739
+ return port
740
+ raise Exception('No free port found')
741
+
742
+ port = find_free_port()
743
+ server = make_server('127.0.0.1', port, app.server)
744
+
745
+ # Start server in background thread
746
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
747
+ server_thread.start()
748
+
749
+ print(f'Network visualization started on http://127.0.0.1:{port}/')
750
+
751
+ # Store server reference for cleanup
752
+ app.server_instance = server
753
+ app.port = port
754
+
755
+ return app