ArnoChen commited on
Commit
da4900a
·
1 Parent(s): 1686e54

split lightrag_visualizer into separate module and update entry point

Browse files
lightrag/tools/lightrag_visualizer/__init__.py CHANGED
@@ -1,1226 +0,0 @@
1
- """
2
- 3D GraphML Viewer using Dear ImGui and ModernGL
3
- Author: ParisNeo, ArnoChen
4
- Description: An interactive 3D GraphML viewer using imgui_bundle and ModernGL
5
- Version: 2.0
6
- """
7
-
8
- from typing import Optional, Tuple, Dict, List
9
- import numpy as np
10
- import networkx as nx
11
- import pipmaster as pm
12
-
13
- # Added automatic libraries install using pipmaster
14
- if not pm.is_installed("moderngl"):
15
- pm.install("moderngl")
16
- if not pm.is_installed("imgui_bundle"):
17
- pm.install("imgui_bundle")
18
- if not pm.is_installed("pyglm"):
19
- pm.install("pyglm")
20
- if not pm.is_installed("python-louvain"):
21
- pm.install("python-louvain")
22
-
23
- import moderngl
24
- from imgui_bundle import imgui, immapp, hello_imgui
25
- import community
26
- import glm
27
- import tkinter as tk
28
- from tkinter import filedialog
29
- import traceback
30
- import colorsys
31
- import os
32
-
33
- CUSTOM_FONT = "font.ttf"
34
-
35
- DEFAULT_FONT_ENG = "Geist-Regular.ttf"
36
- DEFAULT_FONT_CHI = "SmileySans-Oblique.ttf"
37
-
38
-
39
- class Node3D:
40
- """Class representing a 3D node in the graph"""
41
-
42
- def __init__(
43
- self, position: glm.vec3, color: glm.vec3, label: str, size: float, idx: int
44
- ):
45
- self.position = position
46
- self.color = color
47
- self.label = label
48
- self.size = size
49
- self.idx = idx
50
-
51
-
52
- class GraphViewer:
53
- """Main class for 3D graph visualization"""
54
-
55
- def __init__(self):
56
- self.glctx = None # ModernGL context
57
- self.graph: Optional[nx.Graph] = None
58
- self.nodes: List[Node3D] = []
59
- self.id_node_map: Dict[str, Node3D] = {}
60
- self.communities = None
61
- self.community_colors = None
62
-
63
- # Window dimensions
64
- self.window_width = 1280
65
- self.window_height = 720
66
-
67
- # Camera parameters
68
- self.position = glm.vec3(0.0, -10.0, 0.0) # Initial camera position
69
- self.front = glm.vec3(0.0, 1.0, 0.0) # Direction camera is facing
70
- self.up = glm.vec3(0.0, 0.0, 1.0) # Up vector
71
- self.yaw = 90.0 # Horizontal rotation (around Z axis)
72
- self.pitch = 0.0 # Vertical rotation
73
- self.move_speed = 0.05
74
- self.mouse_sensitivity = 0.15
75
-
76
- # Graph visualization settings
77
- self.layout_type = "Spring"
78
- self.node_scale = 0.2
79
- self.edge_width = 0.5
80
- self.show_labels = True
81
- self.label_size = 2
82
- self.label_color = (1.0, 1.0, 1.0, 1.0)
83
- self.label_culling_distance = 10.0
84
- self.available_layouts = ("Spring", "Circular", "Shell", "Random")
85
- self.background_color = (0.05, 0.05, 0.05, 1.0)
86
-
87
- # Mouse interaction
88
- self.last_mouse_pos = None
89
- self.mouse_pressed = False
90
- self.mouse_button = -1
91
- self.first_mouse = True
92
-
93
- # File dialog state
94
- self.show_load_error = False
95
- self.error_message = ""
96
-
97
- # Selection state
98
- self.selected_node: Optional[Node3D] = None
99
- self.highlighted_node: Optional[Node3D] = None
100
-
101
- # Node id map
102
- self.node_id_fbo = None
103
- self.node_id_texture = None
104
- self.node_id_depth = None
105
- self.node_id_texture_np: np.ndarray = None
106
-
107
- # Static data
108
- self.sphere_data = create_sphere()
109
-
110
- # Initialization flag
111
- self.initialized = False
112
-
113
- def setup(self):
114
- self.setup_render_context()
115
- self.setup_shaders()
116
- self.setup_buffers()
117
- self.initialized = True
118
-
119
- def handle_keyboard_input(self):
120
- """Handle WASD keyboard input for camera movement"""
121
- io = imgui.get_io()
122
-
123
- if io.want_capture_keyboard:
124
- return
125
-
126
- # Calculate camera vectors
127
- right = glm.normalize(glm.cross(self.front, self.up))
128
-
129
- # Get movement direction from WASD keys
130
- if imgui.is_key_down(imgui.Key.w): # Forward
131
- self.position += self.front * self.move_speed * 0.1
132
- if imgui.is_key_down(imgui.Key.s): # Backward
133
- self.position -= self.front * self.move_speed * 0.1
134
- if imgui.is_key_down(imgui.Key.a): # Left
135
- self.position -= right * self.move_speed * 0.1
136
- if imgui.is_key_down(imgui.Key.d): # Right
137
- self.position += right * self.move_speed * 0.1
138
- if imgui.is_key_down(imgui.Key.q): # Up
139
- self.position += self.up * self.move_speed * 0.1
140
- if imgui.is_key_down(imgui.Key.e): # Down
141
- self.position -= self.up * self.move_speed * 0.1
142
-
143
- def handle_mouse_interaction(self):
144
- """Handle mouse interaction for camera control and node selection"""
145
- if (
146
- imgui.is_any_item_active()
147
- or imgui.is_any_item_hovered()
148
- or imgui.is_any_item_focused()
149
- ):
150
- return
151
-
152
- io = imgui.get_io()
153
- mouse_pos = (io.mouse_pos.x, io.mouse_pos.y)
154
- if (
155
- mouse_pos[0] < 0
156
- or mouse_pos[1] < 0
157
- or mouse_pos[0] >= self.window_width
158
- or mouse_pos[1] >= self.window_height
159
- ):
160
- return
161
-
162
- # Handle first mouse input
163
- if self.first_mouse:
164
- self.last_mouse_pos = mouse_pos
165
- self.first_mouse = False
166
- return
167
-
168
- # Handle mouse movement for camera rotation
169
- if self.mouse_pressed and self.mouse_button == 1: # Right mouse button
170
- dx = self.last_mouse_pos[0] - mouse_pos[0]
171
- dy = self.last_mouse_pos[1] - mouse_pos[1] # Reversed for intuitive control
172
-
173
- dx *= self.mouse_sensitivity
174
- dy *= self.mouse_sensitivity
175
-
176
- self.yaw += dx
177
- self.pitch += dy
178
-
179
- # Limit pitch to avoid flipping
180
- self.pitch = np.clip(self.pitch, -89.0, 89.0)
181
-
182
- # Update front vector
183
- self.front = glm.normalize(
184
- glm.vec3(
185
- np.cos(np.radians(self.yaw)) * np.cos(np.radians(self.pitch)),
186
- np.sin(np.radians(self.yaw)) * np.cos(np.radians(self.pitch)),
187
- np.sin(np.radians(self.pitch)),
188
- )
189
- )
190
-
191
- if not imgui.is_window_hovered():
192
- return
193
-
194
- if io.mouse_wheel != 0:
195
- self.move_speed += io.mouse_wheel * 0.05
196
- self.move_speed = np.max([self.move_speed, 0.01])
197
-
198
- # Handle mouse press/release
199
- for button in range(3):
200
- if imgui.is_mouse_clicked(button):
201
- self.mouse_pressed = True
202
- self.mouse_button = button
203
- if button == 0 and self.highlighted_node: # Left click for selection
204
- self.selected_node = self.highlighted_node
205
-
206
- if imgui.is_mouse_released(button) and self.mouse_button == button:
207
- self.mouse_pressed = False
208
- self.mouse_button = -1
209
-
210
- # Handle node hovering
211
- if not self.mouse_pressed:
212
- hovered = self.find_node_at((int(mouse_pos[0]), int(mouse_pos[1])))
213
- self.highlighted_node = hovered
214
-
215
- # Update last mouse position
216
- self.last_mouse_pos = mouse_pos
217
-
218
- def update_layout(self):
219
- """Update the graph layout"""
220
- pos = nx.spring_layout(
221
- self.graph,
222
- dim=3,
223
- pos={
224
- node_id: list(node.position)
225
- for node_id, node in self.id_node_map.items()
226
- },
227
- k=2.0,
228
- iterations=100,
229
- weight=None,
230
- )
231
-
232
- # Update node positions
233
- for node_id, position in pos.items():
234
- self.id_node_map[node_id].position = glm.vec3(position)
235
- self.update_buffers()
236
-
237
- def render_node_details(self):
238
- """Render node details window"""
239
- if self.selected_node and imgui.begin("Node Details"):
240
- imgui.text(f"ID: {self.selected_node.label}")
241
-
242
- if self.graph:
243
- node_data = self.graph.nodes[self.selected_node.label]
244
- imgui.text(f"Type: {node_data.get('type', 'default')}")
245
-
246
- degree = self.graph.degree[self.selected_node.label]
247
- imgui.text(f"Degree: {degree}")
248
-
249
- for key, value in node_data.items():
250
- if key != "type":
251
- imgui.text(f"{key}: {value}")
252
- if value and imgui.is_item_hovered():
253
- imgui.set_tooltip(str(value))
254
-
255
- imgui.separator()
256
-
257
- connections = self.graph[self.selected_node.label]
258
- if connections:
259
- imgui.text("Connections:")
260
- keys = next(iter(connections.values())).keys()
261
- if imgui.begin_table(
262
- "Connections",
263
- len(keys) + 1,
264
- imgui.TableFlags_.borders
265
- | imgui.TableFlags_.row_bg
266
- | imgui.TableFlags_.resizable
267
- | imgui.TableFlags_.hideable,
268
- ):
269
- imgui.table_setup_column("Node")
270
- for key in keys:
271
- imgui.table_setup_column(key)
272
- imgui.table_headers_row()
273
-
274
- for neighbor, edge_data in connections.items():
275
- imgui.table_next_row()
276
- imgui.table_set_column_index(0)
277
- if imgui.selectable(str(neighbor), True)[0]:
278
- # Select neighbor node
279
- self.selected_node = self.id_node_map[neighbor]
280
- self.position = self.selected_node.position - self.front
281
- for idx, key in enumerate(keys):
282
- imgui.table_set_column_index(idx + 1)
283
- value = str(edge_data.get(key, ""))
284
- imgui.text(value)
285
- if value and imgui.is_item_hovered():
286
- imgui.set_tooltip(value)
287
- imgui.end_table()
288
-
289
- imgui.end()
290
-
291
- def setup_render_context(self):
292
- """Initialize ModernGL context"""
293
- self.glctx = moderngl.create_context()
294
- self.glctx.enable(moderngl.DEPTH_TEST | moderngl.CULL_FACE)
295
- self.glctx.clear_color = self.background_color
296
-
297
- def setup_shaders(self):
298
- """Setup vertex and fragment shaders for node and edge rendering"""
299
- # Node shader program
300
- self.node_prog = self.glctx.program(
301
- vertex_shader="""
302
- #version 330
303
-
304
- uniform mat4 mvp;
305
- uniform vec3 camera;
306
- uniform int selected_node;
307
- uniform int highlighted_node;
308
- uniform float scale;
309
-
310
- in vec3 in_position;
311
- in vec3 in_instance_position;
312
- in vec3 in_instance_color;
313
- in float in_instance_size;
314
-
315
- out vec3 frag_color;
316
- out vec3 frag_normal;
317
- out vec3 frag_view_dir;
318
-
319
- void main() {
320
- vec3 pos = in_position * in_instance_size * scale + in_instance_position;
321
- gl_Position = mvp * vec4(pos, 1.0);
322
-
323
- frag_normal = normalize(in_position);
324
- frag_view_dir = normalize(camera - pos);
325
-
326
- if (selected_node == gl_InstanceID) {
327
- frag_color = vec3(1.0, 0.5, 0.0);
328
- }
329
- else if (highlighted_node == gl_InstanceID) {
330
- frag_color = vec3(1.0, 0.8, 0.2);
331
- }
332
- else {
333
- frag_color = in_instance_color;
334
- }
335
- }
336
- """,
337
- fragment_shader="""
338
- #version 330
339
-
340
- in vec3 frag_color;
341
- in vec3 frag_normal;
342
- in vec3 frag_view_dir;
343
-
344
- out vec4 outColor;
345
-
346
- void main() {
347
- // Edge detection based on normal-view angle
348
- float edge = 1.0 - abs(dot(frag_normal, frag_view_dir));
349
-
350
- // Create sharp outline
351
- float outline = smoothstep(0.8, 0.9, edge);
352
-
353
- // Mix the sphere color with outline
354
- vec3 final_color = mix(frag_color, vec3(0.0), outline);
355
-
356
- outColor = vec4(final_color, 1.0);
357
- }
358
- """,
359
- )
360
-
361
- # Edge shader program with wide lines using geometry shader
362
- self.edge_prog = self.glctx.program(
363
- vertex_shader="""
364
- #version 330
365
-
366
- uniform mat4 mvp;
367
-
368
- in vec3 in_position;
369
- in vec3 in_color;
370
-
371
- out vec3 v_color;
372
- out vec4 v_position;
373
-
374
- void main() {
375
- v_position = mvp * vec4(in_position, 1.0);
376
- gl_Position = v_position;
377
- v_color = in_color;
378
- }
379
- """,
380
- geometry_shader="""
381
- #version 330
382
-
383
- layout(lines) in;
384
- layout(triangle_strip, max_vertices = 4) out;
385
-
386
- uniform float edge_width;
387
- uniform vec2 viewport_size;
388
-
389
- in vec3 v_color[];
390
- in vec4 v_position[];
391
- out vec3 g_color;
392
- out float edge_coord;
393
-
394
- void main() {
395
- // Get the two vertices of the line
396
- vec4 p1 = v_position[0];
397
- vec4 p2 = v_position[1];
398
-
399
- // Perspective division
400
- vec4 p1_ndc = p1 / p1.w;
401
- vec4 p2_ndc = p2 / p2.w;
402
-
403
- // Calculate line direction in screen space
404
- vec2 dir = normalize((p2_ndc.xy - p1_ndc.xy) * viewport_size);
405
- vec2 normal = vec2(-dir.y, dir.x);
406
-
407
- // Calculate half width based on screen space
408
- float half_width = edge_width * 0.5;
409
- vec2 offset = normal * (half_width / viewport_size);
410
-
411
- // Emit vertices with proper depth
412
- gl_Position = vec4(p1_ndc.xy + offset, p1_ndc.z, 1.0);
413
- gl_Position *= p1.w; // Restore perspective
414
- g_color = v_color[0];
415
- edge_coord = 1.0;
416
- EmitVertex();
417
-
418
- gl_Position = vec4(p1_ndc.xy - offset, p1_ndc.z, 1.0);
419
- gl_Position *= p1.w;
420
- g_color = v_color[0];
421
- edge_coord = -1.0;
422
- EmitVertex();
423
-
424
- gl_Position = vec4(p2_ndc.xy + offset, p2_ndc.z, 1.0);
425
- gl_Position *= p2.w;
426
- g_color = v_color[1];
427
- edge_coord = 1.0;
428
- EmitVertex();
429
-
430
- gl_Position = vec4(p2_ndc.xy - offset, p2_ndc.z, 1.0);
431
- gl_Position *= p2.w;
432
- g_color = v_color[1];
433
- edge_coord = -1.0;
434
- EmitVertex();
435
-
436
- EndPrimitive();
437
- }
438
- """,
439
- fragment_shader="""
440
- #version 330
441
-
442
- in vec3 g_color;
443
- in float edge_coord;
444
-
445
- out vec4 fragColor;
446
-
447
- void main() {
448
- // Edge outline parameters
449
- float outline_width = 0.2; // Width of the outline relative to edge
450
- float edge_softness = 0.1; // Softness of the edge
451
- float edge_dist = abs(edge_coord);
452
-
453
- // Calculate outline
454
- float outline_factor = smoothstep(1.0 - outline_width - edge_softness,
455
- 1.0 - outline_width,
456
- edge_dist);
457
-
458
- // Mix edge color with outline (black)
459
- vec3 final_color = mix(g_color, vec3(0.0), outline_factor);
460
-
461
- // Calculate alpha for anti-aliasing
462
- float alpha = 1.0 - smoothstep(1.0 - edge_softness, 1.0, edge_dist);
463
-
464
- fragColor = vec4(final_color, alpha);
465
- }
466
- """,
467
- )
468
-
469
- # Id framebuffer shader program
470
- self.node_id_prog = self.glctx.program(
471
- vertex_shader="""
472
- #version 330
473
-
474
- uniform mat4 mvp;
475
- uniform float scale;
476
-
477
- in vec3 in_position;
478
- in vec3 in_instance_position;
479
- in float in_instance_size;
480
-
481
- out vec3 frag_color;
482
-
483
- vec3 int_to_rgb(int value) {
484
- float R = float((value >> 16) & 0xFF);
485
- float G = float((value >> 8) & 0xFF);
486
- float B = float(value & 0xFF);
487
- // normalize to [0, 1]
488
- return vec3(R / 255.0, G / 255.0, B / 255.0);
489
- }
490
-
491
- void main() {
492
- vec3 pos = in_position * in_instance_size * scale + in_instance_position;
493
- gl_Position = mvp * vec4(pos, 1.0);
494
- frag_color = int_to_rgb(gl_InstanceID);
495
- }
496
- """,
497
- fragment_shader="""
498
- #version 330
499
- in vec3 frag_color;
500
- out vec4 outColor;
501
- void main() {
502
- outColor = vec4(frag_color, 1.0);
503
- }
504
- """,
505
- )
506
-
507
- def setup_buffers(self):
508
- """Setup vertex buffers for nodes and edges"""
509
- # We'll create these when loading the graph
510
- self.node_vbo = None
511
- self.node_color_vbo = None
512
- self.node_size_vbo = None
513
- self.edge_vbo = None
514
- self.edge_color_vbo = None
515
- self.node_vao = None
516
- self.edge_vao = None
517
- self.node_id_vao = None
518
- self.sphere_pos_vbo = None
519
- self.sphere_index_buffer = None
520
-
521
- def load_file(self, filepath: str):
522
- """Load a GraphML file with error handling"""
523
- try:
524
- # Clear existing data
525
- self.id_node_map.clear()
526
- self.nodes.clear()
527
- self.selected_node = None
528
- self.highlighted_node = None
529
- self.setup_buffers()
530
-
531
- # Load new graph
532
- self.graph = nx.read_graphml(filepath)
533
- self.calculate_layout()
534
- self.update_buffers()
535
- self.show_load_error = False
536
- self.error_message = ""
537
- except Exception as _:
538
- self.show_load_error = True
539
- self.error_message = traceback.format_exc()
540
- print(self.error_message)
541
-
542
- def calculate_layout(self):
543
- """Calculate 3D layout for the graph"""
544
- if not self.graph:
545
- return
546
-
547
- # Detect communities for coloring
548
- self.communities = community.best_partition(self.graph)
549
- num_communities = len(set(self.communities.values()))
550
- self.community_colors = generate_colors(num_communities)
551
-
552
- # Calculate layout based on selected type
553
- if self.layout_type == "Spring":
554
- pos = nx.spring_layout(
555
- self.graph, dim=3, k=2.0, iterations=100, weight=None
556
- )
557
- elif self.layout_type == "Circular":
558
- pos_2d = nx.circular_layout(self.graph)
559
- pos = {node: np.array((x, 0.0, y)) for node, (x, y) in pos_2d.items()}
560
- elif self.layout_type == "Shell":
561
- # Group nodes by community for shell layout
562
- comm_lists = [[] for _ in range(num_communities)]
563
- for node, comm in self.communities.items():
564
- comm_lists[comm].append(node)
565
- pos_2d = nx.shell_layout(self.graph, comm_lists)
566
- pos = {node: np.array((x, 0.0, y)) for node, (x, y) in pos_2d.items()}
567
- else: # Random
568
- pos = {node: np.random.rand(3) * 2 - 1 for node in self.graph.nodes()}
569
-
570
- # Scale positions
571
- positions = np.array(list(pos.values()))
572
- if len(positions) > 0:
573
- scale = 10.0 / max(1.0, np.max(np.abs(positions)))
574
- pos = {node: coords * scale for node, coords in pos.items()}
575
-
576
- # Calculate degree-based sizes
577
- degrees = dict(self.graph.degree())
578
- max_degree = max(degrees.values()) if degrees else 1
579
- min_degree = min(degrees.values()) if degrees else 1
580
-
581
- idx = 0
582
- # Create nodes with community colors
583
- for node_id in self.graph.nodes():
584
- position = glm.vec3(pos[node_id])
585
- color = self.get_node_color(node_id)
586
-
587
- # Normalize sizes between 0.5 and 2.0
588
- size = 1.0
589
- if max_degree != min_degree:
590
- # Normalize and scale size
591
- normalized = (degrees[node_id] - min_degree) / (max_degree - min_degree)
592
- size = 0.5 + normalized * 1.5
593
-
594
- if node_id in self.id_node_map:
595
- node = self.id_node_map[node_id]
596
- node.position = position
597
- node.base_color = color
598
- node.color = color
599
- node.size = size
600
- else:
601
- node = Node3D(position, color, str(node_id), size, idx)
602
- self.id_node_map[node_id] = node
603
- self.nodes.append(node)
604
- idx += 1
605
-
606
- self.update_buffers()
607
-
608
- def get_node_color(self, node_id: str) -> glm.vec3:
609
- """Get RGBA color based on community"""
610
- if self.communities and node_id in self.communities:
611
- comm_id = self.communities[node_id]
612
- color = self.community_colors[comm_id]
613
- return color
614
- return glm.vec3(0.5, 0.5, 0.5)
615
-
616
- def update_buffers(self):
617
- """Update vertex buffers with current node and edge data using batch rendering"""
618
- if not self.graph:
619
- return
620
-
621
- # Update node buffers
622
- node_positions = []
623
- node_colors = []
624
- node_sizes = []
625
-
626
- for node in self.nodes:
627
- node_positions.append(node.position)
628
- node_colors.append(node.color) # Only use RGB components
629
- node_sizes.append(node.size)
630
-
631
- if node_positions:
632
- node_positions = np.array(node_positions, dtype=np.float32)
633
- node_colors = np.array(node_colors, dtype=np.float32)
634
- node_sizes = np.array(node_sizes, dtype=np.float32)
635
-
636
- self.node_vbo = self.glctx.buffer(node_positions.tobytes())
637
- self.node_color_vbo = self.glctx.buffer(node_colors.tobytes())
638
- self.node_size_vbo = self.glctx.buffer(node_sizes.tobytes())
639
- self.sphere_pos_vbo = self.glctx.buffer(self.sphere_data[0].tobytes())
640
- self.sphere_index_buffer = self.glctx.buffer(self.sphere_data[1].tobytes())
641
-
642
- self.node_vao = self.glctx.vertex_array(
643
- self.node_prog,
644
- [
645
- (self.sphere_pos_vbo, "3f", "in_position"),
646
- (self.node_vbo, "3f /i", "in_instance_position"),
647
- (self.node_color_vbo, "3f /i", "in_instance_color"),
648
- (self.node_size_vbo, "f /i", "in_instance_size"),
649
- ],
650
- index_buffer=self.sphere_index_buffer,
651
- index_element_size=4,
652
- )
653
- self.node_vao.instances = len(self.nodes)
654
-
655
- self.node_id_vao = self.glctx.vertex_array(
656
- self.node_id_prog,
657
- [
658
- (self.sphere_pos_vbo, "3f", "in_position"),
659
- (self.node_vbo, "3f /i", "in_instance_position"),
660
- (self.node_size_vbo, "f /i", "in_instance_size"),
661
- ],
662
- index_buffer=self.sphere_index_buffer,
663
- index_element_size=4,
664
- )
665
- self.node_id_vao.instances = len(self.nodes)
666
-
667
- # Update edge buffers
668
- edge_positions = []
669
- edge_colors = []
670
-
671
- for edge in self.graph.edges():
672
- start_node = self.id_node_map[edge[0]]
673
- end_node = self.id_node_map[edge[1]]
674
-
675
- edge_positions.append(start_node.position)
676
- edge_colors.append(start_node.color)
677
-
678
- edge_positions.append(end_node.position)
679
- edge_colors.append(end_node.color)
680
-
681
- if edge_positions:
682
- edge_positions = np.array(edge_positions, dtype=np.float32)
683
- edge_colors = np.array(edge_colors, dtype=np.float32)
684
-
685
- self.edge_vbo = self.glctx.buffer(edge_positions.tobytes())
686
- self.edge_color_vbo = self.glctx.buffer(edge_colors.tobytes())
687
-
688
- self.edge_vao = self.glctx.vertex_array(
689
- self.edge_prog,
690
- [
691
- (self.edge_vbo, "3f", "in_position"),
692
- (self.edge_color_vbo, "3f", "in_color"),
693
- ],
694
- )
695
-
696
- def update_view_proj_matrix(self):
697
- """Update view matrix based on camera parameters"""
698
- self.view_matrix = glm.lookAt(
699
- self.position, self.position + self.front, self.up
700
- )
701
-
702
- aspect_ratio = self.window_width / self.window_height
703
- self.proj_matrix = glm.perspective(
704
- glm.radians(60.0), # FOV
705
- aspect_ratio, # Aspect ratio
706
- 0.001, # Near plane
707
- 1000.0, # Far plane
708
- )
709
-
710
- def find_node_at(self, screen_pos: Tuple[int, int]) -> Optional[Node3D]:
711
- """Find the node at a specific screen position"""
712
- if (
713
- self.node_id_texture_np is None
714
- or self.node_id_texture_np.shape[1] != self.window_width
715
- or self.node_id_texture_np.shape[0] != self.window_height
716
- or screen_pos[0] < 0
717
- or screen_pos[1] < 0
718
- or screen_pos[0] >= self.window_width
719
- or screen_pos[1] >= self.window_height
720
- ):
721
- return None
722
-
723
- x = screen_pos[0]
724
- y = self.window_height - screen_pos[1] - 1
725
- pixel = self.node_id_texture_np[y, x]
726
-
727
- if pixel[3] == 0:
728
- return None
729
-
730
- R = int(round(pixel[0] * 255))
731
- G = int(round(pixel[1] * 255))
732
- B = int(round(pixel[2] * 255))
733
- index = (R << 16) | (G << 8) | B
734
-
735
- if index > len(self.nodes):
736
- return None
737
- return self.nodes[index]
738
-
739
- def is_node_visible_at(self, screen_pos: Tuple[int, int], node_idx: int) -> bool:
740
- """Check if a node exists at a specific screen position"""
741
- node = self.find_node_at(screen_pos)
742
- return node is not None and node.idx == node_idx
743
-
744
- def render_settings(self):
745
- """Render settings window"""
746
- if imgui.begin("Graph Settings"):
747
- # Layout type combo
748
- changed, value = imgui.combo(
749
- "Layout",
750
- self.available_layouts.index(self.layout_type),
751
- self.available_layouts,
752
- )
753
- if changed:
754
- self.layout_type = self.available_layouts[value]
755
- self.calculate_layout() # Recalculate layout when changed
756
-
757
- # Node size slider
758
- changed, value = imgui.slider_float("Node Scale", self.node_scale, 0.01, 10)
759
- if changed:
760
- self.node_scale = value
761
-
762
- # Edge width slider
763
- changed, value = imgui.slider_float("Edge Width", self.edge_width, 0, 20)
764
- if changed:
765
- self.edge_width = value
766
-
767
- # Show labels checkbox
768
- changed, value = imgui.checkbox("Show Labels", self.show_labels)
769
-
770
- if changed:
771
- self.show_labels = value
772
-
773
- if self.show_labels:
774
- # Label size slider
775
- changed, value = imgui.slider_float(
776
- "Label Size", self.label_size, 0.5, 10.0
777
- )
778
- if changed:
779
- self.label_size = value
780
-
781
- # Label color picker
782
- changed, value = imgui.color_edit4(
783
- "Label Color",
784
- self.label_color,
785
- imgui.ColorEditFlags_.picker_hue_wheel,
786
- )
787
- if changed:
788
- self.label_color = (value[0], value[1], value[2], value[3])
789
-
790
- # Label culling distance slider
791
- changed, value = imgui.slider_float(
792
- "Label Culling Distance", self.label_culling_distance, 0.1, 100.0
793
- )
794
- if changed:
795
- self.label_culling_distance = value
796
-
797
- # Background color picker
798
- changed, value = imgui.color_edit4(
799
- "Background Color",
800
- self.background_color,
801
- imgui.ColorEditFlags_.picker_hue_wheel,
802
- )
803
- if changed:
804
- self.background_color = (value[0], value[1], value[2], value[3])
805
-
806
- imgui.end()
807
-
808
- def save_node_id_texture_to_png(self, filename):
809
- # Convert to a PIL Image and save as PNG
810
- from PIL import Image
811
-
812
- scaled_array = self.node_id_texture_np * 255
813
- img = Image.fromarray(
814
- scaled_array.astype(np.uint8),
815
- "RGBA",
816
- )
817
- img = img.transpose(method=Image.FLIP_TOP_BOTTOM)
818
- img.save(filename)
819
-
820
- def render_id_map(self, mvp: glm.mat4):
821
- """Render an offscreen id map where each node is drawn with a unique id color."""
822
- # Lazy initialization of id framebuffer
823
- if self.node_id_texture is not None:
824
- if (
825
- self.node_id_texture.width != self.window_width
826
- or self.node_id_texture.height != self.window_height
827
- ):
828
- self.node_id_fbo = None
829
- self.node_id_texture = None
830
- self.node_id_texture_np = None
831
- self.node_id_depth = None
832
-
833
- if self.node_id_texture is None:
834
- self.node_id_texture = self.glctx.texture(
835
- (self.window_width, self.window_height), components=4, dtype="f4"
836
- )
837
- self.node_id_depth = self.glctx.depth_renderbuffer(
838
- size=(self.window_width, self.window_height)
839
- )
840
- self.node_id_fbo = self.glctx.framebuffer(
841
- color_attachments=[self.node_id_texture],
842
- depth_attachment=self.node_id_depth,
843
- )
844
- self.node_id_texture_np = np.zeros(
845
- (self.window_height, self.window_width, 4), dtype=np.float32
846
- )
847
-
848
- # Bind the offscreen framebuffer
849
- self.node_id_fbo.use()
850
- self.glctx.clear(0, 0, 0, 0)
851
-
852
- # Render nodes
853
- if self.node_id_vao:
854
- self.node_id_prog["mvp"].write(mvp.to_bytes())
855
- self.node_id_prog["scale"].write(np.float32(self.node_scale).tobytes())
856
- self.node_id_vao.render(moderngl.TRIANGLES)
857
-
858
- # Revert to default framebuffer
859
- self.glctx.screen.use()
860
- self.node_id_texture.read_into(self.node_id_texture_np.data)
861
-
862
- def render(self):
863
- """Render the graph"""
864
- # Clear screen
865
- self.glctx.clear(*self.background_color, depth=1)
866
-
867
- if not self.graph:
868
- return
869
-
870
- # Enable blending for transparency
871
- self.glctx.enable(moderngl.BLEND)
872
- self.glctx.blend_func = moderngl.SRC_ALPHA, moderngl.ONE_MINUS_SRC_ALPHA
873
-
874
- # Update view and projection matrices
875
- self.update_view_proj_matrix()
876
- mvp = self.proj_matrix * self.view_matrix
877
-
878
- # Render edges first (under nodes)
879
- if self.edge_vao:
880
- self.edge_prog["mvp"].write(mvp.to_bytes())
881
- self.edge_prog["edge_width"].value = (
882
- float(self.edge_width) * 2.0
883
- ) # Double the width for better visibility
884
- self.edge_prog["viewport_size"].value = (
885
- float(self.window_width),
886
- float(self.window_height),
887
- )
888
- self.edge_vao.render(moderngl.LINES)
889
-
890
- # Render nodes
891
- if self.node_vao:
892
- self.node_prog["mvp"].write(mvp.to_bytes())
893
- self.node_prog["camera"].write(self.position.to_bytes())
894
- self.node_prog["selected_node"].write(
895
- np.int32(self.selected_node.idx).tobytes()
896
- if self.selected_node
897
- else np.int32(-1).tobytes()
898
- )
899
- self.node_prog["highlighted_node"].write(
900
- np.int32(self.highlighted_node.idx).tobytes()
901
- if self.highlighted_node
902
- else np.int32(-1).tobytes()
903
- )
904
- self.node_prog["scale"].write(np.float32(self.node_scale).tobytes())
905
- self.node_vao.render(moderngl.TRIANGLES)
906
-
907
- self.glctx.disable(moderngl.BLEND)
908
-
909
- # Render id map
910
- self.render_id_map(mvp)
911
-
912
- def render_labels(self):
913
- # Render labels if enabled
914
- if self.show_labels and self.nodes:
915
- # Save current font scale
916
- original_scale = imgui.get_font_size()
917
-
918
- self.update_view_proj_matrix()
919
- mvp = self.proj_matrix * self.view_matrix
920
-
921
- for node in self.nodes:
922
- # Project node position to screen space
923
- pos = mvp * glm.vec4(
924
- node.position[0], node.position[1], node.position[2], 1.0
925
- )
926
-
927
- # Check if node is behind camera
928
- if pos.w > 0 and pos.w < self.label_culling_distance:
929
- screen_x = (pos.x / pos.w + 1) * self.window_width / 2
930
- screen_y = (-pos.y / pos.w + 1) * self.window_height / 2
931
-
932
- if self.is_node_visible_at(
933
- (int(screen_x), int(screen_y)), node.idx
934
- ):
935
- # Set font scale
936
- imgui.set_window_font_scale(float(self.label_size) * node.size)
937
-
938
- # Calculate label size
939
- label_size = imgui.calc_text_size(node.label)
940
-
941
- # Adjust position to center the label
942
- screen_x -= label_size.x / 2
943
- screen_y -= label_size.y / 2
944
-
945
- # Set text color with calculated alpha
946
- imgui.push_style_color(imgui.Col_.text, self.label_color)
947
-
948
- # Draw label using ImGui
949
- imgui.set_cursor_pos((screen_x, screen_y))
950
- imgui.text(node.label)
951
-
952
- # Restore text color
953
- imgui.pop_style_color()
954
-
955
- # Restore original font scale
956
- imgui.set_window_font_scale(original_scale)
957
-
958
- def reset_view(self):
959
- """Reset camera view to default"""
960
- self.position = glm.vec3(0.0, -10.0, 0.0)
961
- self.front = glm.vec3(0.0, 1.0, 0.0)
962
- self.yaw = 90.0
963
- self.pitch = 0.0
964
-
965
-
966
- def generate_colors(n: int) -> List[glm.vec3]:
967
- """Generate n distinct colors using HSV color space"""
968
- colors = []
969
- for i in range(n):
970
- # Use golden ratio to generate well-distributed hues
971
- hue = (i * 0.618033988749895) % 1.0
972
- # Fixed saturation and value for vibrant colors
973
- saturation = 0.8
974
- value = 0.95
975
- # Convert HSV to RGB
976
- rgb = colorsys.hsv_to_rgb(hue, saturation, value)
977
- # Add alpha channel
978
- colors.append(glm.vec3(rgb))
979
- return colors
980
-
981
-
982
- def show_file_dialog() -> Optional[str]:
983
- """Show a file dialog for selecting GraphML files"""
984
- root = tk.Tk()
985
- root.withdraw() # Hide the main window
986
- file_path = filedialog.askopenfilename(
987
- title="Select GraphML File",
988
- filetypes=[("GraphML files", "*.graphml"), ("All files", "*.*")],
989
- )
990
- root.destroy()
991
- return file_path if file_path else None
992
-
993
-
994
- def create_sphere(sectors: int = 32, rings: int = 16) -> Tuple:
995
- """
996
- Creates a sphere.
997
- """
998
- R = 1.0 / (rings - 1)
999
- S = 1.0 / (sectors - 1)
1000
-
1001
- # Use those names as normals and uvs are part of the API
1002
- vertices_l = [0.0] * (rings * sectors * 3)
1003
- # normals_l = [0.0] * (rings * sectors * 3)
1004
- uvs_l = [0.0] * (rings * sectors * 2)
1005
-
1006
- v, n, t = 0, 0, 0
1007
- for r in range(rings):
1008
- for s in range(sectors):
1009
- y = np.sin(-np.pi / 2 + np.pi * r * R)
1010
- x = np.cos(2 * np.pi * s * S) * np.sin(np.pi * r * R)
1011
- z = np.sin(2 * np.pi * s * S) * np.sin(np.pi * r * R)
1012
-
1013
- uvs_l[t] = s * S
1014
- uvs_l[t + 1] = r * R
1015
-
1016
- vertices_l[v] = x
1017
- vertices_l[v + 1] = y
1018
- vertices_l[v + 2] = z
1019
-
1020
- t += 2
1021
- v += 3
1022
- n += 3
1023
-
1024
- indices = [0] * rings * sectors * 6
1025
- i = 0
1026
- for r in range(rings - 1):
1027
- for s in range(sectors - 1):
1028
- indices[i] = r * sectors + s
1029
- indices[i + 1] = (r + 1) * sectors + (s + 1)
1030
- indices[i + 2] = r * sectors + (s + 1)
1031
-
1032
- indices[i + 3] = r * sectors + s
1033
- indices[i + 4] = (r + 1) * sectors + s
1034
- indices[i + 5] = (r + 1) * sectors + (s + 1)
1035
- i += 6
1036
-
1037
- vbo_vertices = np.array(vertices_l, dtype=np.float32)
1038
- vbo_elements = np.array(indices, dtype=np.uint32)
1039
-
1040
- return (vbo_vertices, vbo_elements)
1041
-
1042
-
1043
- def draw_text_with_bg(
1044
- text: str,
1045
- text_pos: imgui.ImVec2Like,
1046
- text_size: imgui.ImVec2Like,
1047
- bg_color: int,
1048
- ):
1049
- imgui.get_window_draw_list().add_rect_filled(
1050
- (text_pos[0] - 5, text_pos[1] - 5),
1051
- (text_pos[0] + text_size[0] + 5, text_pos[1] + text_size[1] + 5),
1052
- bg_color,
1053
- 3.0,
1054
- )
1055
- imgui.set_cursor_pos(text_pos)
1056
- imgui.text(text)
1057
-
1058
-
1059
- def main():
1060
- """Main application entry point"""
1061
- viewer = GraphViewer()
1062
-
1063
- show_fps = True
1064
- text_bg_color = imgui.IM_COL32(0, 0, 0, 100)
1065
-
1066
- def gui():
1067
- if not viewer.initialized:
1068
- viewer.setup()
1069
- # # Change the theme
1070
- # tweaked_theme = hello_imgui.get_runner_params().imgui_window_params.tweaked_theme
1071
- # tweaked_theme.theme = hello_imgui.ImGuiTheme_.darcula_darker
1072
- # hello_imgui.apply_tweaked_theme(tweaked_theme)
1073
-
1074
- viewer.window_width = int(imgui.get_window_width())
1075
- viewer.window_height = int(imgui.get_window_height())
1076
-
1077
- # Handle keyboard and mouse input
1078
- viewer.handle_keyboard_input()
1079
- viewer.handle_mouse_interaction()
1080
-
1081
- style = imgui.get_style()
1082
- window_bg_color = style.color_(imgui.Col_.window_bg.value)
1083
-
1084
- window_bg_color.w = 0.8
1085
- style.set_color_(imgui.Col_.window_bg.value, window_bg_color)
1086
-
1087
- # Main control window
1088
- imgui.begin("Graph Controls")
1089
-
1090
- if imgui.button("Load GraphML"):
1091
- filepath = show_file_dialog()
1092
- if filepath:
1093
- viewer.load_file(filepath)
1094
-
1095
- # Show error message if loading failed
1096
- if viewer.show_load_error:
1097
- imgui.push_style_color(imgui.Col_.text, (1.0, 0.0, 0.0, 1.0))
1098
- imgui.text(f"Error loading file: {viewer.error_message}")
1099
- imgui.pop_style_color()
1100
-
1101
- imgui.separator()
1102
-
1103
- # Camera controls help
1104
- imgui.text("Camera Controls:")
1105
- imgui.bullet_text("Hold Right Mouse - Look around")
1106
- imgui.bullet_text("W/S - Move forward/backward")
1107
- imgui.bullet_text("A/D - Move left/right")
1108
- imgui.bullet_text("Q/E - Move up/down")
1109
- imgui.bullet_text("Left Mouse - Select node")
1110
- imgui.bullet_text("Wheel - Change the movement speed")
1111
-
1112
- imgui.separator()
1113
-
1114
- # Camera settings
1115
- _, viewer.move_speed = imgui.slider_float(
1116
- "Movement Speed", viewer.move_speed, 0.01, 2.0
1117
- )
1118
- _, viewer.mouse_sensitivity = imgui.slider_float(
1119
- "Mouse Sensitivity", viewer.mouse_sensitivity, 0.01, 0.5
1120
- )
1121
-
1122
- imgui.separator()
1123
-
1124
- imgui.begin_horizontal("buttons")
1125
-
1126
- if imgui.button("Reset Camera"):
1127
- viewer.reset_view()
1128
-
1129
- if imgui.button("Update Layout") and viewer.graph:
1130
- viewer.update_layout()
1131
-
1132
- # if imgui.button("Save Node ID Texture"):
1133
- # viewer.save_node_id_texture_to_png("node_id_texture.png")
1134
-
1135
- imgui.end_horizontal()
1136
-
1137
- imgui.end()
1138
-
1139
- # Render node details window if a node is selected
1140
- viewer.render_node_details()
1141
-
1142
- # Render graph settings window
1143
- viewer.render_settings()
1144
-
1145
- # Render FPS
1146
- if show_fps:
1147
- imgui.set_window_font_scale(1)
1148
- fps_text = f"FPS: {hello_imgui.frame_rate():.1f}"
1149
- text_size = imgui.calc_text_size(fps_text)
1150
- cursor_pos = (10, viewer.window_height - text_size.y - 10)
1151
- draw_text_with_bg(fps_text, cursor_pos, text_size, text_bg_color)
1152
-
1153
- # Render highlighted node ID
1154
- if viewer.highlighted_node:
1155
- imgui.set_window_font_scale(1)
1156
- node_text = f"Node ID: {viewer.highlighted_node.label}"
1157
- text_size = imgui.calc_text_size(node_text)
1158
- cursor_pos = (
1159
- viewer.window_width - text_size.x - 10,
1160
- viewer.window_height - text_size.y - 10,
1161
- )
1162
- draw_text_with_bg(node_text, cursor_pos, text_size, text_bg_color)
1163
-
1164
- window_bg_color.w = 0
1165
- style.set_color_(imgui.Col_.window_bg.value, window_bg_color)
1166
-
1167
- # Render labels
1168
- viewer.render_labels()
1169
-
1170
- def custom_background():
1171
- if viewer.initialized:
1172
- viewer.render()
1173
-
1174
- runner_params = hello_imgui.RunnerParams()
1175
- runner_params.app_window_params.window_geometry.size = (
1176
- viewer.window_width,
1177
- viewer.window_height,
1178
- )
1179
- runner_params.app_window_params.window_title = "3D GraphML Viewer"
1180
- runner_params.callbacks.show_gui = gui
1181
- runner_params.callbacks.custom_background = custom_background
1182
-
1183
- def load_font():
1184
- # You will need to provide it yourself, or use another font.
1185
- font_filename = CUSTOM_FONT
1186
-
1187
- io = imgui.get_io()
1188
- io.fonts.tex_desired_width = 4096 # Larger texture for better CJK font quality
1189
- font_size_pixels = 14
1190
- asset_dir = os.path.join(os.path.dirname(__file__), "assets")
1191
-
1192
- # Try to load custom font
1193
- if not os.path.isfile(font_filename):
1194
- font_filename = os.path.join(asset_dir, font_filename)
1195
- if os.path.isfile(font_filename):
1196
- custom_font = io.fonts.add_font_from_file_ttf(
1197
- filename=font_filename,
1198
- size_pixels=font_size_pixels,
1199
- glyph_ranges_as_int_list=io.fonts.get_glyph_ranges_chinese_full(),
1200
- )
1201
- io.font_default = custom_font
1202
- return
1203
-
1204
- # Load default fonts
1205
- io.fonts.add_font_from_file_ttf(
1206
- filename=os.path.join(asset_dir, DEFAULT_FONT_ENG),
1207
- size_pixels=font_size_pixels,
1208
- )
1209
-
1210
- font_config = imgui.ImFontConfig()
1211
- font_config.merge_mode = True
1212
-
1213
- io.font_default = io.fonts.add_font_from_file_ttf(
1214
- filename=os.path.join(asset_dir, DEFAULT_FONT_CHI),
1215
- size_pixels=font_size_pixels,
1216
- font_cfg=font_config,
1217
- glyph_ranges_as_int_list=io.fonts.get_glyph_ranges_chinese_full(),
1218
- )
1219
-
1220
- runner_params.callbacks.load_additional_fonts = load_font
1221
-
1222
- immapp.run(runner_params)
1223
-
1224
-
1225
- if __name__ == "__main__":
1226
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lightrag/tools/lightrag_visualizer/graph_visualizer.py ADDED
@@ -0,0 +1,1226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 3D GraphML Viewer using Dear ImGui and ModernGL
3
+ Author: ParisNeo, ArnoChen
4
+ Description: An interactive 3D GraphML viewer using imgui_bundle and ModernGL
5
+ Version: 2.0
6
+ """
7
+
8
+ from typing import Optional, Tuple, Dict, List
9
+ import numpy as np
10
+ import networkx as nx
11
+ import pipmaster as pm
12
+
13
+ # Added automatic libraries install using pipmaster
14
+ if not pm.is_installed("moderngl"):
15
+ pm.install("moderngl")
16
+ if not pm.is_installed("imgui_bundle"):
17
+ pm.install("imgui_bundle")
18
+ if not pm.is_installed("pyglm"):
19
+ pm.install("pyglm")
20
+ if not pm.is_installed("python-louvain"):
21
+ pm.install("python-louvain")
22
+
23
+ import moderngl
24
+ from imgui_bundle import imgui, immapp, hello_imgui
25
+ import community
26
+ import glm
27
+ import tkinter as tk
28
+ from tkinter import filedialog
29
+ import traceback
30
+ import colorsys
31
+ import os
32
+
33
+ CUSTOM_FONT = "font.ttf"
34
+
35
+ DEFAULT_FONT_ENG = "Geist-Regular.ttf"
36
+ DEFAULT_FONT_CHI = "SmileySans-Oblique.ttf"
37
+
38
+
39
+ class Node3D:
40
+ """Class representing a 3D node in the graph"""
41
+
42
+ def __init__(
43
+ self, position: glm.vec3, color: glm.vec3, label: str, size: float, idx: int
44
+ ):
45
+ self.position = position
46
+ self.color = color
47
+ self.label = label
48
+ self.size = size
49
+ self.idx = idx
50
+
51
+
52
+ class GraphViewer:
53
+ """Main class for 3D graph visualization"""
54
+
55
+ def __init__(self):
56
+ self.glctx = None # ModernGL context
57
+ self.graph: Optional[nx.Graph] = None
58
+ self.nodes: List[Node3D] = []
59
+ self.id_node_map: Dict[str, Node3D] = {}
60
+ self.communities = None
61
+ self.community_colors = None
62
+
63
+ # Window dimensions
64
+ self.window_width = 1280
65
+ self.window_height = 720
66
+
67
+ # Camera parameters
68
+ self.position = glm.vec3(0.0, -10.0, 0.0) # Initial camera position
69
+ self.front = glm.vec3(0.0, 1.0, 0.0) # Direction camera is facing
70
+ self.up = glm.vec3(0.0, 0.0, 1.0) # Up vector
71
+ self.yaw = 90.0 # Horizontal rotation (around Z axis)
72
+ self.pitch = 0.0 # Vertical rotation
73
+ self.move_speed = 0.05
74
+ self.mouse_sensitivity = 0.15
75
+
76
+ # Graph visualization settings
77
+ self.layout_type = "Spring"
78
+ self.node_scale = 0.2
79
+ self.edge_width = 0.5
80
+ self.show_labels = True
81
+ self.label_size = 2
82
+ self.label_color = (1.0, 1.0, 1.0, 1.0)
83
+ self.label_culling_distance = 10.0
84
+ self.available_layouts = ("Spring", "Circular", "Shell", "Random")
85
+ self.background_color = (0.05, 0.05, 0.05, 1.0)
86
+
87
+ # Mouse interaction
88
+ self.last_mouse_pos = None
89
+ self.mouse_pressed = False
90
+ self.mouse_button = -1
91
+ self.first_mouse = True
92
+
93
+ # File dialog state
94
+ self.show_load_error = False
95
+ self.error_message = ""
96
+
97
+ # Selection state
98
+ self.selected_node: Optional[Node3D] = None
99
+ self.highlighted_node: Optional[Node3D] = None
100
+
101
+ # Node id map
102
+ self.node_id_fbo = None
103
+ self.node_id_texture = None
104
+ self.node_id_depth = None
105
+ self.node_id_texture_np: np.ndarray = None
106
+
107
+ # Static data
108
+ self.sphere_data = create_sphere()
109
+
110
+ # Initialization flag
111
+ self.initialized = False
112
+
113
+ def setup(self):
114
+ self.setup_render_context()
115
+ self.setup_shaders()
116
+ self.setup_buffers()
117
+ self.initialized = True
118
+
119
+ def handle_keyboard_input(self):
120
+ """Handle WASD keyboard input for camera movement"""
121
+ io = imgui.get_io()
122
+
123
+ if io.want_capture_keyboard:
124
+ return
125
+
126
+ # Calculate camera vectors
127
+ right = glm.normalize(glm.cross(self.front, self.up))
128
+
129
+ # Get movement direction from WASD keys
130
+ if imgui.is_key_down(imgui.Key.w): # Forward
131
+ self.position += self.front * self.move_speed * 0.1
132
+ if imgui.is_key_down(imgui.Key.s): # Backward
133
+ self.position -= self.front * self.move_speed * 0.1
134
+ if imgui.is_key_down(imgui.Key.a): # Left
135
+ self.position -= right * self.move_speed * 0.1
136
+ if imgui.is_key_down(imgui.Key.d): # Right
137
+ self.position += right * self.move_speed * 0.1
138
+ if imgui.is_key_down(imgui.Key.q): # Up
139
+ self.position += self.up * self.move_speed * 0.1
140
+ if imgui.is_key_down(imgui.Key.e): # Down
141
+ self.position -= self.up * self.move_speed * 0.1
142
+
143
+ def handle_mouse_interaction(self):
144
+ """Handle mouse interaction for camera control and node selection"""
145
+ if (
146
+ imgui.is_any_item_active()
147
+ or imgui.is_any_item_hovered()
148
+ or imgui.is_any_item_focused()
149
+ ):
150
+ return
151
+
152
+ io = imgui.get_io()
153
+ mouse_pos = (io.mouse_pos.x, io.mouse_pos.y)
154
+ if (
155
+ mouse_pos[0] < 0
156
+ or mouse_pos[1] < 0
157
+ or mouse_pos[0] >= self.window_width
158
+ or mouse_pos[1] >= self.window_height
159
+ ):
160
+ return
161
+
162
+ # Handle first mouse input
163
+ if self.first_mouse:
164
+ self.last_mouse_pos = mouse_pos
165
+ self.first_mouse = False
166
+ return
167
+
168
+ # Handle mouse movement for camera rotation
169
+ if self.mouse_pressed and self.mouse_button == 1: # Right mouse button
170
+ dx = self.last_mouse_pos[0] - mouse_pos[0]
171
+ dy = self.last_mouse_pos[1] - mouse_pos[1] # Reversed for intuitive control
172
+
173
+ dx *= self.mouse_sensitivity
174
+ dy *= self.mouse_sensitivity
175
+
176
+ self.yaw += dx
177
+ self.pitch += dy
178
+
179
+ # Limit pitch to avoid flipping
180
+ self.pitch = np.clip(self.pitch, -89.0, 89.0)
181
+
182
+ # Update front vector
183
+ self.front = glm.normalize(
184
+ glm.vec3(
185
+ np.cos(np.radians(self.yaw)) * np.cos(np.radians(self.pitch)),
186
+ np.sin(np.radians(self.yaw)) * np.cos(np.radians(self.pitch)),
187
+ np.sin(np.radians(self.pitch)),
188
+ )
189
+ )
190
+
191
+ if not imgui.is_window_hovered():
192
+ return
193
+
194
+ if io.mouse_wheel != 0:
195
+ self.move_speed += io.mouse_wheel * 0.05
196
+ self.move_speed = np.max([self.move_speed, 0.01])
197
+
198
+ # Handle mouse press/release
199
+ for button in range(3):
200
+ if imgui.is_mouse_clicked(button):
201
+ self.mouse_pressed = True
202
+ self.mouse_button = button
203
+ if button == 0 and self.highlighted_node: # Left click for selection
204
+ self.selected_node = self.highlighted_node
205
+
206
+ if imgui.is_mouse_released(button) and self.mouse_button == button:
207
+ self.mouse_pressed = False
208
+ self.mouse_button = -1
209
+
210
+ # Handle node hovering
211
+ if not self.mouse_pressed:
212
+ hovered = self.find_node_at((int(mouse_pos[0]), int(mouse_pos[1])))
213
+ self.highlighted_node = hovered
214
+
215
+ # Update last mouse position
216
+ self.last_mouse_pos = mouse_pos
217
+
218
+ def update_layout(self):
219
+ """Update the graph layout"""
220
+ pos = nx.spring_layout(
221
+ self.graph,
222
+ dim=3,
223
+ pos={
224
+ node_id: list(node.position)
225
+ for node_id, node in self.id_node_map.items()
226
+ },
227
+ k=2.0,
228
+ iterations=100,
229
+ weight=None,
230
+ )
231
+
232
+ # Update node positions
233
+ for node_id, position in pos.items():
234
+ self.id_node_map[node_id].position = glm.vec3(position)
235
+ self.update_buffers()
236
+
237
+ def render_node_details(self):
238
+ """Render node details window"""
239
+ if self.selected_node and imgui.begin("Node Details"):
240
+ imgui.text(f"ID: {self.selected_node.label}")
241
+
242
+ if self.graph:
243
+ node_data = self.graph.nodes[self.selected_node.label]
244
+ imgui.text(f"Type: {node_data.get('type', 'default')}")
245
+
246
+ degree = self.graph.degree[self.selected_node.label]
247
+ imgui.text(f"Degree: {degree}")
248
+
249
+ for key, value in node_data.items():
250
+ if key != "type":
251
+ imgui.text(f"{key}: {value}")
252
+ if value and imgui.is_item_hovered():
253
+ imgui.set_tooltip(str(value))
254
+
255
+ imgui.separator()
256
+
257
+ connections = self.graph[self.selected_node.label]
258
+ if connections:
259
+ imgui.text("Connections:")
260
+ keys = next(iter(connections.values())).keys()
261
+ if imgui.begin_table(
262
+ "Connections",
263
+ len(keys) + 1,
264
+ imgui.TableFlags_.borders
265
+ | imgui.TableFlags_.row_bg
266
+ | imgui.TableFlags_.resizable
267
+ | imgui.TableFlags_.hideable,
268
+ ):
269
+ imgui.table_setup_column("Node")
270
+ for key in keys:
271
+ imgui.table_setup_column(key)
272
+ imgui.table_headers_row()
273
+
274
+ for neighbor, edge_data in connections.items():
275
+ imgui.table_next_row()
276
+ imgui.table_set_column_index(0)
277
+ if imgui.selectable(str(neighbor), True)[0]:
278
+ # Select neighbor node
279
+ self.selected_node = self.id_node_map[neighbor]
280
+ self.position = self.selected_node.position - self.front
281
+ for idx, key in enumerate(keys):
282
+ imgui.table_set_column_index(idx + 1)
283
+ value = str(edge_data.get(key, ""))
284
+ imgui.text(value)
285
+ if value and imgui.is_item_hovered():
286
+ imgui.set_tooltip(value)
287
+ imgui.end_table()
288
+
289
+ imgui.end()
290
+
291
+ def setup_render_context(self):
292
+ """Initialize ModernGL context"""
293
+ self.glctx = moderngl.create_context()
294
+ self.glctx.enable(moderngl.DEPTH_TEST | moderngl.CULL_FACE)
295
+ self.glctx.clear_color = self.background_color
296
+
297
+ def setup_shaders(self):
298
+ """Setup vertex and fragment shaders for node and edge rendering"""
299
+ # Node shader program
300
+ self.node_prog = self.glctx.program(
301
+ vertex_shader="""
302
+ #version 330
303
+
304
+ uniform mat4 mvp;
305
+ uniform vec3 camera;
306
+ uniform int selected_node;
307
+ uniform int highlighted_node;
308
+ uniform float scale;
309
+
310
+ in vec3 in_position;
311
+ in vec3 in_instance_position;
312
+ in vec3 in_instance_color;
313
+ in float in_instance_size;
314
+
315
+ out vec3 frag_color;
316
+ out vec3 frag_normal;
317
+ out vec3 frag_view_dir;
318
+
319
+ void main() {
320
+ vec3 pos = in_position * in_instance_size * scale + in_instance_position;
321
+ gl_Position = mvp * vec4(pos, 1.0);
322
+
323
+ frag_normal = normalize(in_position);
324
+ frag_view_dir = normalize(camera - pos);
325
+
326
+ if (selected_node == gl_InstanceID) {
327
+ frag_color = vec3(1.0, 0.5, 0.0);
328
+ }
329
+ else if (highlighted_node == gl_InstanceID) {
330
+ frag_color = vec3(1.0, 0.8, 0.2);
331
+ }
332
+ else {
333
+ frag_color = in_instance_color;
334
+ }
335
+ }
336
+ """,
337
+ fragment_shader="""
338
+ #version 330
339
+
340
+ in vec3 frag_color;
341
+ in vec3 frag_normal;
342
+ in vec3 frag_view_dir;
343
+
344
+ out vec4 outColor;
345
+
346
+ void main() {
347
+ // Edge detection based on normal-view angle
348
+ float edge = 1.0 - abs(dot(frag_normal, frag_view_dir));
349
+
350
+ // Create sharp outline
351
+ float outline = smoothstep(0.8, 0.9, edge);
352
+
353
+ // Mix the sphere color with outline
354
+ vec3 final_color = mix(frag_color, vec3(0.0), outline);
355
+
356
+ outColor = vec4(final_color, 1.0);
357
+ }
358
+ """,
359
+ )
360
+
361
+ # Edge shader program with wide lines using geometry shader
362
+ self.edge_prog = self.glctx.program(
363
+ vertex_shader="""
364
+ #version 330
365
+
366
+ uniform mat4 mvp;
367
+
368
+ in vec3 in_position;
369
+ in vec3 in_color;
370
+
371
+ out vec3 v_color;
372
+ out vec4 v_position;
373
+
374
+ void main() {
375
+ v_position = mvp * vec4(in_position, 1.0);
376
+ gl_Position = v_position;
377
+ v_color = in_color;
378
+ }
379
+ """,
380
+ geometry_shader="""
381
+ #version 330
382
+
383
+ layout(lines) in;
384
+ layout(triangle_strip, max_vertices = 4) out;
385
+
386
+ uniform float edge_width;
387
+ uniform vec2 viewport_size;
388
+
389
+ in vec3 v_color[];
390
+ in vec4 v_position[];
391
+ out vec3 g_color;
392
+ out float edge_coord;
393
+
394
+ void main() {
395
+ // Get the two vertices of the line
396
+ vec4 p1 = v_position[0];
397
+ vec4 p2 = v_position[1];
398
+
399
+ // Perspective division
400
+ vec4 p1_ndc = p1 / p1.w;
401
+ vec4 p2_ndc = p2 / p2.w;
402
+
403
+ // Calculate line direction in screen space
404
+ vec2 dir = normalize((p2_ndc.xy - p1_ndc.xy) * viewport_size);
405
+ vec2 normal = vec2(-dir.y, dir.x);
406
+
407
+ // Calculate half width based on screen space
408
+ float half_width = edge_width * 0.5;
409
+ vec2 offset = normal * (half_width / viewport_size);
410
+
411
+ // Emit vertices with proper depth
412
+ gl_Position = vec4(p1_ndc.xy + offset, p1_ndc.z, 1.0);
413
+ gl_Position *= p1.w; // Restore perspective
414
+ g_color = v_color[0];
415
+ edge_coord = 1.0;
416
+ EmitVertex();
417
+
418
+ gl_Position = vec4(p1_ndc.xy - offset, p1_ndc.z, 1.0);
419
+ gl_Position *= p1.w;
420
+ g_color = v_color[0];
421
+ edge_coord = -1.0;
422
+ EmitVertex();
423
+
424
+ gl_Position = vec4(p2_ndc.xy + offset, p2_ndc.z, 1.0);
425
+ gl_Position *= p2.w;
426
+ g_color = v_color[1];
427
+ edge_coord = 1.0;
428
+ EmitVertex();
429
+
430
+ gl_Position = vec4(p2_ndc.xy - offset, p2_ndc.z, 1.0);
431
+ gl_Position *= p2.w;
432
+ g_color = v_color[1];
433
+ edge_coord = -1.0;
434
+ EmitVertex();
435
+
436
+ EndPrimitive();
437
+ }
438
+ """,
439
+ fragment_shader="""
440
+ #version 330
441
+
442
+ in vec3 g_color;
443
+ in float edge_coord;
444
+
445
+ out vec4 fragColor;
446
+
447
+ void main() {
448
+ // Edge outline parameters
449
+ float outline_width = 0.2; // Width of the outline relative to edge
450
+ float edge_softness = 0.1; // Softness of the edge
451
+ float edge_dist = abs(edge_coord);
452
+
453
+ // Calculate outline
454
+ float outline_factor = smoothstep(1.0 - outline_width - edge_softness,
455
+ 1.0 - outline_width,
456
+ edge_dist);
457
+
458
+ // Mix edge color with outline (black)
459
+ vec3 final_color = mix(g_color, vec3(0.0), outline_factor);
460
+
461
+ // Calculate alpha for anti-aliasing
462
+ float alpha = 1.0 - smoothstep(1.0 - edge_softness, 1.0, edge_dist);
463
+
464
+ fragColor = vec4(final_color, alpha);
465
+ }
466
+ """,
467
+ )
468
+
469
+ # Id framebuffer shader program
470
+ self.node_id_prog = self.glctx.program(
471
+ vertex_shader="""
472
+ #version 330
473
+
474
+ uniform mat4 mvp;
475
+ uniform float scale;
476
+
477
+ in vec3 in_position;
478
+ in vec3 in_instance_position;
479
+ in float in_instance_size;
480
+
481
+ out vec3 frag_color;
482
+
483
+ vec3 int_to_rgb(int value) {
484
+ float R = float((value >> 16) & 0xFF);
485
+ float G = float((value >> 8) & 0xFF);
486
+ float B = float(value & 0xFF);
487
+ // normalize to [0, 1]
488
+ return vec3(R / 255.0, G / 255.0, B / 255.0);
489
+ }
490
+
491
+ void main() {
492
+ vec3 pos = in_position * in_instance_size * scale + in_instance_position;
493
+ gl_Position = mvp * vec4(pos, 1.0);
494
+ frag_color = int_to_rgb(gl_InstanceID);
495
+ }
496
+ """,
497
+ fragment_shader="""
498
+ #version 330
499
+ in vec3 frag_color;
500
+ out vec4 outColor;
501
+ void main() {
502
+ outColor = vec4(frag_color, 1.0);
503
+ }
504
+ """,
505
+ )
506
+
507
+ def setup_buffers(self):
508
+ """Setup vertex buffers for nodes and edges"""
509
+ # We'll create these when loading the graph
510
+ self.node_vbo = None
511
+ self.node_color_vbo = None
512
+ self.node_size_vbo = None
513
+ self.edge_vbo = None
514
+ self.edge_color_vbo = None
515
+ self.node_vao = None
516
+ self.edge_vao = None
517
+ self.node_id_vao = None
518
+ self.sphere_pos_vbo = None
519
+ self.sphere_index_buffer = None
520
+
521
+ def load_file(self, filepath: str):
522
+ """Load a GraphML file with error handling"""
523
+ try:
524
+ # Clear existing data
525
+ self.id_node_map.clear()
526
+ self.nodes.clear()
527
+ self.selected_node = None
528
+ self.highlighted_node = None
529
+ self.setup_buffers()
530
+
531
+ # Load new graph
532
+ self.graph = nx.read_graphml(filepath)
533
+ self.calculate_layout()
534
+ self.update_buffers()
535
+ self.show_load_error = False
536
+ self.error_message = ""
537
+ except Exception as _:
538
+ self.show_load_error = True
539
+ self.error_message = traceback.format_exc()
540
+ print(self.error_message)
541
+
542
+ def calculate_layout(self):
543
+ """Calculate 3D layout for the graph"""
544
+ if not self.graph:
545
+ return
546
+
547
+ # Detect communities for coloring
548
+ self.communities = community.best_partition(self.graph)
549
+ num_communities = len(set(self.communities.values()))
550
+ self.community_colors = generate_colors(num_communities)
551
+
552
+ # Calculate layout based on selected type
553
+ if self.layout_type == "Spring":
554
+ pos = nx.spring_layout(
555
+ self.graph, dim=3, k=2.0, iterations=100, weight=None
556
+ )
557
+ elif self.layout_type == "Circular":
558
+ pos_2d = nx.circular_layout(self.graph)
559
+ pos = {node: np.array((x, 0.0, y)) for node, (x, y) in pos_2d.items()}
560
+ elif self.layout_type == "Shell":
561
+ # Group nodes by community for shell layout
562
+ comm_lists = [[] for _ in range(num_communities)]
563
+ for node, comm in self.communities.items():
564
+ comm_lists[comm].append(node)
565
+ pos_2d = nx.shell_layout(self.graph, comm_lists)
566
+ pos = {node: np.array((x, 0.0, y)) for node, (x, y) in pos_2d.items()}
567
+ else: # Random
568
+ pos = {node: np.random.rand(3) * 2 - 1 for node in self.graph.nodes()}
569
+
570
+ # Scale positions
571
+ positions = np.array(list(pos.values()))
572
+ if len(positions) > 0:
573
+ scale = 10.0 / max(1.0, np.max(np.abs(positions)))
574
+ pos = {node: coords * scale for node, coords in pos.items()}
575
+
576
+ # Calculate degree-based sizes
577
+ degrees = dict(self.graph.degree())
578
+ max_degree = max(degrees.values()) if degrees else 1
579
+ min_degree = min(degrees.values()) if degrees else 1
580
+
581
+ idx = 0
582
+ # Create nodes with community colors
583
+ for node_id in self.graph.nodes():
584
+ position = glm.vec3(pos[node_id])
585
+ color = self.get_node_color(node_id)
586
+
587
+ # Normalize sizes between 0.5 and 2.0
588
+ size = 1.0
589
+ if max_degree != min_degree:
590
+ # Normalize and scale size
591
+ normalized = (degrees[node_id] - min_degree) / (max_degree - min_degree)
592
+ size = 0.5 + normalized * 1.5
593
+
594
+ if node_id in self.id_node_map:
595
+ node = self.id_node_map[node_id]
596
+ node.position = position
597
+ node.base_color = color
598
+ node.color = color
599
+ node.size = size
600
+ else:
601
+ node = Node3D(position, color, str(node_id), size, idx)
602
+ self.id_node_map[node_id] = node
603
+ self.nodes.append(node)
604
+ idx += 1
605
+
606
+ self.update_buffers()
607
+
608
+ def get_node_color(self, node_id: str) -> glm.vec3:
609
+ """Get RGBA color based on community"""
610
+ if self.communities and node_id in self.communities:
611
+ comm_id = self.communities[node_id]
612
+ color = self.community_colors[comm_id]
613
+ return color
614
+ return glm.vec3(0.5, 0.5, 0.5)
615
+
616
+ def update_buffers(self):
617
+ """Update vertex buffers with current node and edge data using batch rendering"""
618
+ if not self.graph:
619
+ return
620
+
621
+ # Update node buffers
622
+ node_positions = []
623
+ node_colors = []
624
+ node_sizes = []
625
+
626
+ for node in self.nodes:
627
+ node_positions.append(node.position)
628
+ node_colors.append(node.color) # Only use RGB components
629
+ node_sizes.append(node.size)
630
+
631
+ if node_positions:
632
+ node_positions = np.array(node_positions, dtype=np.float32)
633
+ node_colors = np.array(node_colors, dtype=np.float32)
634
+ node_sizes = np.array(node_sizes, dtype=np.float32)
635
+
636
+ self.node_vbo = self.glctx.buffer(node_positions.tobytes())
637
+ self.node_color_vbo = self.glctx.buffer(node_colors.tobytes())
638
+ self.node_size_vbo = self.glctx.buffer(node_sizes.tobytes())
639
+ self.sphere_pos_vbo = self.glctx.buffer(self.sphere_data[0].tobytes())
640
+ self.sphere_index_buffer = self.glctx.buffer(self.sphere_data[1].tobytes())
641
+
642
+ self.node_vao = self.glctx.vertex_array(
643
+ self.node_prog,
644
+ [
645
+ (self.sphere_pos_vbo, "3f", "in_position"),
646
+ (self.node_vbo, "3f /i", "in_instance_position"),
647
+ (self.node_color_vbo, "3f /i", "in_instance_color"),
648
+ (self.node_size_vbo, "f /i", "in_instance_size"),
649
+ ],
650
+ index_buffer=self.sphere_index_buffer,
651
+ index_element_size=4,
652
+ )
653
+ self.node_vao.instances = len(self.nodes)
654
+
655
+ self.node_id_vao = self.glctx.vertex_array(
656
+ self.node_id_prog,
657
+ [
658
+ (self.sphere_pos_vbo, "3f", "in_position"),
659
+ (self.node_vbo, "3f /i", "in_instance_position"),
660
+ (self.node_size_vbo, "f /i", "in_instance_size"),
661
+ ],
662
+ index_buffer=self.sphere_index_buffer,
663
+ index_element_size=4,
664
+ )
665
+ self.node_id_vao.instances = len(self.nodes)
666
+
667
+ # Update edge buffers
668
+ edge_positions = []
669
+ edge_colors = []
670
+
671
+ for edge in self.graph.edges():
672
+ start_node = self.id_node_map[edge[0]]
673
+ end_node = self.id_node_map[edge[1]]
674
+
675
+ edge_positions.append(start_node.position)
676
+ edge_colors.append(start_node.color)
677
+
678
+ edge_positions.append(end_node.position)
679
+ edge_colors.append(end_node.color)
680
+
681
+ if edge_positions:
682
+ edge_positions = np.array(edge_positions, dtype=np.float32)
683
+ edge_colors = np.array(edge_colors, dtype=np.float32)
684
+
685
+ self.edge_vbo = self.glctx.buffer(edge_positions.tobytes())
686
+ self.edge_color_vbo = self.glctx.buffer(edge_colors.tobytes())
687
+
688
+ self.edge_vao = self.glctx.vertex_array(
689
+ self.edge_prog,
690
+ [
691
+ (self.edge_vbo, "3f", "in_position"),
692
+ (self.edge_color_vbo, "3f", "in_color"),
693
+ ],
694
+ )
695
+
696
+ def update_view_proj_matrix(self):
697
+ """Update view matrix based on camera parameters"""
698
+ self.view_matrix = glm.lookAt(
699
+ self.position, self.position + self.front, self.up
700
+ )
701
+
702
+ aspect_ratio = self.window_width / self.window_height
703
+ self.proj_matrix = glm.perspective(
704
+ glm.radians(60.0), # FOV
705
+ aspect_ratio, # Aspect ratio
706
+ 0.001, # Near plane
707
+ 1000.0, # Far plane
708
+ )
709
+
710
+ def find_node_at(self, screen_pos: Tuple[int, int]) -> Optional[Node3D]:
711
+ """Find the node at a specific screen position"""
712
+ if (
713
+ self.node_id_texture_np is None
714
+ or self.node_id_texture_np.shape[1] != self.window_width
715
+ or self.node_id_texture_np.shape[0] != self.window_height
716
+ or screen_pos[0] < 0
717
+ or screen_pos[1] < 0
718
+ or screen_pos[0] >= self.window_width
719
+ or screen_pos[1] >= self.window_height
720
+ ):
721
+ return None
722
+
723
+ x = screen_pos[0]
724
+ y = self.window_height - screen_pos[1] - 1
725
+ pixel = self.node_id_texture_np[y, x]
726
+
727
+ if pixel[3] == 0:
728
+ return None
729
+
730
+ R = int(round(pixel[0] * 255))
731
+ G = int(round(pixel[1] * 255))
732
+ B = int(round(pixel[2] * 255))
733
+ index = (R << 16) | (G << 8) | B
734
+
735
+ if index > len(self.nodes):
736
+ return None
737
+ return self.nodes[index]
738
+
739
+ def is_node_visible_at(self, screen_pos: Tuple[int, int], node_idx: int) -> bool:
740
+ """Check if a node exists at a specific screen position"""
741
+ node = self.find_node_at(screen_pos)
742
+ return node is not None and node.idx == node_idx
743
+
744
+ def render_settings(self):
745
+ """Render settings window"""
746
+ if imgui.begin("Graph Settings"):
747
+ # Layout type combo
748
+ changed, value = imgui.combo(
749
+ "Layout",
750
+ self.available_layouts.index(self.layout_type),
751
+ self.available_layouts,
752
+ )
753
+ if changed:
754
+ self.layout_type = self.available_layouts[value]
755
+ self.calculate_layout() # Recalculate layout when changed
756
+
757
+ # Node size slider
758
+ changed, value = imgui.slider_float("Node Scale", self.node_scale, 0.01, 10)
759
+ if changed:
760
+ self.node_scale = value
761
+
762
+ # Edge width slider
763
+ changed, value = imgui.slider_float("Edge Width", self.edge_width, 0, 20)
764
+ if changed:
765
+ self.edge_width = value
766
+
767
+ # Show labels checkbox
768
+ changed, value = imgui.checkbox("Show Labels", self.show_labels)
769
+
770
+ if changed:
771
+ self.show_labels = value
772
+
773
+ if self.show_labels:
774
+ # Label size slider
775
+ changed, value = imgui.slider_float(
776
+ "Label Size", self.label_size, 0.5, 10.0
777
+ )
778
+ if changed:
779
+ self.label_size = value
780
+
781
+ # Label color picker
782
+ changed, value = imgui.color_edit4(
783
+ "Label Color",
784
+ self.label_color,
785
+ imgui.ColorEditFlags_.picker_hue_wheel,
786
+ )
787
+ if changed:
788
+ self.label_color = (value[0], value[1], value[2], value[3])
789
+
790
+ # Label culling distance slider
791
+ changed, value = imgui.slider_float(
792
+ "Label Culling Distance", self.label_culling_distance, 0.1, 100.0
793
+ )
794
+ if changed:
795
+ self.label_culling_distance = value
796
+
797
+ # Background color picker
798
+ changed, value = imgui.color_edit4(
799
+ "Background Color",
800
+ self.background_color,
801
+ imgui.ColorEditFlags_.picker_hue_wheel,
802
+ )
803
+ if changed:
804
+ self.background_color = (value[0], value[1], value[2], value[3])
805
+
806
+ imgui.end()
807
+
808
+ def save_node_id_texture_to_png(self, filename):
809
+ # Convert to a PIL Image and save as PNG
810
+ from PIL import Image
811
+
812
+ scaled_array = self.node_id_texture_np * 255
813
+ img = Image.fromarray(
814
+ scaled_array.astype(np.uint8),
815
+ "RGBA",
816
+ )
817
+ img = img.transpose(method=Image.FLIP_TOP_BOTTOM)
818
+ img.save(filename)
819
+
820
+ def render_id_map(self, mvp: glm.mat4):
821
+ """Render an offscreen id map where each node is drawn with a unique id color."""
822
+ # Lazy initialization of id framebuffer
823
+ if self.node_id_texture is not None:
824
+ if (
825
+ self.node_id_texture.width != self.window_width
826
+ or self.node_id_texture.height != self.window_height
827
+ ):
828
+ self.node_id_fbo = None
829
+ self.node_id_texture = None
830
+ self.node_id_texture_np = None
831
+ self.node_id_depth = None
832
+
833
+ if self.node_id_texture is None:
834
+ self.node_id_texture = self.glctx.texture(
835
+ (self.window_width, self.window_height), components=4, dtype="f4"
836
+ )
837
+ self.node_id_depth = self.glctx.depth_renderbuffer(
838
+ size=(self.window_width, self.window_height)
839
+ )
840
+ self.node_id_fbo = self.glctx.framebuffer(
841
+ color_attachments=[self.node_id_texture],
842
+ depth_attachment=self.node_id_depth,
843
+ )
844
+ self.node_id_texture_np = np.zeros(
845
+ (self.window_height, self.window_width, 4), dtype=np.float32
846
+ )
847
+
848
+ # Bind the offscreen framebuffer
849
+ self.node_id_fbo.use()
850
+ self.glctx.clear(0, 0, 0, 0)
851
+
852
+ # Render nodes
853
+ if self.node_id_vao:
854
+ self.node_id_prog["mvp"].write(mvp.to_bytes())
855
+ self.node_id_prog["scale"].write(np.float32(self.node_scale).tobytes())
856
+ self.node_id_vao.render(moderngl.TRIANGLES)
857
+
858
+ # Revert to default framebuffer
859
+ self.glctx.screen.use()
860
+ self.node_id_texture.read_into(self.node_id_texture_np.data)
861
+
862
+ def render(self):
863
+ """Render the graph"""
864
+ # Clear screen
865
+ self.glctx.clear(*self.background_color, depth=1)
866
+
867
+ if not self.graph:
868
+ return
869
+
870
+ # Enable blending for transparency
871
+ self.glctx.enable(moderngl.BLEND)
872
+ self.glctx.blend_func = moderngl.SRC_ALPHA, moderngl.ONE_MINUS_SRC_ALPHA
873
+
874
+ # Update view and projection matrices
875
+ self.update_view_proj_matrix()
876
+ mvp = self.proj_matrix * self.view_matrix
877
+
878
+ # Render edges first (under nodes)
879
+ if self.edge_vao:
880
+ self.edge_prog["mvp"].write(mvp.to_bytes())
881
+ self.edge_prog["edge_width"].value = (
882
+ float(self.edge_width) * 2.0
883
+ ) # Double the width for better visibility
884
+ self.edge_prog["viewport_size"].value = (
885
+ float(self.window_width),
886
+ float(self.window_height),
887
+ )
888
+ self.edge_vao.render(moderngl.LINES)
889
+
890
+ # Render nodes
891
+ if self.node_vao:
892
+ self.node_prog["mvp"].write(mvp.to_bytes())
893
+ self.node_prog["camera"].write(self.position.to_bytes())
894
+ self.node_prog["selected_node"].write(
895
+ np.int32(self.selected_node.idx).tobytes()
896
+ if self.selected_node
897
+ else np.int32(-1).tobytes()
898
+ )
899
+ self.node_prog["highlighted_node"].write(
900
+ np.int32(self.highlighted_node.idx).tobytes()
901
+ if self.highlighted_node
902
+ else np.int32(-1).tobytes()
903
+ )
904
+ self.node_prog["scale"].write(np.float32(self.node_scale).tobytes())
905
+ self.node_vao.render(moderngl.TRIANGLES)
906
+
907
+ self.glctx.disable(moderngl.BLEND)
908
+
909
+ # Render id map
910
+ self.render_id_map(mvp)
911
+
912
+ def render_labels(self):
913
+ # Render labels if enabled
914
+ if self.show_labels and self.nodes:
915
+ # Save current font scale
916
+ original_scale = imgui.get_font_size()
917
+
918
+ self.update_view_proj_matrix()
919
+ mvp = self.proj_matrix * self.view_matrix
920
+
921
+ for node in self.nodes:
922
+ # Project node position to screen space
923
+ pos = mvp * glm.vec4(
924
+ node.position[0], node.position[1], node.position[2], 1.0
925
+ )
926
+
927
+ # Check if node is behind camera
928
+ if pos.w > 0 and pos.w < self.label_culling_distance:
929
+ screen_x = (pos.x / pos.w + 1) * self.window_width / 2
930
+ screen_y = (-pos.y / pos.w + 1) * self.window_height / 2
931
+
932
+ if self.is_node_visible_at(
933
+ (int(screen_x), int(screen_y)), node.idx
934
+ ):
935
+ # Set font scale
936
+ imgui.set_window_font_scale(float(self.label_size) * node.size)
937
+
938
+ # Calculate label size
939
+ label_size = imgui.calc_text_size(node.label)
940
+
941
+ # Adjust position to center the label
942
+ screen_x -= label_size.x / 2
943
+ screen_y -= label_size.y / 2
944
+
945
+ # Set text color with calculated alpha
946
+ imgui.push_style_color(imgui.Col_.text, self.label_color)
947
+
948
+ # Draw label using ImGui
949
+ imgui.set_cursor_pos((screen_x, screen_y))
950
+ imgui.text(node.label)
951
+
952
+ # Restore text color
953
+ imgui.pop_style_color()
954
+
955
+ # Restore original font scale
956
+ imgui.set_window_font_scale(original_scale)
957
+
958
+ def reset_view(self):
959
+ """Reset camera view to default"""
960
+ self.position = glm.vec3(0.0, -10.0, 0.0)
961
+ self.front = glm.vec3(0.0, 1.0, 0.0)
962
+ self.yaw = 90.0
963
+ self.pitch = 0.0
964
+
965
+
966
+ def generate_colors(n: int) -> List[glm.vec3]:
967
+ """Generate n distinct colors using HSV color space"""
968
+ colors = []
969
+ for i in range(n):
970
+ # Use golden ratio to generate well-distributed hues
971
+ hue = (i * 0.618033988749895) % 1.0
972
+ # Fixed saturation and value for vibrant colors
973
+ saturation = 0.8
974
+ value = 0.95
975
+ # Convert HSV to RGB
976
+ rgb = colorsys.hsv_to_rgb(hue, saturation, value)
977
+ # Add alpha channel
978
+ colors.append(glm.vec3(rgb))
979
+ return colors
980
+
981
+
982
+ def show_file_dialog() -> Optional[str]:
983
+ """Show a file dialog for selecting GraphML files"""
984
+ root = tk.Tk()
985
+ root.withdraw() # Hide the main window
986
+ file_path = filedialog.askopenfilename(
987
+ title="Select GraphML File",
988
+ filetypes=[("GraphML files", "*.graphml"), ("All files", "*.*")],
989
+ )
990
+ root.destroy()
991
+ return file_path if file_path else None
992
+
993
+
994
+ def create_sphere(sectors: int = 32, rings: int = 16) -> Tuple:
995
+ """
996
+ Creates a sphere.
997
+ """
998
+ R = 1.0 / (rings - 1)
999
+ S = 1.0 / (sectors - 1)
1000
+
1001
+ # Use those names as normals and uvs are part of the API
1002
+ vertices_l = [0.0] * (rings * sectors * 3)
1003
+ # normals_l = [0.0] * (rings * sectors * 3)
1004
+ uvs_l = [0.0] * (rings * sectors * 2)
1005
+
1006
+ v, n, t = 0, 0, 0
1007
+ for r in range(rings):
1008
+ for s in range(sectors):
1009
+ y = np.sin(-np.pi / 2 + np.pi * r * R)
1010
+ x = np.cos(2 * np.pi * s * S) * np.sin(np.pi * r * R)
1011
+ z = np.sin(2 * np.pi * s * S) * np.sin(np.pi * r * R)
1012
+
1013
+ uvs_l[t] = s * S
1014
+ uvs_l[t + 1] = r * R
1015
+
1016
+ vertices_l[v] = x
1017
+ vertices_l[v + 1] = y
1018
+ vertices_l[v + 2] = z
1019
+
1020
+ t += 2
1021
+ v += 3
1022
+ n += 3
1023
+
1024
+ indices = [0] * rings * sectors * 6
1025
+ i = 0
1026
+ for r in range(rings - 1):
1027
+ for s in range(sectors - 1):
1028
+ indices[i] = r * sectors + s
1029
+ indices[i + 1] = (r + 1) * sectors + (s + 1)
1030
+ indices[i + 2] = r * sectors + (s + 1)
1031
+
1032
+ indices[i + 3] = r * sectors + s
1033
+ indices[i + 4] = (r + 1) * sectors + s
1034
+ indices[i + 5] = (r + 1) * sectors + (s + 1)
1035
+ i += 6
1036
+
1037
+ vbo_vertices = np.array(vertices_l, dtype=np.float32)
1038
+ vbo_elements = np.array(indices, dtype=np.uint32)
1039
+
1040
+ return (vbo_vertices, vbo_elements)
1041
+
1042
+
1043
+ def draw_text_with_bg(
1044
+ text: str,
1045
+ text_pos: imgui.ImVec2Like,
1046
+ text_size: imgui.ImVec2Like,
1047
+ bg_color: int,
1048
+ ):
1049
+ imgui.get_window_draw_list().add_rect_filled(
1050
+ (text_pos[0] - 5, text_pos[1] - 5),
1051
+ (text_pos[0] + text_size[0] + 5, text_pos[1] + text_size[1] + 5),
1052
+ bg_color,
1053
+ 3.0,
1054
+ )
1055
+ imgui.set_cursor_pos(text_pos)
1056
+ imgui.text(text)
1057
+
1058
+
1059
+ def main():
1060
+ """Main application entry point"""
1061
+ viewer = GraphViewer()
1062
+
1063
+ show_fps = True
1064
+ text_bg_color = imgui.IM_COL32(0, 0, 0, 100)
1065
+
1066
+ def gui():
1067
+ if not viewer.initialized:
1068
+ viewer.setup()
1069
+ # # Change the theme
1070
+ # tweaked_theme = hello_imgui.get_runner_params().imgui_window_params.tweaked_theme
1071
+ # tweaked_theme.theme = hello_imgui.ImGuiTheme_.darcula_darker
1072
+ # hello_imgui.apply_tweaked_theme(tweaked_theme)
1073
+
1074
+ viewer.window_width = int(imgui.get_window_width())
1075
+ viewer.window_height = int(imgui.get_window_height())
1076
+
1077
+ # Handle keyboard and mouse input
1078
+ viewer.handle_keyboard_input()
1079
+ viewer.handle_mouse_interaction()
1080
+
1081
+ style = imgui.get_style()
1082
+ window_bg_color = style.color_(imgui.Col_.window_bg.value)
1083
+
1084
+ window_bg_color.w = 0.8
1085
+ style.set_color_(imgui.Col_.window_bg.value, window_bg_color)
1086
+
1087
+ # Main control window
1088
+ imgui.begin("Graph Controls")
1089
+
1090
+ if imgui.button("Load GraphML"):
1091
+ filepath = show_file_dialog()
1092
+ if filepath:
1093
+ viewer.load_file(filepath)
1094
+
1095
+ # Show error message if loading failed
1096
+ if viewer.show_load_error:
1097
+ imgui.push_style_color(imgui.Col_.text, (1.0, 0.0, 0.0, 1.0))
1098
+ imgui.text(f"Error loading file: {viewer.error_message}")
1099
+ imgui.pop_style_color()
1100
+
1101
+ imgui.separator()
1102
+
1103
+ # Camera controls help
1104
+ imgui.text("Camera Controls:")
1105
+ imgui.bullet_text("Hold Right Mouse - Look around")
1106
+ imgui.bullet_text("W/S - Move forward/backward")
1107
+ imgui.bullet_text("A/D - Move left/right")
1108
+ imgui.bullet_text("Q/E - Move up/down")
1109
+ imgui.bullet_text("Left Mouse - Select node")
1110
+ imgui.bullet_text("Wheel - Change the movement speed")
1111
+
1112
+ imgui.separator()
1113
+
1114
+ # Camera settings
1115
+ _, viewer.move_speed = imgui.slider_float(
1116
+ "Movement Speed", viewer.move_speed, 0.01, 2.0
1117
+ )
1118
+ _, viewer.mouse_sensitivity = imgui.slider_float(
1119
+ "Mouse Sensitivity", viewer.mouse_sensitivity, 0.01, 0.5
1120
+ )
1121
+
1122
+ imgui.separator()
1123
+
1124
+ imgui.begin_horizontal("buttons")
1125
+
1126
+ if imgui.button("Reset Camera"):
1127
+ viewer.reset_view()
1128
+
1129
+ if imgui.button("Update Layout") and viewer.graph:
1130
+ viewer.update_layout()
1131
+
1132
+ # if imgui.button("Save Node ID Texture"):
1133
+ # viewer.save_node_id_texture_to_png("node_id_texture.png")
1134
+
1135
+ imgui.end_horizontal()
1136
+
1137
+ imgui.end()
1138
+
1139
+ # Render node details window if a node is selected
1140
+ viewer.render_node_details()
1141
+
1142
+ # Render graph settings window
1143
+ viewer.render_settings()
1144
+
1145
+ # Render FPS
1146
+ if show_fps:
1147
+ imgui.set_window_font_scale(1)
1148
+ fps_text = f"FPS: {hello_imgui.frame_rate():.1f}"
1149
+ text_size = imgui.calc_text_size(fps_text)
1150
+ cursor_pos = (10, viewer.window_height - text_size.y - 10)
1151
+ draw_text_with_bg(fps_text, cursor_pos, text_size, text_bg_color)
1152
+
1153
+ # Render highlighted node ID
1154
+ if viewer.highlighted_node:
1155
+ imgui.set_window_font_scale(1)
1156
+ node_text = f"Node ID: {viewer.highlighted_node.label}"
1157
+ text_size = imgui.calc_text_size(node_text)
1158
+ cursor_pos = (
1159
+ viewer.window_width - text_size.x - 10,
1160
+ viewer.window_height - text_size.y - 10,
1161
+ )
1162
+ draw_text_with_bg(node_text, cursor_pos, text_size, text_bg_color)
1163
+
1164
+ window_bg_color.w = 0
1165
+ style.set_color_(imgui.Col_.window_bg.value, window_bg_color)
1166
+
1167
+ # Render labels
1168
+ viewer.render_labels()
1169
+
1170
+ def custom_background():
1171
+ if viewer.initialized:
1172
+ viewer.render()
1173
+
1174
+ runner_params = hello_imgui.RunnerParams()
1175
+ runner_params.app_window_params.window_geometry.size = (
1176
+ viewer.window_width,
1177
+ viewer.window_height,
1178
+ )
1179
+ runner_params.app_window_params.window_title = "3D GraphML Viewer"
1180
+ runner_params.callbacks.show_gui = gui
1181
+ runner_params.callbacks.custom_background = custom_background
1182
+
1183
+ def load_font():
1184
+ # You will need to provide it yourself, or use another font.
1185
+ font_filename = CUSTOM_FONT
1186
+
1187
+ io = imgui.get_io()
1188
+ io.fonts.tex_desired_width = 4096 # Larger texture for better CJK font quality
1189
+ font_size_pixels = 14
1190
+ asset_dir = os.path.join(os.path.dirname(__file__), "assets")
1191
+
1192
+ # Try to load custom font
1193
+ if not os.path.isfile(font_filename):
1194
+ font_filename = os.path.join(asset_dir, font_filename)
1195
+ if os.path.isfile(font_filename):
1196
+ custom_font = io.fonts.add_font_from_file_ttf(
1197
+ filename=font_filename,
1198
+ size_pixels=font_size_pixels,
1199
+ glyph_ranges_as_int_list=io.fonts.get_glyph_ranges_chinese_full(),
1200
+ )
1201
+ io.font_default = custom_font
1202
+ return
1203
+
1204
+ # Load default fonts
1205
+ io.fonts.add_font_from_file_ttf(
1206
+ filename=os.path.join(asset_dir, DEFAULT_FONT_ENG),
1207
+ size_pixels=font_size_pixels,
1208
+ )
1209
+
1210
+ font_config = imgui.ImFontConfig()
1211
+ font_config.merge_mode = True
1212
+
1213
+ io.font_default = io.fonts.add_font_from_file_ttf(
1214
+ filename=os.path.join(asset_dir, DEFAULT_FONT_CHI),
1215
+ size_pixels=font_size_pixels,
1216
+ font_cfg=font_config,
1217
+ glyph_ranges_as_int_list=io.fonts.get_glyph_ranges_chinese_full(),
1218
+ )
1219
+
1220
+ runner_params.callbacks.load_additional_fonts = load_font
1221
+
1222
+ immapp.run(runner_params)
1223
+
1224
+
1225
+ if __name__ == "__main__":
1226
+ main()
setup.py CHANGED
@@ -112,7 +112,7 @@ setuptools.setup(
112
  entry_points={
113
  "console_scripts": [
114
  "lightrag-server=lightrag.api.lightrag_server:main [api]",
115
- "lightrag-viewer=lightrag.tools.lightrag_visualizer:main [tools]",
116
  ],
117
  },
118
  )
 
112
  entry_points={
113
  "console_scripts": [
114
  "lightrag-server=lightrag.api.lightrag_server:main [api]",
115
+ "lightrag-viewer=lightrag.tools.lightrag_visualizer.graph_visualizer:main [tools]",
116
  ],
117
  },
118
  )