| … | … | @@ -38,7 +38,7 @@ const CLUSTER_COLORS: Array[Color] = [ |
| 38 | 38 | Color("#e67e22"), Color("#5865f2"), Color("#0dbd8b"), Color("#e74c3c"), |
| 39 | 39 | Color("#f1c40f"), Color("#9b59b6"), Color("#1abc9c"), Color("#3498db") |
| 40 | 40 | ] |
| 41 | | const COLOR_UNCLUSTERED := Color(0.6, 0.65, 0.7) |
| 41 | const COLOR_UNCLUSTERED := Color(0.75, 0.8, 0.85) |
| 42 | 42 | const COLOR_EDGE := Color(1.0, 1.0, 1.0) |
| 43 | 43 | const COLOR_BG := Color(0.08, 0.08, 0.1) |
| 44 | 44 | |
| … | … | @@ -58,6 +58,11 @@ func set_graph_data(nodes: Array, edges: Array, clusters: Array, cluster_labels: |
| 58 | 58 | _title_map.clear() |
| 59 | 59 | _cluster_labels = cluster_labels |
| 60 | 60 | |
| 61 | # Reset zoom/pan so new layout is fully visible |
| 62 | _zoom = 1.0 |
| 63 | _pan = Vector2.ZERO |
| 64 | _hovered_node = "" |
| 65 | |
| 61 | 66 | # Build cluster membership: room_id -> cluster_id |
| 62 | 67 | for ci: int in clusters.size(): |
| 63 | 68 | var cluster: Variant = clusters[ci] |
| … | … | @@ -127,23 +132,23 @@ func _grid_layout() -> void: |
| 127 | 132 | if groups.is_empty(): |
| 128 | 133 | return |
| 129 | 134 | |
| 130 | | # Layout groups in a horizontal strip, each group gets proportional width |
| 131 | | var total_nodes: int = _nodes.size() |
| 135 | # Layout groups with fixed minimum cell size so nodes never bunch up. |
| 136 | # Each group gets enough space for its nodes at min_cell_w apart. |
| 132 | 137 | var padding: float = 40.0 |
| 133 | | var group_gap: float = 30.0 |
| 134 | | var avail_w: float = rect.x - padding * 2 - group_gap * float(groups.size() - 1) |
| 138 | var group_gap: float = 40.0 |
| 139 | var min_cell_w: float = 90.0 # minimum horizontal spacing between nodes |
| 140 | var min_cell_h: float = 70.0 # minimum vertical spacing between nodes |
| 135 | 141 | var avail_h: float = rect.y - padding * 2 |
| 136 | 142 | |
| 137 | 143 | var x_offset: float = padding |
| 138 | 144 | for g: Dictionary in groups: |
| 139 | 145 | var ids: Array = g["ids"] |
| 140 | 146 | var n: int = ids.size() |
| 141 | | # Width proportional to number of nodes (min width for small groups) |
| 142 | | var group_w: float = maxf(avail_w * (float(n) / float(total_nodes)), 60.0) |
| 143 | 147 | var cols: int = ceili(sqrt(float(n))) |
| 144 | 148 | var rows: int = ceili(float(n) / float(cols)) |
| 145 | | var cell_w: float = group_w / float(cols + 1) |
| 146 | | var cell_h: float = avail_h / float(rows + 1) |
| 149 | var cell_w: float = maxf(min_cell_w, avail_h / float(rows + 1)) |
| 150 | var cell_h: float = maxf(min_cell_h, avail_h / float(rows + 1)) |
| 151 | var group_w: float = cell_w * float(cols + 1) |
| 147 | 152 | |
| 148 | 153 | for i: int in n: |
| 149 | 154 | var nid: String = ids[i] |
| … | … | @@ -244,7 +249,7 @@ func _layout_step() -> void: |
| 244 | 249 | func _spread_overlapping() -> void: |
| 245 | 250 | ## Push apart nodes that are too close, so labels don't overlap. |
| 246 | 251 | ## Minimum distance = enough for a label (~80px at zoom 1.0). |
| 247 | | var min_dist: float = 50.0 |
| 252 | var min_dist: float = 80.0 |
| 248 | 253 | var node_ids: Array = _positions.keys() |
| 249 | 254 | # Run a few passes to resolve cascading overlaps |
| 250 | 255 | for _pass: int in 5: |
| … | … | @@ -294,8 +299,8 @@ func _rescale_to_fit() -> void: |
| 294 | 299 | |
| 295 | 300 | var available: Vector2 = rect - Vector2(padding * 2, padding * 2) |
| 296 | 301 | var scale_factor: float = minf(available.x / extent.x, available.y / extent.y) |
| 297 | | # Don't scale up if layout is already small |
| 298 | | scale_factor = minf(scale_factor, 2.0) |
| 302 | # Don't scale up excessively |
| 303 | scale_factor = minf(scale_factor, 3.0) |
| 299 | 304 | |
| 300 | 305 | var center_of_nodes: Vector2 = (min_pos + max_pos) / 2.0 |
| 301 | 306 | var viewport_center: Vector2 = rect / 2.0 |
| … | … | @@ -315,17 +320,30 @@ func _draw() -> void: |
| 315 | 320 | return |
| 316 | 321 | |
| 317 | 322 | # Draw edges — daemon uses {from, to, mi} |
| 323 | # Adaptive rendering: reduce alpha and filter low-MI edges when dense |
| 324 | var edge_count: int = _edges.size() |
| 325 | var mi_floor: float = 0.0 # skip edges below this MI |
| 326 | var alpha_scale: float = 1.0 |
| 327 | if edge_count > 100: |
| 328 | mi_floor = 0.3 |
| 329 | alpha_scale = 0.5 |
| 330 | elif edge_count > 50: |
| 331 | mi_floor = 0.1 |
| 332 | alpha_scale = 0.7 |
| 333 | |
| 318 | 334 | for edge: Variant in _edges: |
| 319 | 335 | if not edge is Dictionary: |
| 320 | 336 | continue |
| 321 | 337 | var src: String = str(edge.get("from", "")) |
| 322 | 338 | var tgt: String = str(edge.get("to", "")) |
| 323 | 339 | var mi: float = float(edge.get("mi", 1.0)) |
| 340 | if mi < mi_floor: |
| 341 | continue |
| 324 | 342 | if not _positions.has(src) or not _positions.has(tgt): |
| 325 | 343 | continue |
| 326 | 344 | var p1: Vector2 = _world_to_screen(_positions[src]) |
| 327 | 345 | var p2: Vector2 = _world_to_screen(_positions[tgt]) |
| 328 | | var alpha: float = clampf(mi / 3.0, 0.15, 0.7) |
| 346 | var alpha: float = clampf(mi / 3.0 * alpha_scale, 0.05, 0.6) |
| 329 | 347 | var width: float = clampf(mi, 0.5, 3.0) * _zoom |
| 330 | 348 | draw_line(p1, p2, Color(COLOR_EDGE, alpha), width) |
| 331 | 349 | |
| … | … | @@ -350,46 +368,49 @@ func _draw() -> void: |
| 350 | 368 | |
| 351 | 369 | draw_circle(pos, radius, color) |
| 352 | 370 | |
| 353 | | # Label below node |
| 371 | # Label below node — centered using font metrics |
| 354 | 372 | var title: String = _title_map.get(nid, nid.left(8)) |
| 355 | 373 | if title.length() > MAX_LABEL: |
| 356 | 374 | title = title.left(MAX_LABEL - 1) + "..." |
| 357 | | var label_pos: Vector2 = pos + Vector2(-title.length() * 2.5 * _zoom, (radius + 12.0 * _zoom)) |
| 358 | 375 | var font_size: int = int(LABEL_SIZE * _zoom) |
| 359 | 376 | if font_size >= 6: |
| 360 | | draw_string(_font, label_pos, title, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color(0.8, 0.8, 0.8)) |
| 377 | var text_w: float = _font.get_string_size(title, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x |
| 378 | var label_pos: Vector2 = pos + Vector2(-text_w * 0.5, radius + 14.0 * _zoom) |
| 379 | draw_string(_font, label_pos, title, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color(0.85, 0.85, 0.85)) |
| 361 | 380 | |
| 362 | | # Draw cluster labels at centroids |
| 381 | # Draw cluster labels above cluster bounding box (not centroid — avoids node collision) |
| 363 | 382 | if not _cluster_labels.is_empty(): |
| 364 | | # Collect node positions per cluster |
| 365 | | var cluster_positions: Dictionary = {} # cluster_index -> Array[Vector2] |
| 383 | # Collect node positions per cluster and find top-center of bounding box |
| 384 | var cluster_bounds: Dictionary = {} # cluster_index -> {min_x, max_x, min_y} |
| 366 | 385 | for nid: String in _cluster_map.keys(): |
| 367 | 386 | var ci: int = _cluster_map[nid] |
| 368 | 387 | if not _positions.has(nid): |
| 369 | 388 | continue |
| 370 | | if not cluster_positions.has(ci): |
| 371 | | cluster_positions[ci] = [] |
| 372 | | cluster_positions[ci].append(_positions[nid]) |
| 389 | var p: Vector2 = _positions[nid] |
| 390 | if not cluster_bounds.has(ci): |
| 391 | cluster_bounds[ci] = {"min_x": p.x, "max_x": p.x, "min_y": p.y} |
| 392 | else: |
| 393 | var b: Dictionary = cluster_bounds[ci] |
| 394 | b["min_x"] = minf(b["min_x"], p.x) |
| 395 | b["max_x"] = maxf(b["max_x"], p.x) |
| 396 | b["min_y"] = minf(b["min_y"], p.y) |
| 373 | 397 | |
| 374 | | for ci: int in cluster_positions.keys(): |
| 398 | for ci: int in cluster_bounds.keys(): |
| 375 | 399 | if not _cluster_labels.has(ci): |
| 376 | 400 | continue |
| 377 | 401 | var meta: Dictionary = _cluster_labels[ci] |
| 378 | 402 | var label_text: String = str(meta.get("label", "")) |
| 379 | 403 | if label_text == "": |
| 380 | 404 | continue |
| 381 | | # Compute centroid |
| 382 | | var pts: Array = cluster_positions[ci] |
| 383 | | var centroid := Vector2.ZERO |
| 384 | | for p: Vector2 in pts: |
| 385 | | centroid += p |
| 386 | | centroid /= float(pts.size()) |
| 387 | | var screen_pos: Vector2 = _world_to_screen(centroid) |
| 405 | var b: Dictionary = cluster_bounds[ci] |
| 406 | # Position label at top-center of cluster, above all nodes |
| 407 | var top_center := Vector2((b["min_x"] + b["max_x"]) / 2.0, b["min_y"]) |
| 408 | var screen_pos: Vector2 = _world_to_screen(top_center) |
| 388 | 409 | var color: Color = CLUSTER_COLORS[ci % CLUSTER_COLORS.size()] |
| 389 | 410 | var font_size: int = int(14.0 * _zoom) |
| 390 | 411 | if font_size >= 8: |
| 391 | | # Draw label above the centroid |
| 392 | | var lbl_pos: Vector2 = screen_pos - Vector2(label_text.length() * 3.5 * _zoom, 20.0 * _zoom) |
| 412 | var text_w: float = _font.get_string_size(label_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x |
| 413 | var lbl_pos: Vector2 = screen_pos - Vector2(text_w * 0.5, 24.0 * _zoom) |
| 393 | 414 | draw_string(_font, lbl_pos, label_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color.lightened(0.4)) |
| 394 | 415 | |
| 395 | 416 | # Tooltip for hovered node |