Wyomike commited on
Commit
414d65d
Β·
1 Parent(s): 3a29397

Another attempt to fix the remote graph

Browse files
Files changed (2) hide show
  1. pages/FilteredGraph.py +0 -187
  2. pages/TopicWeb.py +558 -20
pages/FilteredGraph.py CHANGED
@@ -1,190 +1,3 @@
1
- # import streamlit as st
2
- # import networkx as nx
3
- # from pyvis.network import Network
4
- # import streamlit.components.v1 as components
5
- # from supabase import create_client, Client
6
- # import json
7
- # import datetime
8
- # import math
9
- # import os
10
-
11
- # st.set_page_config(layout="wide", page_title="Live Topic Search")
12
-
13
- # # --- CONFIGURATION ---
14
- # MAX_NODES = 500 # Safety limit to prevent browser crash on huge results
15
- # TIME_SCALE = 1 / 3600 # For color gradient (Hours)
16
-
17
- # # Load Topic Labels (for pretty names)
18
- # LABELS_FILE = "topic_labels.json"
19
- # labels_map = {}
20
- # if os.path.exists(LABELS_FILE):
21
- # with open(LABELS_FILE, 'r') as f:
22
- # labels_map = json.load(f)
23
-
24
- # # --- SETUP SUPABASE ---
25
- # @st.cache_resource
26
- # def init_supabase():
27
- # url = os.environ["SUPABASE_URL"]
28
- # key = os.environ["SUPABASE_KEY"]
29
- # return create_client(url, key)
30
-
31
- # supabase: Client = init_supabase()
32
-
33
- # # --- PAGE CONFIG ---
34
- # st.title("πŸ”Ž Live Topic Search")
35
- # st.markdown("Search the **Supabase** database for specific content and visualize the community discussing it.")
36
-
37
- # # --- SIDEBAR INPUTS ---
38
- # st.sidebar.header("Search Parameters")
39
- # search_query = st.sidebar.text_input("Search Content", placeholder="e.g. 'Elon', 'Climate', 'Python'")
40
- # limit = st.sidebar.slider("Max Posts to Fetch", 50, 2000, 500)
41
-
42
- # # Visual Controls (Embedded in logic later, but setup values here)
43
- # st.sidebar.markdown("---")
44
- # st.sidebar.write("**Graph Settings**")
45
- # node_spacing = st.sidebar.slider("Node Spacing", 50, 500, 250)
46
-
47
- # # --- HELPER: Time Color ---
48
- # def get_time_color(timestamp, max_ts, min_ts):
49
- # """Calculates color from Cyan (Old) to Red (New)."""
50
- # if not timestamp: return "#888888"
51
-
52
- # # Simple normalization if range is valid
53
- # if max_ts == min_ts: return "#FF0000"
54
-
55
- # age = (max_ts - timestamp) * TIME_SCALE
56
- # max_age = (max_ts - min_ts) * TIME_SCALE
57
-
58
- # try:
59
- # decay = math.log(age + 1) / math.log(max_age + 1)
60
- # ratio = max(0.0, min(1.0, 1.0 - decay))
61
- # except ValueError:
62
- # ratio = 0.5
63
-
64
- # r = int(ratio * 255)
65
- # g = int((1 - ratio) * 255)
66
- # b = int((1 - ratio) * 255)
67
- # return f"#{r:02x}{g:02x}{b:02x}"
68
-
69
- # # --- MAIN LOGIC ---
70
-
71
- # if search_query:
72
- # with st.spinner(f"Searching database for '{search_query}'..."):
73
- # # 1. Query Supabase
74
- # # We assume your table has 'content', 'author_user_id', 'cluster_id', and 'post_timestamp'
75
- # try:
76
- # response = supabase.table("posts") \
77
- # .select("author_user_id, cluster_id, post_timestamp, content") \
78
- # .ilike("content", f"%{search_query}%") \
79
- # .limit(limit) \
80
- # .execute()
81
-
82
- # data = response.data
83
- # except Exception as e:
84
- # st.error(f"Database Error: {e}")
85
- # st.stop()
86
-
87
- # if not data:
88
- # st.warning("No posts found matching that query.")
89
- # st.stop()
90
-
91
- # st.success(f"Found {len(data)} posts. Building graph...")
92
-
93
- # # 2. Build Graph from Query Results
94
- # G = nx.Graph()
95
-
96
- # # Track min/max time for this specific batch for coloring
97
- # timestamps = [row['post_timestamp'] for row in data if row['post_timestamp']]
98
- # if timestamps:
99
- # min_ts = min(timestamps)
100
- # max_ts = max(timestamps)
101
- # else:
102
- # min_ts, max_ts = 0, 1
103
-
104
- # # Processing loop
105
- # for row in data:
106
- # user_id = row['author_user_id']
107
- # topic_id = row['cluster_id']
108
- # ts = row['post_timestamp'] or 0
109
-
110
- # if not user_id: continue
111
-
112
- # # Node IDs
113
- # u_node = f"User_{user_id}"
114
-
115
- # # Handle Topic Node (Use Label if available)
116
- # if topic_id is not None:
117
- # if topic_id == 0:
118
- # continue
119
- # raw_t_id = f"Topic_{topic_id}"
120
- # t_label = labels_map.get(raw_t_id, f"Topic {topic_id}")
121
- # t_node = raw_t_id
122
- # else:
123
- # # If no topic assigned, link to a "Uncategorized" node
124
- # t_node = "Topic_Unknown"
125
- # t_label = "Uncategorized"
126
-
127
- # # Add Nodes
128
- # if u_node not in G:
129
- # G.add_node(u_node, label=f"User {user_id}", group="users", title=f"User ID: {user_id}", color="#97C2FC", size=10)
130
-
131
- # if t_node not in G:
132
- # G.add_node(t_node, label=t_label, group="topics", title=t_label, color="#FB7E81", size=25)
133
-
134
- # # Add/Update Edge
135
- # if G.has_edge(u_node, t_node):
136
- # G[u_node][t_node]['weight'] += 1
137
- # # Keep most recent timestamp
138
- # if ts > G[u_node][t_node]['ts']:
139
- # G[u_node][t_node]['ts'] = ts
140
- # else:
141
- # G.add_edge(u_node, t_node, weight=1, ts=ts)
142
-
143
- # # 3. Generate PyVis Visualization
144
- # nt = Network(height="700px", width="100%", bgcolor="#222222", font_color="white")
145
-
146
- # # Add Nodes
147
- # for node_id in G.nodes:
148
- # attr = G.nodes[node_id]
149
- # nt.add_node(node_id, label=attr['label'], title=attr['title'], color=attr['color'], size=attr['size'])
150
-
151
- # # Add Edges with Coloring
152
- # for u, v, d in G.edges(data=True):
153
- # weight = d['weight']
154
- # ts = d['ts']
155
- # color = get_time_color(ts, max_ts, min_ts)
156
-
157
- # date_str = datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d') if ts > 0 else "Unknown"
158
- # title = f"{weight} posts matching '{search_query}'\nLast Active: {date_str}"
159
-
160
- # nt.add_edge(u, v, value=weight, title=title, color=color)
161
-
162
- # # Physics Options
163
- # nt.set_options(f"""
164
- # var options = {{
165
- # "physics": {{
166
- # "barnesHut": {{
167
- # "gravitationalConstant": -8000,
168
- # "springLength": {node_spacing},
169
- # "springConstant": 0.04,
170
- # "damping": 0.2
171
- # }},
172
- # "minVelocity": 0.75
173
- # }}
174
- # }}
175
- # """)
176
-
177
- # # Render
178
- # html_data = nt.generate_html()
179
- # components.html(html_data, height=720, scrolling=False)
180
-
181
- # # Stats
182
- # st.info(f"Visualizing {G.number_of_nodes()} nodes (Users & Topics) and {G.number_of_edges()} connections based on search results.")
183
-
184
- # else:
185
- # st.info("πŸ‘ˆ Enter a search term in the sidebar to generate a graph from the live database.")
186
-
187
-
188
  import streamlit as st
189
  import networkx as nx
190
  from pyvis.network import Network
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import streamlit as st
2
  import networkx as nx
3
  from pyvis.network import Network
pages/TopicWeb.py CHANGED
@@ -1,3 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import streamlit as st
2
  import networkx as nx
3
  import pickle
@@ -8,12 +499,14 @@ import datetime
8
  import math
9
  import pandas as pd
10
  import streamlit.components.v1 as components
 
11
 
12
  # --- CONFIGURATION ---
13
  CACHE_FILE = "mastodon_network.pkl"
14
  LABELS_FILE = "topic_labels.json"
15
- MAX_PRE_FETCH = 50 # Python sends max 50 neighbors per node to browser
16
- TIME_SCALE = 1 / 3600 # Logarithmic scale factor (Hours)
 
17
 
18
  # Define Blocklist
19
  NSFW_KEYWORDS = {
@@ -26,29 +519,74 @@ NSFW_KEYWORDS = {
26
  # --- PAGE CONFIG ---
27
  st.set_page_config(layout="wide", page_title="Mastodon Graph Explorer")
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  # --- 1. LOAD DATA (Cached) ---
30
  @st.cache_resource
31
  def load_data():
32
  data = {"G": None, "labels": {}, "min_ts": 0, "max_ts": 1}
33
 
34
- if os.path.exists(CACHE_FILE):
35
- with open(CACHE_FILE, 'rb') as f:
36
- data["G"] = pickle.load(f)
37
-
38
- # Pre-calculate Time Range
39
- G = data["G"]
40
- timestamps = [
41
- d['last_timestamp']
42
- for u, v, d in G.edges(data=True)
43
- if 'last_timestamp' in d and d['last_timestamp'] > 0
44
- ]
45
- if timestamps:
46
- data["min_ts"] = min(timestamps)
47
- data["max_ts"] = max(timestamps)
48
 
49
- if os.path.exists(LABELS_FILE):
50
- with open(LABELS_FILE, 'r') as f:
51
- data["labels"] = json.load(f)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
  return data
54
 
@@ -59,7 +597,7 @@ MIN_TS = data["min_ts"]
59
  MAX_TS = data["max_ts"]
60
 
61
  if G is None:
62
- st.error(f"Cache file '{CACHE_FILE}' not found! Please run 'build_graph_cache.py' locally first.")
63
  st.stop()
64
 
65
  # --- HELPER FUNCTIONS ---
 
1
+ # import streamlit as st
2
+ # import networkx as nx
3
+ # import pickle
4
+ # import os
5
+ # import json
6
+ # import random
7
+ # import datetime
8
+ # import math
9
+ # import pandas as pd
10
+ # import streamlit.components.v1 as components
11
+
12
+ # # --- CONFIGURATION ---
13
+ # CACHE_FILE = "mastodon_network.pkl"
14
+ # LABELS_FILE = "topic_labels.json"
15
+ # MAX_PRE_FETCH = 50 # Python sends max 50 neighbors per node to browser
16
+ # TIME_SCALE = 1 / 3600 # Logarithmic scale factor (Hours)
17
+
18
+ # # Define Blocklist
19
+ # NSFW_KEYWORDS = {
20
+ # "nsfw", "porn", "nude", "naked", "sex", "18+", "xxx",
21
+ # "cum", "dick", "pussy", "hentai", "erotic", "onlyfans",
22
+ # "boobs", "tits", "cock", "slut", "whore", "bitch", "incest",
23
+ # "lewd", "horny", "sensual", "fetish", "bondage"
24
+ # }
25
+
26
+ # # --- PAGE CONFIG ---
27
+ # st.set_page_config(layout="wide", page_title="Mastodon Graph Explorer")
28
+
29
+ # # --- 1. LOAD DATA (Cached) ---
30
+ # @st.cache_resource
31
+ # def load_data():
32
+ # data = {"G": None, "labels": {}, "min_ts": 0, "max_ts": 1}
33
+
34
+ # if os.path.exists(CACHE_FILE):
35
+ # with open(CACHE_FILE, 'rb') as f:
36
+ # data["G"] = pickle.load(f)
37
+
38
+ # # Pre-calculate Time Range
39
+ # G = data["G"]
40
+ # timestamps = [
41
+ # d['last_timestamp']
42
+ # for u, v, d in G.edges(data=True)
43
+ # if 'last_timestamp' in d and d['last_timestamp'] > 0
44
+ # ]
45
+ # if timestamps:
46
+ # data["min_ts"] = min(timestamps)
47
+ # data["max_ts"] = max(timestamps)
48
+
49
+ # if os.path.exists(LABELS_FILE):
50
+ # with open(LABELS_FILE, 'r') as f:
51
+ # data["labels"] = json.load(f)
52
+
53
+ # return data
54
+
55
+ # data = load_data()
56
+ # G = data["G"]
57
+ # labels_map = data["labels"]
58
+ # MIN_TS = data["min_ts"]
59
+ # MAX_TS = data["max_ts"]
60
+
61
+ # if G is None:
62
+ # st.error(f"Cache file '{CACHE_FILE}' not found! Please run 'build_graph_cache.py' locally first.")
63
+ # st.stop()
64
+
65
+ # # --- HELPER FUNCTIONS ---
66
+ # def get_time_color(ts):
67
+ # """Returns a hex color code from Cyan (Old) to Red (New) using Logarithmic Age."""
68
+ # if ts <= 0: return "#888888"
69
+
70
+ # age = (MAX_TS - ts) * TIME_SCALE
71
+ # max_age = (MAX_TS - MIN_TS) * TIME_SCALE
72
+
73
+ # if max_age == 0: return "#FF0000"
74
+
75
+ # # Logarithmic decay
76
+ # decay = math.log(age + 1) / math.log(max_age + 1)
77
+ # ratio = max(0.0, min(1.0, 1.0 - decay))
78
+
79
+ # # Cyan (#00FFFF) to Red (#FF0000)
80
+ # r = int(ratio * 255)
81
+ # g = int((1 - ratio) * 255)
82
+ # b = int((1 - ratio) * 255)
83
+ # return f"#{r:02x}{g:02x}{b:02x}"
84
+
85
+ # def is_node_allowed(node_id):
86
+ # if node_id == "Topic_0": return False
87
+ # label = labels_map.get(node_id, G.nodes[node_id].get('label', node_id))
88
+ # if label == "[NSFW]": return False
89
+ # if isinstance(label, str):
90
+ # label_words = set(label.lower().replace("_", " ").split())
91
+ # if not NSFW_KEYWORDS.isdisjoint(label_words):
92
+ # return False
93
+ # return True
94
+
95
+ # def get_node_label(node_id):
96
+ # """Returns the friendly label for a node if available, else the ID."""
97
+ # if node_id.startswith("Topic_") and node_id in labels_map:
98
+ # return labels_map[node_id]
99
+ # return G.nodes[node_id].get('label', node_id)
100
+
101
+ # def get_related_entities(graph, node_id, top_n=10):
102
+ # """
103
+ # Finds nodes that share the most connections with the target node.
104
+ # For a User, finds Users with shared Topics.
105
+ # For a Topic, finds Topics with shared Users.
106
+ # """
107
+ # if node_id not in graph:
108
+ # return []
109
+
110
+ # # Get direct neighbors (e.g., Topics for a User)
111
+ # direct_neighbors = list(graph.neighbors(node_id))
112
+
113
+ # related_scores = {}
114
+
115
+ # for neighbor in direct_neighbors:
116
+ # if not is_node_allowed(neighbor): continue
117
+
118
+ # # Get neighbors of neighbor (e.g., Users for a Topic)
119
+ # second_degree_neighbors = list(graph.neighbors(neighbor))
120
+
121
+ # for second_node in second_degree_neighbors:
122
+ # if second_node == node_id: continue
123
+ # if not is_node_allowed(second_node): continue
124
+
125
+ # # Score = Number of shared connections (Simple Common Neighbors)
126
+ # related_scores[second_node] = related_scores.get(second_node, 0) + 1
127
+
128
+ # # Sort by score descending
129
+ # sorted_related = sorted(related_scores.items(), key=lambda x: x[1], reverse=True)
130
+ # return sorted_related[:top_n]
131
+
132
+ # # --- SIDEBAR CONTROLS ---
133
+ # st.sidebar.title("πŸ” Graph Controls")
134
+
135
+ # # Prepare Topic List for Dropdown
136
+ # topic_nodes = [n for n in G.nodes if n.startswith("Topic_")]
137
+ # topic_options = {}
138
+
139
+ # for t in topic_nodes:
140
+ # # Get fancy label if available, otherwise ID
141
+ # label = labels_map.get(t, G.nodes[t].get('label', t))
142
+
143
+ # # Skip NSFW/Filtered topics in the dropdown
144
+ # if not is_node_allowed(t):
145
+ # continue
146
+
147
+ # # Format: "Nature Photography (Topic_5)"
148
+ # display_name = f"{label} ({t})"
149
+ # topic_options[display_name] = t
150
+
151
+ # # Prepare User List for Dropdown
152
+ # user_nodes = [n for n in G.nodes if n.startswith("User_")]
153
+ # user_options = {}
154
+
155
+ # for u in user_nodes:
156
+ # # Label is usually "User {id}"
157
+ # label = G.nodes[u].get('label', u)
158
+ # user_options[label] = u
159
+
160
+ # # Search Mode
161
+ # search_mode = st.sidebar.radio("Search Mode", ["Select Topic", "Select User", "Random Topic"])
162
+
163
+ # target_node = None
164
+
165
+ # if search_mode == "Select User":
166
+ # # Sort options for easier finding
167
+ # sorted_users = sorted(user_options.keys())
168
+ # selected_user = st.sidebar.selectbox("Search User", sorted_users)
169
+
170
+ # if selected_user:
171
+ # target_node = user_options[selected_user]
172
+
173
+ # elif search_mode == "Select Topic":
174
+ # # Sort options alphabetically
175
+ # sorted_options = sorted(topic_options.keys())
176
+ # selected_option = st.sidebar.selectbox("Choose a Topic", sorted_options)
177
+
178
+ # if selected_option:
179
+ # target_node = topic_options[selected_option]
180
+
181
+ # elif search_mode == "Random Topic":
182
+ # if st.sidebar.button("Pick Random Topic"):
183
+ # # Pick from the filtered list we created above
184
+ # if topic_options:
185
+ # random_display = random.choice(list(topic_options.keys()))
186
+ # target_node = topic_options[random_display]
187
+ # st.session_state['selected_node'] = target_node
188
+
189
+ # if 'selected_node' in st.session_state and not target_node:
190
+ # target_node = st.session_state['selected_node']
191
+
192
+ # # Sliders
193
+ # st.sidebar.markdown("---")
194
+ # max_neighbors = st.sidebar.slider("Top Neighbors per Node", 1, 50, 5, help="How many strong connections to show per node.")
195
+ # depth = st.sidebar.slider("Graph Depth", 1, 2, 2, help="1 = Direct connections only. 2 = Friends of friends.")
196
+
197
+ # # --- MAIN GRAPH GENERATION ---
198
+ # def generate_html(center_node, max_neighbors, depth):
199
+ # # 1. Python BFS (Pre-Filtering)
200
+ # nodes_to_include = {center_node}
201
+ # edges_to_include = []
202
+
203
+ # l1_neighbors = list(G.neighbors(center_node))
204
+ # l1_neighbors.sort(key=lambda x: G[center_node][x].get('weight', 1), reverse=True)
205
+ # l1_neighbors = l1_neighbors[:MAX_PRE_FETCH]
206
+
207
+ # for n1 in l1_neighbors:
208
+ # if not is_node_allowed(n1): continue
209
+ # nodes_to_include.add(n1)
210
+ # edges_to_include.append((center_node, n1))
211
+
212
+ # l2_neighbors = list(G.neighbors(n1))
213
+ # l2_neighbors.sort(key=lambda x: G[n1][x].get('weight', 1), reverse=True)
214
+ # l2_neighbors = l2_neighbors[:MAX_PRE_FETCH]
215
+
216
+ # for n2 in l2_neighbors:
217
+ # if not is_node_allowed(n2): continue
218
+ # if n2 != center_node:
219
+ # nodes_to_include.add(n2)
220
+ # edges_to_include.append((n1, n2))
221
+
222
+ # # 2. Prepare JSON Data
223
+ # js_nodes = []
224
+ # for nid in nodes_to_include:
225
+ # attr = G.nodes[nid]
226
+ # group = attr.get('group', 'unknown')
227
+ # base_size = attr.get('size', 10)
228
+
229
+ # # Apply Labels
230
+ # if group == "topics" and nid in labels_map:
231
+ # label = labels_map[nid]
232
+ # if len(label) > 20: label = label[:20] + "..."
233
+ # title = f"{nid}: {labels_map[nid]}"
234
+ # else:
235
+ # label = attr.get('label', nid)
236
+ # title = label
237
+
238
+ # node_data = {
239
+ # "id": nid,
240
+ # "label": label,
241
+ # "group": group,
242
+ # "value": base_size, # Default VisJS scaling
243
+ # "title": title,
244
+ # "baseSize": base_size # Used for custom slider scaling
245
+ # }
246
+
247
+ # if nid == center_node:
248
+ # node_data["color"] = "#00FF00"
249
+ # node_data["size"] = 40
250
+ # node_data["baseSize"] = 40
251
+ # elif group == "users":
252
+ # node_data["color"] = "#97C2FC"
253
+ # else:
254
+ # node_data["color"] = "#FB7E81"
255
+
256
+ # js_nodes.append(node_data)
257
+
258
+ # js_edges = []
259
+ # for u, v in edges_to_include:
260
+ # data = G[u][v]
261
+ # weight = data.get('weight', 1)
262
+ # ts = data.get('last_timestamp', 0)
263
+
264
+ # edge_color = get_time_color(ts)
265
+ # date_str = datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d') if ts > 0 else "Unknown"
266
+ # tooltip = f"Posts: {weight}\nLast Active: {date_str}"
267
+
268
+ # js_edges.append({
269
+ # "from": u,
270
+ # "to": v,
271
+ # "value": weight,
272
+ # "title": tooltip,
273
+ # "color": edge_color
274
+ # })
275
+
276
+ # # 3. Generate HTML with Embedded Logic
277
+ # html_string = f"""
278
+ # <!DOCTYPE html>
279
+ # <html>
280
+ # <head>
281
+ # <script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
282
+ # <style type="text/css">
283
+ # body {{ font-family: sans-serif; background-color: #222; color: white; margin: 0; }}
284
+ # #mynetwork {{ width: 100%; height: 700px; border: 1px solid #444; }}
285
+ # #controls {{ padding: 15px; background-color: #333; display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }}
286
+ # .control-group {{ display: flex; align-items: center; gap: 10px; }}
287
+ # input[type=range] {{ width: 150px; cursor: pointer; }}
288
+ # .label {{ font-weight: bold; font-size: 14px; color: #ddd; }}
289
+ # .val {{ font-family: monospace; color: #00FF00; font-weight: bold; width: 30px; }}
290
+ # .legend {{ margin-left: auto; font-size: 12px; color: #aaa; display: flex; gap: 10px; align-items: center; }}
291
+ # .dot {{ height: 10px; width: 10px; border-radius: 50%; display: inline-block; margin-right: 5px; }}
292
+ # </style>
293
+ # </head>
294
+ # <body>
295
+ # <div id="controls">
296
+ # <div class="control-group">
297
+ # <span class="label">πŸ” Top N:</span>
298
+ # <input type="range" id="limitSlider" min="1" max="{MAX_PRE_FETCH}" value="{max_neighbors}">
299
+ # <span id="limitLabel" class="val">{max_neighbors}</span>
300
+ # </div>
301
+ # <div class="control-group">
302
+ # <span class="label">πŸ•ΈοΈ Depth:</span>
303
+ # <input type="range" id="depthSlider" min="1" max="10" value="{depth}" step="1">
304
+ # <span id="depthLabel" class="val">{depth}</span>
305
+ # </div>
306
+ # <div class="control-group">
307
+ # <span class="label">πŸ”΅ Size:</span>
308
+ # <input type="range" id="sizeSlider" min="10" max="200" value="100">
309
+ # <span id="sizeLabel" class="val">100%</span>
310
+ # </div>
311
+ # <div class="legend">
312
+ # <span><span class="dot" style="background-color: #00FFFF;"></span>Old</span>
313
+ # <span><span class="dot" style="background-color: #FF0000;"></span>New</span>
314
+ # </div>
315
+ # </div>
316
+ # <div id="mynetwork"></div>
317
+ # <script type="text/javascript">
318
+ # const allNodes = {json.dumps(js_nodes)};
319
+ # const allEdges = {json.dumps(js_edges)};
320
+ # const rootNodeId = "{center_node}";
321
+
322
+ # const adjacency = {{}};
323
+ # allEdges.forEach(edge => {{
324
+ # if (!adjacency[edge.from]) adjacency[edge.from] = [];
325
+ # if (!adjacency[edge.to]) adjacency[edge.to] = [];
326
+ # adjacency[edge.from].push(edge);
327
+ # adjacency[edge.to].push(edge);
328
+ # }});
329
+
330
+ # function getFilteredData(limit, maxDepth, sizeScale) {{
331
+ # const visitedNodes = new Set();
332
+ # const edgesToKeep = new Set();
333
+ # const queue = [{{ id: rootNodeId, depth: 0 }}];
334
+ # visitedNodes.add(rootNodeId);
335
+
336
+ # while (queue.length > 0) {{
337
+ # const {{ id: currentNode, depth: currentDepth }} = queue.shift();
338
+ # if (currentDepth >= maxDepth) continue;
339
+
340
+ # const myEdges = adjacency[currentNode] || [];
341
+ # const sortedEdges = myEdges.sort((a, b) => b.value - a.value);
342
+ # const topEdges = sortedEdges.slice(0, limit);
343
+
344
+ # topEdges.forEach(edge => {{
345
+ # const neighbor = (edge.from === currentNode) ? edge.to : edge.from;
346
+ # edgesToKeep.add(edge);
347
+ # if (!visitedNodes.has(neighbor)) {{
348
+ # visitedNodes.add(neighbor);
349
+ # queue.push({{ id: neighbor, depth: currentDepth + 1 }});
350
+ # }}
351
+ # }});
352
+ # }}
353
+
354
+ # const filteredEdges = Array.from(edgesToKeep);
355
+ # const filteredNodes = allNodes
356
+ # .filter(n => visitedNodes.has(n.id))
357
+ # .map(n => {{
358
+ # return {{ ...n, size: n.baseSize * (sizeScale / 100), value: undefined }};
359
+ # }});
360
+
361
+ # return {{ nodes: filteredNodes, edges: filteredEdges }};
362
+ # }}
363
+
364
+ # const container = document.getElementById('mynetwork');
365
+ # const options = {{
366
+ # nodes: {{ shape: 'dot', font: {{ color: 'white' }} }},
367
+ # edges: {{ smooth: {{ type: 'continuous' }} }},
368
+ # physics: {{
369
+ # stabilization: false,
370
+ # barnesHut: {{ gravitationalConstant: -8000, springConstant: 0.04, springLength: 250, damping: 0.2 }},
371
+ # minVelocity: 0.75
372
+ # }}
373
+ # }};
374
+
375
+ # let currentLimit = {max_neighbors};
376
+ # let currentDepth = {depth};
377
+ # let currentSize = 100;
378
+
379
+ # const initialData = getFilteredData(currentLimit, currentDepth, currentSize);
380
+ # const network = new vis.Network(container, initialData, options);
381
+
382
+ # const limitSlider = document.getElementById('limitSlider');
383
+ # const depthSlider = document.getElementById('depthSlider');
384
+ # const sizeSlider = document.getElementById('sizeSlider');
385
+
386
+ # limitSlider.oninput = function() {{
387
+ # currentLimit = parseInt(this.value);
388
+ # document.getElementById('limitLabel').innerText = currentLimit;
389
+ # network.setData(getFilteredData(currentLimit, currentDepth, currentSize));
390
+ # }}
391
+ # depthSlider.oninput = function() {{
392
+ # currentDepth = parseInt(this.value);
393
+ # document.getElementById('depthLabel').innerText = currentDepth;
394
+ # network.setData(getFilteredData(currentLimit, currentDepth, currentSize));
395
+ # }}
396
+ # sizeSlider.oninput = function() {{
397
+ # currentSize = parseInt(this.value);
398
+ # document.getElementById('sizeLabel').innerText = currentSize + "%";
399
+ # network.setData(getFilteredData(currentLimit, currentDepth, currentSize));
400
+ # }}
401
+ # </script>
402
+ # </body>
403
+ # </html>
404
+ # """
405
+ # return html_string
406
+
407
+ # # --- RENDER PAGE ---
408
+ # st.title("Topic Buzz Explorer 🐝")
409
+
410
+ # if target_node:
411
+ # display_name = get_node_label(target_node)
412
+ # st.subheader(f"Visualizing: {display_name}")
413
+
414
+ # # --- GRAPH ---
415
+ # html_data = generate_html(target_node, max_neighbors, depth)
416
+ # components.html(html_data, height=720, scrolling=False)
417
+ # st.info(f"Exploring **{display_name}**. Use the controls inside the graph to filter connections.")
418
+
419
+ # # --- METRICS & ANALYSIS ---
420
+ # st.markdown("---")
421
+ # st.subheader(f"πŸ“Š Analysis for {display_name}")
422
+
423
+ # col1, col2 = st.columns(2)
424
+
425
+ # # Column 1: Direct Neighbors (Who/What are they connecting to?)
426
+ # with col1:
427
+ # st.markdown("#### πŸ”— Top Connections")
428
+ # neighbors = list(G.neighbors(target_node))
429
+
430
+ # # Sort by edge weight (posts)
431
+ # neighbors.sort(key=lambda x: G[target_node][x].get('weight', 1), reverse=True)
432
+
433
+ # # Convert to DataFrame for nice table
434
+ # conn_data = []
435
+ # for n in neighbors[:15]: # Top 15
436
+ # if not is_node_allowed(n): continue
437
+ # weight = G[target_node][n].get('weight', 1)
438
+ # ts = G[target_node][n].get('last_timestamp', 0)
439
+ # date_str = datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d') if ts > 0 else "N/A"
440
+
441
+ # conn_data.append({
442
+ # "Name": get_node_label(n),
443
+ # "Posts": weight,
444
+ # "Last Active": date_str
445
+ # })
446
+
447
+ # if conn_data:
448
+ # st.dataframe(pd.DataFrame(conn_data), hide_index=True)
449
+ # else:
450
+ # st.write("No connections found.")
451
+
452
+ # # Column 2: Related Entities (Who else is like this?)
453
+ # with col2:
454
+ # if target_node.startswith("User_"):
455
+ # st.markdown("#### 🀝 Similar Users (Shared Topics)")
456
+ # else:
457
+ # st.markdown("#### πŸ”— Related Topics (Shared Users)")
458
+
459
+ # related = get_related_entities(G, target_node, top_n=15)
460
+
461
+ # related_data = []
462
+ # for node_id, score in related:
463
+ # related_data.append({
464
+ # "Name": get_node_label(node_id),
465
+ # "Shared Connections": score
466
+ # })
467
+
468
+ # if related_data:
469
+ # st.dataframe(pd.DataFrame(related_data), hide_index=True)
470
+ # else:
471
+ # st.write("No related entities found.")
472
+
473
+ # else:
474
+ # st.write("πŸ‘ˆ Select a User or Topic from the sidebar to begin.")
475
+ # st.markdown("""
476
+ # ### How to use:
477
+ # 1. **Select a Topic** to see who is talking about it.
478
+ # 2. **Select a User** to see what they are interested in.
479
+ # 3. **Use the Sliders** inside the graph to filter noise and explore connections.
480
+ # """)
481
+
482
+
483
+
484
+
485
+
486
+
487
+
488
+
489
+
490
+
491
+
492
  import streamlit as st
493
  import networkx as nx
494
  import pickle
 
499
  import math
500
  import pandas as pd
501
  import streamlit.components.v1 as components
502
+ from supabase import create_client, Client # <--- ADDED IMPORT
503
 
504
  # --- CONFIGURATION ---
505
  CACHE_FILE = "mastodon_network.pkl"
506
  LABELS_FILE = "topic_labels.json"
507
+ BUCKET = "topic_assets" # <--- ADDED BUCKET NAME
508
+ MAX_PRE_FETCH = 50
509
+ TIME_SCALE = 1 / 3600
510
 
511
  # Define Blocklist
512
  NSFW_KEYWORDS = {
 
519
  # --- PAGE CONFIG ---
520
  st.set_page_config(layout="wide", page_title="Mastodon Graph Explorer")
521
 
522
+ # --- SUPABASE SETUP (Added) ---
523
+ # We need this to download the graph if it's missing
524
+ SUPABASE_URL = os.environ.get("SUPABASE_URL")
525
+ SUPABASE_KEY = os.environ.get("SUPABASE_KEY")
526
+ try:
527
+ supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
528
+ except Exception:
529
+ st.warning("Supabase credentials missing. Will only look for local files.")
530
+ supabase = None
531
+
532
+ # --- HELPER: Download from Supabase (Added) ---
533
+ def ensure_local_file(filename):
534
+ """
535
+ Checks if a file exists locally. If not, attempts to download it
536
+ from Supabase Storage and save it to disk.
537
+ """
538
+ if os.path.exists(filename):
539
+ return True
540
+
541
+ if not supabase:
542
+ return False
543
+
544
+ print(f"☁️ File {filename} not found locally. Downloading from Supabase...")
545
+ try:
546
+ # 1. Get the bytes from Supabase
547
+ file_bytes = supabase.storage.from_(BUCKET).download(filename)
548
+
549
+ # 2. Save bytes to local disk
550
+ with open(filename, "wb") as f:
551
+ f.write(file_bytes)
552
+
553
+ print(f"βœ… Downloaded {filename}")
554
+ return True
555
+ except Exception as e:
556
+ print(f"❌ Failed to download {filename}: {e}")
557
+ return False
558
+
559
  # --- 1. LOAD DATA (Cached) ---
560
  @st.cache_resource
561
  def load_data():
562
  data = {"G": None, "labels": {}, "min_ts": 0, "max_ts": 1}
563
 
564
+ # 1. Check/Download Graph
565
+ if ensure_local_file(CACHE_FILE):
566
+ try:
567
+ with open(CACHE_FILE, 'rb') as f:
568
+ data["G"] = pickle.load(f)
 
 
 
 
 
 
 
 
 
569
 
570
+ # Pre-calculate Time Range
571
+ G = data["G"]
572
+ timestamps = [
573
+ d['last_timestamp']
574
+ for u, v, d in G.edges(data=True)
575
+ if 'last_timestamp' in d and d['last_timestamp'] > 0
576
+ ]
577
+ if timestamps:
578
+ data["min_ts"] = min(timestamps)
579
+ data["max_ts"] = max(timestamps)
580
+ except Exception as e:
581
+ st.error(f"Error loading graph pickle: {e}")
582
+
583
+ # 2. Check/Download Labels
584
+ if ensure_local_file(LABELS_FILE):
585
+ try:
586
+ with open(LABELS_FILE, 'r') as f:
587
+ data["labels"] = json.load(f)
588
+ except Exception:
589
+ pass # Non-critical
590
 
591
  return data
592
 
 
597
  MAX_TS = data["max_ts"]
598
 
599
  if G is None:
600
+ st.error(f"Could not load '{CACHE_FILE}'. Check your Supabase bucket or run the build script.")
601
  st.stop()
602
 
603
  # --- HELPER FUNCTIONS ---