gopeak 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Force-directed layout algorithm for node positioning
3
+ */
4
+ import { nodes, edges, NODE_W, NODE_H } from './state.js';
5
+ // Minimum spacing between nodes
6
+ const MIN_SPACING_X = NODE_W + 40;
7
+ const MIN_SPACING_Y = NODE_H + 30;
8
+ export function initLayout() {
9
+ if (nodes.length === 0)
10
+ return;
11
+ // Build adjacency map for connected nodes
12
+ const adjacency = new Map();
13
+ nodes.forEach(n => {
14
+ adjacency.set(n.path, []);
15
+ });
16
+ edges.forEach(e => {
17
+ if (adjacency.has(e.from) && adjacency.has(e.to)) {
18
+ adjacency.get(e.from).push(e.to);
19
+ adjacency.get(e.to).push(e.from);
20
+ }
21
+ });
22
+ // Find root nodes (most connections or extends nothing)
23
+ const connectionCount = new Map();
24
+ nodes.forEach(n => {
25
+ const count = (adjacency.get(n.path) || []).length;
26
+ connectionCount.set(n.path, count);
27
+ });
28
+ // Sort nodes by connection count (most connected first)
29
+ const sortedNodes = [...nodes].sort((a, b) => connectionCount.get(b.path) - connectionCount.get(a.path));
30
+ // Initial placement: spread nodes in a grid with good spacing
31
+ const cols = Math.ceil(Math.sqrt(nodes.length));
32
+ const startX = -(cols * MIN_SPACING_X) / 2;
33
+ const startY = -(Math.ceil(nodes.length / cols) * MIN_SPACING_Y) / 2;
34
+ sortedNodes.forEach((n, i) => {
35
+ const col = i % cols;
36
+ const row = Math.floor(i / cols);
37
+ n.x = startX + col * MIN_SPACING_X;
38
+ n.y = startY + row * MIN_SPACING_Y;
39
+ });
40
+ // Run force-directed simulation with collision detection
41
+ const iterations = 150;
42
+ for (let iter = 0; iter < iterations; iter++) {
43
+ const alpha = Math.pow(1 - iter / iterations, 2); // Quadratic cooling
44
+ applyForces(alpha, adjacency);
45
+ resolveCollisions();
46
+ }
47
+ // Final collision resolution pass
48
+ for (let i = 0; i < 10; i++) {
49
+ resolveCollisions();
50
+ }
51
+ // Center the layout
52
+ centerLayout();
53
+ }
54
+ export function initGroupedLayout() {
55
+ if (nodes.length === 0)
56
+ return;
57
+ const groups = {};
58
+ nodes.forEach(n => {
59
+ const cat = n.category || 'other';
60
+ if (!groups[cat])
61
+ groups[cat] = [];
62
+ groups[cat].push(n);
63
+ });
64
+ const groupKeys = Object.keys(groups).sort((a, b) => groups[b].length - groups[a].length);
65
+ const groupCols = Math.ceil(Math.sqrt(groupKeys.length));
66
+ const GROUP_PADDING = 60;
67
+ const GROUP_GAP = 120;
68
+ const groupLayouts = {};
69
+ groupKeys.forEach((cat, gi) => {
70
+ const groupNodes = groups[cat];
71
+ const cols = Math.max(1, Math.ceil(Math.sqrt(groupNodes.length)));
72
+ const rows = Math.ceil(groupNodes.length / cols);
73
+ const innerW = cols * MIN_SPACING_X;
74
+ const innerH = rows * MIN_SPACING_Y;
75
+ const gCol = gi % groupCols;
76
+ const gRow = Math.floor(gi / groupCols);
77
+ groupLayouts[cat] = {
78
+ nodes: groupNodes,
79
+ cols,
80
+ rows,
81
+ innerW,
82
+ innerH,
83
+ totalW: innerW + GROUP_PADDING * 2,
84
+ totalH: innerH + GROUP_PADDING * 2 + 30,
85
+ gCol,
86
+ gRow
87
+ };
88
+ });
89
+ const maxColWidths = [];
90
+ const maxRowHeights = [];
91
+ Object.values(groupLayouts).forEach(g => {
92
+ while (maxColWidths.length <= g.gCol)
93
+ maxColWidths.push(0);
94
+ while (maxRowHeights.length <= g.gRow)
95
+ maxRowHeights.push(0);
96
+ maxColWidths[g.gCol] = Math.max(maxColWidths[g.gCol], g.totalW);
97
+ maxRowHeights[g.gRow] = Math.max(maxRowHeights[g.gRow], g.totalH);
98
+ });
99
+ const colOffsets = [0];
100
+ for (let i = 1; i < maxColWidths.length; i++) {
101
+ colOffsets[i] = colOffsets[i - 1] + maxColWidths[i - 1] + GROUP_GAP;
102
+ }
103
+ const rowOffsets = [0];
104
+ for (let i = 1; i < maxRowHeights.length; i++) {
105
+ rowOffsets[i] = rowOffsets[i - 1] + maxRowHeights[i - 1] + GROUP_GAP;
106
+ }
107
+ Object.entries(groupLayouts).forEach(([cat, g]) => {
108
+ const groupX = colOffsets[g.gCol];
109
+ const groupY = rowOffsets[g.gRow] + 30;
110
+ g.groupBox = {
111
+ x: groupX - GROUP_PADDING,
112
+ y: groupY - GROUP_PADDING - 30,
113
+ w: g.totalW,
114
+ h: g.totalH,
115
+ category: cat
116
+ };
117
+ g.nodes.forEach((n, ni) => {
118
+ const col = ni % g.cols;
119
+ const row = Math.floor(ni / g.cols);
120
+ n.x = groupX + col * MIN_SPACING_X + MIN_SPACING_X / 2;
121
+ n.y = groupY + row * MIN_SPACING_Y + MIN_SPACING_Y / 2;
122
+ });
123
+ });
124
+ const iterations = 60;
125
+ for (let iter = 0; iter < iterations; iter++) {
126
+ const alpha = Math.pow(1 - iter / iterations, 2) * 0.5;
127
+ Object.values(groupLayouts).forEach(g => {
128
+ for (let i = 0; i < g.nodes.length; i++) {
129
+ for (let j = i + 1; j < g.nodes.length; j++) {
130
+ const a = g.nodes[i];
131
+ const b = g.nodes[j];
132
+ let dx = b.x - a.x;
133
+ let dy = b.y - a.y;
134
+ if (dx === 0 && dy === 0) {
135
+ dx = Math.random() - 0.5;
136
+ dy = Math.random() - 0.5;
137
+ }
138
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
139
+ const force = (20000 / (dist * dist)) * alpha;
140
+ const fx = (dx / dist) * force;
141
+ const fy = (dy / dist) * force;
142
+ a.x -= fx;
143
+ a.y -= fy;
144
+ b.x += fx;
145
+ b.y += fy;
146
+ }
147
+ }
148
+ });
149
+ edges.forEach(e => {
150
+ const from = nodes.find(n => n.path === e.from);
151
+ const to = nodes.find(n => n.path === e.to);
152
+ if (!from || !to || from.category !== to.category)
153
+ return;
154
+ const dx = to.x - from.x;
155
+ const dy = to.y - from.y;
156
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
157
+ if (dist > MIN_SPACING_X) {
158
+ const force = (dist - MIN_SPACING_X) * 0.05 * alpha;
159
+ const fx = (dx / dist) * force;
160
+ const fy = (dy / dist) * force;
161
+ from.x += fx;
162
+ from.y += fy;
163
+ to.x -= fx;
164
+ to.y -= fy;
165
+ }
166
+ });
167
+ }
168
+ window.__categoryGroupBoxes = Object.values(groupLayouts).map(g => g.groupBox);
169
+ centerGroupedLayout();
170
+ }
171
+ function applyForces(alpha, adjacency) {
172
+ const repulsion = 50000; // Strong repulsion
173
+ const attraction = 0.08; // Moderate attraction
174
+ const idealEdgeLength = MIN_SPACING_X * 1.2;
175
+ // Repulsion between all nodes
176
+ for (let i = 0; i < nodes.length; i++) {
177
+ for (let j = i + 1; j < nodes.length; j++) {
178
+ const a = nodes[i];
179
+ const b = nodes[j];
180
+ let dx = b.x - a.x;
181
+ let dy = b.y - a.y;
182
+ // Add small random offset if nodes are at same position
183
+ if (dx === 0 && dy === 0) {
184
+ dx = (Math.random() - 0.5) * 10;
185
+ dy = (Math.random() - 0.5) * 10;
186
+ }
187
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
188
+ const minDist = MIN_SPACING_X; // Minimum desired distance
189
+ // Stronger repulsion when nodes are close
190
+ let force = repulsion / (dist * dist);
191
+ if (dist < minDist) {
192
+ force *= 3; // Extra push when too close
193
+ }
194
+ const fx = (dx / dist) * force * alpha;
195
+ const fy = (dy / dist) * force * alpha;
196
+ a.x -= fx;
197
+ a.y -= fy;
198
+ b.x += fx;
199
+ b.y += fy;
200
+ }
201
+ }
202
+ // Attraction along edges - pull connected nodes together
203
+ edges.forEach(e => {
204
+ const from = nodes.find(n => n.path === e.from);
205
+ const to = nodes.find(n => n.path === e.to);
206
+ if (!from || !to)
207
+ return;
208
+ const dx = to.x - from.x;
209
+ const dy = to.y - from.y;
210
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
211
+ // Only attract if nodes are far apart
212
+ if (dist > idealEdgeLength) {
213
+ const force = (dist - idealEdgeLength) * attraction * alpha;
214
+ const fx = (dx / dist) * force;
215
+ const fy = (dy / dist) * force;
216
+ from.x += fx;
217
+ from.y += fy;
218
+ to.x -= fx;
219
+ to.y -= fy;
220
+ }
221
+ });
222
+ }
223
+ function resolveCollisions() {
224
+ // Separate overlapping nodes
225
+ for (let i = 0; i < nodes.length; i++) {
226
+ for (let j = i + 1; j < nodes.length; j++) {
227
+ const a = nodes[i];
228
+ const b = nodes[j];
229
+ // Check for overlap using bounding boxes
230
+ const overlapX = MIN_SPACING_X - Math.abs(b.x - a.x);
231
+ const overlapY = MIN_SPACING_Y - Math.abs(b.y - a.y);
232
+ if (overlapX > 0 && overlapY > 0) {
233
+ // Nodes are overlapping - push them apart
234
+ let dx = b.x - a.x;
235
+ let dy = b.y - a.y;
236
+ // Add random offset if exactly overlapping
237
+ if (dx === 0)
238
+ dx = (Math.random() - 0.5) * 2;
239
+ if (dy === 0)
240
+ dy = (Math.random() - 0.5) * 2;
241
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
242
+ // Push apart in the direction of least overlap
243
+ if (overlapX < overlapY) {
244
+ // Push horizontally
245
+ const push = (overlapX / 2 + 5) * Math.sign(dx);
246
+ a.x -= push;
247
+ b.x += push;
248
+ }
249
+ else {
250
+ // Push vertically
251
+ const push = (overlapY / 2 + 5) * Math.sign(dy);
252
+ a.y -= push;
253
+ b.y += push;
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+ function centerLayout() {
260
+ if (nodes.length === 0)
261
+ return;
262
+ // Find bounding box
263
+ let minX = Infinity, maxX = -Infinity;
264
+ let minY = Infinity, maxY = -Infinity;
265
+ nodes.forEach(n => {
266
+ minX = Math.min(minX, n.x);
267
+ maxX = Math.max(maxX, n.x);
268
+ minY = Math.min(minY, n.y);
269
+ maxY = Math.max(maxY, n.y);
270
+ });
271
+ // Center around origin
272
+ const centerX = (minX + maxX) / 2;
273
+ const centerY = (minY + maxY) / 2;
274
+ nodes.forEach(n => {
275
+ n.x -= centerX;
276
+ n.y -= centerY;
277
+ });
278
+ }
279
+ function centerGroupedLayout() {
280
+ if (nodes.length === 0)
281
+ return;
282
+ let minX = Infinity;
283
+ let maxX = -Infinity;
284
+ let minY = Infinity;
285
+ let maxY = -Infinity;
286
+ nodes.forEach(n => {
287
+ minX = Math.min(minX, n.x);
288
+ maxX = Math.max(maxX, n.x);
289
+ minY = Math.min(minY, n.y);
290
+ maxY = Math.max(maxY, n.y);
291
+ });
292
+ const cx = (minX + maxX) / 2;
293
+ const cy = (minY + maxY) / 2;
294
+ nodes.forEach(n => {
295
+ n.x -= cx;
296
+ n.y -= cy;
297
+ });
298
+ if (window.__categoryGroupBoxes) {
299
+ window.__categoryGroupBoxes.forEach(b => {
300
+ b.x -= cx;
301
+ b.y -= cy;
302
+ });
303
+ }
304
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Main entry point for the Godot Project Map Visualizer
3
+ */
4
+ import './usages.js'; // Load usages module for side effects (global functions)
5
+ import { centerOnNodes, draw, fitToView, initCanvas, updateZoomIndicator } from './canvas.js';
6
+ import { buildCategoryList, buildChangesPanel, initEvents, updateStats } from './events.js';
7
+ import { initLayout } from './layout.js';
8
+ import { initModals } from './modals.js';
9
+ import { initPanel } from './panel.js';
10
+ import { addActionEntry, actionLog, gitChangeSummary, nodes, PROJECT_DATA } from './state.js';
11
+ import { connectWebSocket, onActionEvent, sendCommand } from './websocket.js';
12
+ const COMMAND_DISPLAY = {
13
+ create_script_file: (args) => `🆕 Script created: ${shortPath(args.path)}`,
14
+ modify_variable: (args) => {
15
+ const action = args.action || 'update';
16
+ const icon = action === 'add' ? '+' : action === 'delete' ? '−' : '✏️';
17
+ return `${icon} var ${args.name || args.old_name || '?'} ${action === 'add' ? '추가' : action === 'delete' ? '삭제' : '수정'}`;
18
+ },
19
+ modify_function: (args) => `✏️ func ${args.name || '?'}() 수정`,
20
+ modify_function_delete: (args) => `− func ${args.name || '?'}() 삭제`,
21
+ modify_signal: (args) => {
22
+ const action = args.action || 'update';
23
+ const icon = action === 'add' ? '🔗' : action === 'delete' ? '−' : '✏️';
24
+ return `${icon} signal ${args.name || args.old_name || '?'} ${action === 'add' ? '추가' : action === 'delete' ? '삭제' : '수정'}`;
25
+ },
26
+ external_change_detected: (args) => {
27
+ const status = args.status || 'modified';
28
+ const icon = status === 'untracked' ? '🆕' : status === 'added' ? '+' : '✏️';
29
+ return `${icon} external ${status}`;
30
+ },
31
+ modify_script: () => '✏️ Script modified',
32
+ create_script: () => '🆕 Script created'
33
+ };
34
+ function shortPath(p) {
35
+ if (!p)
36
+ return '?';
37
+ const parts = p.replace('res://', '').split('/');
38
+ return parts[parts.length - 1];
39
+ }
40
+ function formatTime(ts) {
41
+ const d = new Date(ts);
42
+ const h = d.getHours().toString().padStart(2, '0');
43
+ const m = d.getMinutes().toString().padStart(2, '0');
44
+ const s = d.getSeconds().toString().padStart(2, '0');
45
+ return `${h}:${m}:${s}`;
46
+ }
47
+ function commandToText(command, args) {
48
+ if (!command)
49
+ return 'Unknown action';
50
+ const formatter = COMMAND_DISPLAY[command];
51
+ if (formatter)
52
+ return formatter(args || {});
53
+ return command.replace(/_/g, ' ');
54
+ }
55
+ /** Normalize server entry format (ts/details) to frontend format (timestamp/args) */
56
+ function normalizeEntry(raw) {
57
+ return {
58
+ command: raw.command || 'unknown',
59
+ args: raw.details || raw.args || {},
60
+ timestamp: raw.ts || raw.timestamp || Date.now(),
61
+ reason: raw.reason || null,
62
+ filePath: raw.filePath || raw.details?.path || raw.args?.path || null
63
+ };
64
+ }
65
+ function initTimeline() {
66
+ renderTimeline();
67
+ const fetchActionLog = (attempt = 0) => {
68
+ sendCommand('get_action_log').then((result) => {
69
+ if (result?.entries) {
70
+ for (const entry of result.entries) {
71
+ addActionEntry(normalizeEntry(entry));
72
+ }
73
+ }
74
+ renderTimeline();
75
+ }).catch((err) => {
76
+ if (err.message === 'WebSocket not connected' && attempt < 10) {
77
+ setTimeout(() => fetchActionLog(attempt + 1), 300);
78
+ return;
79
+ }
80
+ console.log('[timeline] Could not fetch action log:', err.message);
81
+ });
82
+ };
83
+ fetchActionLog();
84
+ onActionEvent((msg) => {
85
+ // Server sends { type: 'action_event', entry: { ts, command, filePath, details, reason } }
86
+ const raw = msg.entry || msg;
87
+ addActionEntry(normalizeEntry(raw));
88
+ renderTimeline();
89
+ });
90
+ }
91
+ function renderTimeline() {
92
+ const list = document.getElementById('tl-list');
93
+ const empty = document.getElementById('tl-empty');
94
+ const count = document.getElementById('tl-count');
95
+ if (!list || !empty || !count)
96
+ return;
97
+ count.textContent = actionLog.length;
98
+ if (actionLog.length === 0) {
99
+ empty.style.display = 'block';
100
+ list.innerHTML = '';
101
+ return;
102
+ }
103
+ empty.style.display = 'none';
104
+ const sorted = [...actionLog].reverse();
105
+ const groups = {};
106
+ for (const entry of sorted) {
107
+ const fp = entry.filePath || entry.args?.path || 'Unknown';
108
+ if (!groups[fp])
109
+ groups[fp] = [];
110
+ groups[fp].push(entry);
111
+ }
112
+ let html = '';
113
+ for (const [filePath, entries] of Object.entries(groups)) {
114
+ const fname = shortPath(filePath);
115
+ const escapedPath = filePath.replace(/'/g, "\\'");
116
+ html += `<div class="tl-card" data-path="${filePath}" onclick="timelineCardClick('${escapedPath}')">`;
117
+ html += '<div class="tl-card-header">';
118
+ html += `<span class="tl-card-file">${fname}</span>`;
119
+ html += `<span class="tl-card-count">${entries.length}</span>`;
120
+ html += '</div>';
121
+ for (const entry of entries) {
122
+ const text = commandToText(entry.command, entry.args);
123
+ const time = formatTime(entry.timestamp);
124
+ html += '<div class="tl-action">';
125
+ html += `<span class="tl-action-text">${text}</span>`;
126
+ html += `<span class="tl-action-time">${time}</span>`;
127
+ if (entry.reason) {
128
+ html += `<div class="tl-action-reason">${entry.reason}</div>`;
129
+ }
130
+ html += '</div>';
131
+ }
132
+ html += '</div>';
133
+ }
134
+ list.innerHTML = html;
135
+ }
136
+ // Initialize everything when DOM is ready
137
+ function init() {
138
+ // Connect WebSocket for real-time communication
139
+ connectWebSocket();
140
+ // Initialize canvas and rendering (also restores saved positions)
141
+ const { positionsRestored } = initCanvas();
142
+ // Initialize panel and modals
143
+ initPanel();
144
+ initModals();
145
+ // Initialize event handlers
146
+ initEvents();
147
+ // Update stats
148
+ updateStats();
149
+ // Build category list UI
150
+ buildCategoryList();
151
+ buildChangesPanel();
152
+ const totalChanges = gitChangeSummary.modified + gitChangeSummary.added + gitChangeSummary.untracked;
153
+ if (totalChanges === 0) {
154
+ const changesPanel = document.getElementById('changes-panel');
155
+ if (changesPanel)
156
+ changesPanel.style.display = 'none';
157
+ }
158
+ initTimeline();
159
+ initTimelineDrag();
160
+ // Hide category panel if no categories
161
+ if (!PROJECT_DATA.categories || PROJECT_DATA.categories.length === 0) {
162
+ const catPanel = document.getElementById('category-panel');
163
+ if (catPanel)
164
+ catPanel.style.display = 'none';
165
+ }
166
+ // Get zoom indicator element
167
+ const zoomIndicator = document.getElementById('zoom-indicator');
168
+ if (nodes.length === 0) {
169
+ // No scripts found - show placeholder
170
+ const ctx = document.getElementById('canvas').getContext('2d');
171
+ const W = window.innerWidth;
172
+ const H = window.innerHeight;
173
+ ctx.font = '18px -apple-system, system-ui, sans-serif';
174
+ ctx.fillStyle = '#484f58';
175
+ ctx.textAlign = 'center';
176
+ ctx.fillText('No scripts found in project', W / 2, H / 2);
177
+ zoomIndicator.style.display = 'none';
178
+ }
179
+ else {
180
+ if (positionsRestored) {
181
+ // Positions were restored from localStorage - just update zoom indicator
182
+ updateZoomIndicator();
183
+ }
184
+ else {
185
+ // No saved positions - run force-directed layout
186
+ initLayout();
187
+ // Fit view to show all nodes
188
+ fitToView(nodes);
189
+ }
190
+ // Initial draw
191
+ draw();
192
+ }
193
+ }
194
+ window.timelineCardClick = function timelineCardClick(filePath) {
195
+ const node = nodes.find(n => n.path === filePath);
196
+ if (node) {
197
+ centerOnNodes([node]);
198
+ draw();
199
+ }
200
+ };
201
+ window.toggleTimelinePanel = function toggleTimelinePanel() {
202
+ const panel = document.getElementById('timeline-panel');
203
+ if (panel) {
204
+ panel.classList.toggle('collapsed');
205
+ }
206
+ };
207
+ // ── Timeline panel drag ──
208
+ function initTimelineDrag() {
209
+ const panel = document.getElementById('timeline-panel');
210
+ const header = panel?.querySelector('.tl-header');
211
+ if (!panel || !header)
212
+ return;
213
+ let dragging = false, offsetX = 0, offsetY = 0;
214
+ header.addEventListener('mousedown', (e) => {
215
+ dragging = true;
216
+ const rect = panel.getBoundingClientRect();
217
+ offsetX = e.clientX - rect.left;
218
+ offsetY = e.clientY - rect.top;
219
+ header.style.cursor = 'grabbing';
220
+ e.preventDefault();
221
+ });
222
+ document.addEventListener('mousemove', (e) => {
223
+ if (!dragging)
224
+ return;
225
+ const x = e.clientX - offsetX;
226
+ const y = e.clientY - offsetY;
227
+ panel.style.bottom = 'auto';
228
+ panel.style.right = 'auto';
229
+ panel.style.left = Math.max(0, Math.min(x, window.innerWidth - panel.offsetWidth)) + 'px';
230
+ panel.style.top = Math.max(0, Math.min(y, window.innerHeight - panel.offsetHeight)) + 'px';
231
+ });
232
+ document.addEventListener('mouseup', () => {
233
+ if (dragging) {
234
+ dragging = false;
235
+ header.style.cursor = '';
236
+ }
237
+ });
238
+ }
239
+ // Start when DOM is loaded
240
+ if (document.readyState === 'loading') {
241
+ document.addEventListener('DOMContentLoaded', init);
242
+ }
243
+ else {
244
+ init();
245
+ }