""" VOSviewer-faithful bibliometric network renderer. Implements render_coauthorship_network, render_keyword_network, and render_topic_network using PyVis embedded in Streamlit via st.components.v1.html(). Every visual specification in Phase 4 Section B is implemented here. """ import json import math import re from statistics import median from typing import Any, Dict, List, Optional, Tuple import networkx as nx import hashlib as _hashlib try: with open(__file__, "rb") as _f: _VIZ_VERSION = "v" + _hashlib.md5(_f.read()).hexdigest()[:10] except Exception: _VIZ_VERSION = "v1" from config import ( CANVAS_BG, COMMUNITY_COLORS, NODE_SIZE_MIN, NODE_SIZE_MAX, EDGE_WIDTH_MIN, EDGE_WIDTH_MAX, COLOR_SURFACE_ELEVATED, COLOR_TEXT_SECONDARY, COLOR_PRIMARY, ) from utils.helpers import ( lighten_hex, hex_to_rgba, scale_node_size, scale_edge_width, truncate, format_author_short, percentile, ) # ── Physics options ─────────────────────────────────────────────────────────── def get_physics_options(node_count: int, network_type: str = "default") -> Dict: if node_count < 50: grav = -3000 spring = 200 overlap = 0.8 elif node_count > 500: grav = -8000 spring = 100 overlap = 1.0 else: grav = -5000 spring = 150 overlap = 0.9 # Co-authorship network: use forceAtlas2Based solver instead of barnesHut. # barnesHut applies global pairwise repulsion between ALL nodes — this pushes # intra-cluster nodes into polygon rings regardless of avoidOverlap settings. # forceAtlas2Based uses hub-weighted repulsion: high-degree nodes repel more, # which naturally separates communities while keeping cluster members in # tight organic blobs (not rings). centralGravity=0.01 keeps all clusters # in one compact mass without scattering them across the canvas. if network_type == "coauthorship": return { "layout": { "improvedLayout": False, }, "physics": { "enabled": True, "solver": "forceAtlas2Based", "forceAtlas2Based": { # springLength=1 collapses connected nodes toward the same # point; avoidOverlap=1.0 then stops them at exact touching # distance (sum of radii). This creates a unique BLOB # equilibrium — nodes pack at contact — rather than the ring # equilibrium produced by large springLength where nodes # settle equidistant around a hub. Since node sizes vary # (paper-count scaled 18-85), touching distances differ per # pair, naturally breaking symmetry. "gravitationalConstant": -20, "centralGravity": 0.02, "springLength": 1, "springConstant": 0.08, "damping": 0.4, "avoidOverlap": 1.0, }, "maxVelocity": 80, "minVelocity": 0.3, "stabilization": { "enabled": True, "iterations": 600, "updateInterval": 10, "fit": True, }, "timestep": 0.2, }, "interaction": { "hover": True, "tooltipDelay": 150, "hideEdgesOnDrag": True, "hideEdgesOnZoom": False, "multiselect": True, "navigationButtons": False, "keyboard": {"enabled": False}, "zoomView": True, }, "nodes": { "chosen": True, "physics": True, "shadow": False, "font": { "size": 20, "color": "#000000", "strokeWidth": 2, "strokeColor": "#FFFFFF", "vadjust": 0, }, }, "edges": { "chosen": True, "physics": True, "hoverWidth": 2.5, "selectionWidth": 3.0, "smooth": {"type": "continuous", "roundness": 0.1}, }, } # Keyword networks: zero centralGravity + balanced spring/repulsion. # centralGravity=0 prevents circular ring. Stronger repulsion + longer softer # springs spread clusters wide across canvas without intra-cluster overlap. elif network_type == "keyword": grav = -55000 # strong repulsion separates clusters across canvas central_grav = 0.0 # zero — topology drives placement, no ring force spring = 220 # longer: gives nodes within each cluster room spring_const = 0.05 # soft: cluster structure without squashing nodes damping = 0.10 overlap = 1.0 iterations = 6000 timestep = 0.20 max_vel = 100 min_vel = 0.10 else: central_grav = 0.15 spring_const = 0.04 damping = 0.12 iterations = 2000 timestep = 0.35 max_vel = 60 min_vel = 0.3 return { "physics": { "enabled": True, "barnesHut": { "gravitationalConstant": grav, "centralGravity": central_grav, "springLength": spring, "springConstant": spring_const, "damping": damping, "avoidOverlap": overlap, }, "maxVelocity": max_vel, "minVelocity": min_vel, "stabilization": { "enabled": True, "iterations": iterations, "updateInterval": 25, "fit": True, }, "timestep": timestep, }, "interaction": { "hover": True, "tooltipDelay": 150, "hideEdgesOnDrag": True, "hideEdgesOnZoom": False, "multiselect": True, "navigationButtons": False, "keyboard": {"enabled": False}, "zoomView": True, }, "nodes": { "chosen": True, "physics": True, "shadow": False, "font": { "size": 22 if network_type == "keyword" else 14, "color": "#000000", "strokeWidth": 2, "strokeColor": "#FFFFFF", "vadjust": -40 if network_type == "keyword" else 0, }, }, "edges": { "chosen": True, "physics": True, "hoverWidth": 2.5, "selectionWidth": 3.0, "smooth": {"type": "continuous", "roundness": 0.1}, }, } # ── Tooltip container style ─────────────────────────────────────────────────── _TOOLTIP_STYLE = ( "background:#1C2539;border:1px solid #3B4A6B;border-radius:8px;" "padding:10px 14px;font-family:'Open Sans',Arial,sans-serif;" "font-size:12px;color:#F0F4FF;max-width:260px;" "box-shadow:0 4px 16px rgba(0,0,0,0.5);line-height:1.6;" ) def _wrap_tooltip(content: str) -> str: return f'