OverviewFilesCommitsIssuesDocsReleasesPeers

godot-keet commits / 820deaf

Cluster-aware grid layout + improved metadata population
Grid layout now groups nodes by cluster in separate regions instead of a flat grid. Uses grid when edges are sparse (ratio < 0.5). Auto-populate script: --force flag overwrites stale metadata, better category inference using member count for untitled rooms. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
820deaf09ff6932a3b66f087c291a72258045283
by plankatron <fleeky@gmail.com> · 2026-02-23T03:22:08.000Z

Changed files (3) +358 -15

scripts/graph_canvas.gd +58 -15
……@@ -78,11 +78,13 @@ func set_graph_data(nodes: Array, edges: Array, clusters: Array, cluster_labels:
7878 ids.sort()
7979 seed("".join(ids).hash())
8080
81 if _edges.is_empty():
82 # No edges → grid layout (force-directed is pointless without attraction)
81 # Choose layout strategy:
82 # - No edges or very sparse → cluster-aware grid (force-directed needs edges to work)
83 # - Enough edges → force-directed with cluster seeding
84 var edge_ratio: float = float(_edges.size()) / maxf(float(_nodes.size()), 1.0)
85 if _edges.is_empty() or edge_ratio < 0.5:
8386 _grid_layout()
8487 else:
85 # Force-directed layout
8688 _init_cluster_positions(clusters)
8789 for i: int in LAYOUT_ITERATIONS:
8890 _layout_step()
……@@ -91,25 +93,66 @@ func set_graph_data(nodes: Array, edges: Array, clusters: Array, cluster_labels:
9193
9294
9395func _grid_layout() -> void:
94 ## Deterministic grid layout — used when no edges exist.
96 ## Cluster-aware grid layout — groups nodes by cluster in separate regions.
9597 if _nodes.is_empty():
9698 return
9799 _positions.clear()
98100 var rect: Vector2 = size if size.x > 0 else Vector2(600, 400)
99 var n: int = _nodes.size()
100 var cols: int = ceili(sqrt(float(n)))
101 var rows: int = ceili(float(n) / float(cols))
102 var cell_w: float = rect.x / float(cols + 1)
103 var cell_h: float = rect.y / float(rows + 1)
104
105 for i: int in n:
106 var node: Variant = _nodes[i]
101
102 # Group nodes by cluster
103 var clustered: Dictionary = {} # cluster_id -> [node_id]
104 var unclustered: Array = []
105 for node: Variant in _nodes:
107106 if not node is Dictionary:
108107 continue
109108 var nid: String = str(node.get("id", ""))
110 var col: int = i % cols
111 var row: int = i / cols
112 _positions[nid] = Vector2((col + 1) * cell_w, (row + 1) * cell_h)
109 if _cluster_map.has(nid):
110 var ci: int = _cluster_map[nid]
111 if not clustered.has(ci):
112 clustered[ci] = []
113 clustered[ci].append(nid)
114 else:
115 unclustered.append(nid)
116
117 # All groups: each cluster + unclustered
118 var groups: Array = [] # [{ids: [], color_idx: int}]
119 var sorted_keys: Array = clustered.keys()
120 sorted_keys.sort()
121 for ci: Variant in sorted_keys:
122 groups.append({"ids": clustered[ci], "ci": ci})
123 if not unclustered.is_empty():
124 groups.append({"ids": unclustered, "ci": -1})
125
126 if groups.is_empty():
127 return
128
129 # Layout groups in a horizontal strip, each group gets proportional width
130 var total_nodes: int = _nodes.size()
131 var padding: float = 40.0
132 var group_gap: float = 30.0
133 var avail_w: float = rect.x - padding * 2 - group_gap * float(groups.size() - 1)
134 var avail_h: float = rect.y - padding * 2
135
136 var x_offset: float = padding
137 for g: Dictionary in groups:
138 var ids: Array = g["ids"]
139 var n: int = ids.size()
140 # Width proportional to number of nodes (min width for small groups)
141 var group_w: float = maxf(avail_w * (float(n) / float(total_nodes)), 60.0)
142 var cols: int = ceili(sqrt(float(n)))
143 var rows: int = ceili(float(n) / float(cols))
144 var cell_w: float = group_w / float(cols + 1)
145 var cell_h: float = avail_h / float(rows + 1)
146
147 for i: int in n:
148 var nid: String = ids[i]
149 var col: int = i % cols
150 var row: int = i / cols
151 _positions[nid] = Vector2(
152 x_offset + (col + 1) * cell_w,
153 padding + (row + 1) * cell_h
154 )
155 x_offset += group_w + group_gap
113156
114157
115158func _init_cluster_positions(clusters: Array) -> void:
tests/auto-populate-meta.mjs +248 -0
……@@ -0,0 +1,248 @@
1/**
2 * auto-populate-meta.mjs — Auto-populate metadata for all rooms
3 *
4 * For each room:
5 * - Fetches title, type, member count from keet-core
6 * - Sets discoverable, topics, language, category based on room info
7 * - Skips DMs and special rooms (_test-suite, admin channel)
8 * - Merges with existing metadata (doesn't overwrite unless --force)
9 *
10 * Usage:
11 * bare tests/auto-populate-meta.mjs # dry run (preview changes)
12 * bare tests/auto-populate-meta.mjs --apply # apply changes
13 * bare tests/auto-populate-meta.mjs --force --apply # overwrite existing
14 */
15
16import Pipe from 'bare-pipe'
17import FramedStream from 'framed-stream'
18import b4a from 'b4a'
19
20const SOCKET = '/tmp/keet-daemon.sock'
21const ADMIN_CHANNEL = '98q5xy39rsibzjaxt6bjau5y9qfcjzk6phird399tpbq377uzkio'
22const TEST_SUITE = '_test-suite'
23const APPLY = Bare.argv.includes('--apply')
24const FORCE = Bare.argv.includes('--force') // overwrite existing metadata
25let rpcId = 0
26
27function connect () {
28 const pipe = new Pipe(SOCKET)
29 const framed = new FramedStream(pipe)
30 const pending = new Map()
31
32 framed.on('data', (buf) => {
33 try {
34 const msg = JSON.parse(b4a.toString(buf))
35 const cb = pending.get(msg.id)
36 if (cb) {
37 pending.delete(msg.id)
38 if (msg.ok !== undefined && msg.data !== undefined) msg.result = msg.data
39 cb(msg)
40 }
41 } catch {}
42 })
43
44 function call (method, params = {}) {
45 return new Promise((resolve, reject) => {
46 const id = ++rpcId
47 const timeout = setTimeout(() => {
48 pending.delete(id)
49 reject(new Error(`RPC timeout: ${method}`))
50 }, 15000)
51 pending.set(id, (msg) => {
52 clearTimeout(timeout)
53 if (msg.error) reject(new Error(msg.error.message || msg.error))
54 else resolve(msg.result)
55 })
56 const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params })
57 framed.write(b4a.from(payload))
58 })
59 }
60
61 function close () { framed.destroy(); pipe.destroy() }
62 return { call, close }
63}
64
65// Infer topics from room title + member count
66function inferTopics (title, roomType, memberCount) {
67 if (!title || title === '(untitled)') {
68 // For untitled rooms, use member count to differentiate
69 if (memberCount <= 2) return ['private']
70 if (memberCount <= 5) return ['small-group']
71 return ['community']
72 }
73
74 const lower = title.toLowerCase()
75 const topics = []
76
77 // Category detection — order by specificity
78 if (lower.includes('test') || lower.includes('_test')) topics.push('testing')
79 if (lower.includes('lobby') || lower.includes('general') || lower.includes('chat')) topics.push('social')
80 if (lower.includes('ai') || lower.includes('bot') || lower.includes('clode') || lower.includes('claude')) topics.push('ai')
81 if (lower.includes('invite') || lower.includes('welcome') || lower.includes('onboard')) topics.push('onboarding')
82 if (lower.includes('dev') || lower.includes('code') || lower.includes('hack') || lower.includes('build')) topics.push('development')
83 if (lower.includes('game') || lower.includes('godot') || lower.includes('play')) topics.push('gaming')
84 if (lower.includes('p2p') || lower.includes('hyper') || lower.includes('pear') || lower.includes('keet')) topics.push('p2p')
85 if (lower.includes('music') || lower.includes('art') || lower.includes('media')) topics.push('media')
86 if (lower.includes('wallet') || lower.includes('crypto') || lower.includes('bitcoin') || lower.includes('btc')) topics.push('crypto')
87
88 if (topics.length === 0) {
89 if (memberCount <= 2) topics.push('private')
90 else if (memberCount <= 5) topics.push('small-group')
91 else topics.push('community')
92 }
93 return topics
94}
95
96// Infer category from topics
97function inferCategory (topics) {
98 if (topics.includes('testing')) return 'testing'
99 if (topics.includes('onboarding')) return 'onboarding'
100 if (topics.includes('development')) return 'development'
101 if (topics.includes('gaming')) return 'gaming'
102 if (topics.includes('ai')) return 'ai'
103 if (topics.includes('p2p')) return 'p2p'
104 if (topics.includes('crypto')) return 'crypto'
105 if (topics.includes('media')) return 'media'
106 if (topics.includes('social')) return 'social'
107 if (topics.includes('private')) return 'private'
108 if (topics.includes('small-group')) return 'small-group'
109 if (topics.includes('community')) return 'community'
110 return 'general'
111}
112
113// Normalize stale category values
114function normalizeCategory (cat) {
115 if (cat === 'test') return 'testing' // fix inconsistency
116 return cat
117}
118
119async function run () {
120 console.log(`\n=== Auto-Populate Room Metadata ${APPLY ? '(APPLYING)' : '(DRY RUN)'}${FORCE ? ' [FORCE]' : ''} ===\n`)
121
122 const client = connect()
123
124 const rooms = await client.call('rooms')
125 let updated = 0
126 let skipped = 0
127
128 for (const room of rooms) {
129 const id = room.roomId || room.id
130 const title = room.title || '(untitled)'
131
132 // Get room details
133 let info
134 try {
135 info = await client.call('room', { roomId: id })
136 } catch {
137 console.log(` SKIP: "${title}" — cannot get room info`)
138 skipped++
139 continue
140 }
141
142 const roomType = info.digest?.settings?.roomType
143
144 // Skip DMs
145 if (roomType === 3) {
146 console.log(` SKIP: "${title}" — DM room`)
147 skipped++
148 continue
149 }
150
151 // Skip test suite room
152 if (title === TEST_SUITE) {
153 console.log(` SKIP: "${title}" — test suite room`)
154 skipped++
155 continue
156 }
157
158 // Skip admin channel (but DO set its metadata)
159 const isAdmin = id === ADMIN_CHANNEL
160
161 // Get member count for better inference
162 let memberCount = 0
163 try {
164 const mResult = await client.call('members', { roomId: id })
165 const members = Array.isArray(mResult) ? mResult : (mResult?.members || mResult?.data || [])
166 memberCount = members.length
167 } catch {}
168
169 // Get existing metadata
170 let existing = {}
171 try {
172 existing = await client.call('meta.get', { roomId: id })
173 } catch {}
174
175 // Build metadata update
176 const update = {}
177 let changes = []
178
179 // Topics: set if empty/missing OR force
180 if (FORCE || !existing.topics || existing.topics.length === 0) {
181 if (isAdmin) {
182 update.topics = ['admin', 'system']
183 } else {
184 update.topics = inferTopics(title, roomType, memberCount)
185 }
186 changes.push(`topics=[${update.topics.join(', ')}]`)
187 }
188
189 // Discoverable: set if missing OR force
190 if (FORCE || existing.discoverable === undefined || existing.discoverable === null) {
191 update.discoverable = !isAdmin
192 changes.push(`discoverable=${update.discoverable}`)
193 }
194
195 // Language: set if missing OR force
196 if (FORCE || !existing.language) {
197 update.language = 'en'
198 changes.push('language=en')
199 }
200
201 // Category: normalize stale values, infer from topics
202 const existingCat = existing.category ? normalizeCategory(existing.category) : null
203 if (FORCE || !existingCat) {
204 const topics = update.topics || existing.topics || ['general']
205 update.category = isAdmin ? 'system' : inferCategory(topics)
206 changes.push(`category=${update.category}`)
207 } else if (existingCat !== existing.category) {
208 // Category was normalized (e.g. "test" → "testing")
209 update.category = existingCat
210 changes.push(`category=${existingCat} (normalized)`)
211 }
212
213 if (changes.length === 0) {
214 console.log(` OK: "${title}" — already populated`)
215 skipped++
216 continue
217 }
218
219 console.log(` ${APPLY ? 'SET' : 'WOULD SET'}: "${title}" (${id.slice(0, 12)}...) [${memberCount} members]`)
220 console.log(` ${changes.join(', ')}`)
221
222 if (APPLY) {
223 try {
224 await client.call('meta.set', { roomId: id, metadata: update })
225 updated++
226 } catch (err) {
227 console.log(` ERROR: ${err.message}`)
228 }
229 } else {
230 updated++
231 }
232 }
233
234 console.log(`\n=== RESULT ===`)
235 console.log(` ${APPLY ? 'Updated' : 'Would update'}: ${updated}`)
236 console.log(` Skipped: ${skipped}`)
237 console.log(` Total rooms: ${rooms.length}`)
238
239 if (!APPLY && updated > 0) {
240 console.log(`\nRun with --apply to execute: bare tests/auto-populate-meta.mjs --apply`)
241 console.log(`Add --force to overwrite existing: bare tests/auto-populate-meta.mjs --force --apply`)
242 }
243
244 client.close()
245 setTimeout(() => Bare.exit(0), 500)
246}
247
248run().catch(err => { console.error('FATAL:', err.message); Bare.exit(1) })
tests/check-meta.mjs +52 -0
……@@ -0,0 +1,52 @@
1import Pipe from 'bare-pipe'
2import FramedStream from 'framed-stream'
3import b4a from 'b4a'
4
5let rpcId = 0
6const pipe = new Pipe('/tmp/keet-daemon.sock')
7const framed = new FramedStream(pipe)
8const pending = new Map()
9
10framed.on('data', buf => {
11 const msg = JSON.parse(b4a.toString(buf))
12 const cb = pending.get(msg.id)
13 if (cb) { pending.delete(msg.id); cb(msg) }
14})
15
16function call (method, params = {}) {
17 return new Promise((resolve, reject) => {
18 const id = ++rpcId
19 pending.set(id, msg => {
20 if (msg.ok !== undefined && msg.data !== undefined) msg.result = msg.data
21 if (msg.error) reject(new Error(msg.error.message || String(msg.error)))
22 else resolve(msg.result)
23 })
24 framed.write(b4a.from(JSON.stringify({ jsonrpc: '2.0', id, method, params })))
25 })
26}
27
28const result = await call('rooms')
29const rooms = Array.isArray(result) ? result : []
30
31console.log('Room metadata audit:\n')
32const catCounts = {}
33
34for (const r of rooms) {
35 const rid = r.roomId || r.id
36 const title = r.title || '(untitled)'
37 let meta = {}
38 try { meta = await call('meta.get', { roomId: rid }) } catch {}
39 if (!meta) meta = {}
40 const cat = meta.category || 'NONE'
41 const topics = meta.topics || []
42 catCounts[cat] = (catCounts[cat] || 0) + 1
43 console.log(title.padEnd(28), 'cat=' + cat.padEnd(14), 'topics=' + JSON.stringify(topics))
44}
45
46console.log('\nCategory distribution:')
47for (const [cat, count] of Object.entries(catCounts).sort((a, b) => b[1] - a[1])) {
48 console.log(' ' + cat.padEnd(16) + count + ' rooms')
49}
50
51pipe.destroy()
52setTimeout(() => Bare.exit(0), 500)