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,832 @@
1
+ /**
2
+ * Canvas rendering, camera controls, and drawing utilities
3
+ */
4
+ import { nodes, edges, NODE_W, NODE_H, camera, defaultZoom, W, H, setDimensions, searchTerm, hoveredNode, selectedNode, currentView, sceneData, expandedScene, expandedSceneHierarchy, selectedSceneNode, hoveredSceneNode, scenePositions, setExpandedScene, setSelectedSceneNode, setHoveredSceneNode, setScenePosition, scriptToScenes, categoryGroupMode, categories, activeCategories, categoryColorMap, changesVisible } from './state.js';
5
+ let canvas, ctx;
6
+ let zoomIndicator, zoomText;
7
+ let dpr = 1; // Device pixel ratio
8
+ // Storage key for position persistence
9
+ const STORAGE_KEY = 'godot-visualizer-positions';
10
+ export function initCanvas() {
11
+ canvas = document.getElementById('canvas');
12
+ ctx = canvas.getContext('2d');
13
+ zoomIndicator = document.getElementById('zoom-indicator');
14
+ zoomText = document.getElementById('zoom-text');
15
+ // Get device pixel ratio for crisp rendering on high-DPI displays
16
+ dpr = window.devicePixelRatio || 1;
17
+ resize();
18
+ const positionsRestored = loadPositions(); // Restore saved positions
19
+ return { canvas, ctx, positionsRestored };
20
+ }
21
+ export function getDpr() {
22
+ return dpr;
23
+ }
24
+ export function getCanvas() {
25
+ return canvas;
26
+ }
27
+ export function getContext() {
28
+ return ctx;
29
+ }
30
+ export function resize() {
31
+ const w = window.innerWidth;
32
+ const h = window.innerHeight;
33
+ setDimensions(w, h);
34
+ // Update DPR in case it changed (e.g., moving window between displays)
35
+ dpr = window.devicePixelRatio || 1;
36
+ // Set canvas size accounting for device pixel ratio for crisp rendering
37
+ canvas.width = w * dpr;
38
+ canvas.height = h * dpr;
39
+ // Scale canvas back to CSS size
40
+ canvas.style.width = w + 'px';
41
+ canvas.style.height = h + 'px';
42
+ // Scale context to account for DPR
43
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
44
+ }
45
+ export function screenToWorld(sx, sy) {
46
+ return {
47
+ x: (sx - W / 2) / camera.zoom + camera.x,
48
+ y: (sy - H / 2) / camera.zoom + camera.y
49
+ };
50
+ }
51
+ export function updateZoomIndicator() {
52
+ const pct = Math.round(camera.zoom * 100);
53
+ zoomText.value = pct + '%';
54
+ zoomIndicator.classList.toggle('faded', Math.abs(camera.zoom - defaultZoom) < 0.01);
55
+ }
56
+ export function resetZoom() {
57
+ camera.zoom = defaultZoom;
58
+ updateZoomIndicator();
59
+ draw();
60
+ }
61
+ export function setCustomZoom(value) {
62
+ // Parse percentage string like "150%" or just "150" or "1.5"
63
+ let parsed = parseFloat(value.replace('%', '').trim());
64
+ if (isNaN(parsed))
65
+ return;
66
+ // If user entered a small number like 1.5, treat as multiplier
67
+ if (parsed > 0 && parsed < 10) {
68
+ parsed = parsed * 100;
69
+ }
70
+ // Clamp to valid range (10% - 500%)
71
+ const newZoom = Math.max(0.1, Math.min(5, parsed / 100));
72
+ camera.zoom = newZoom;
73
+ updateZoomIndicator();
74
+ draw();
75
+ }
76
+ // Make functions available globally for onclick
77
+ window.resetZoom = resetZoom;
78
+ window.setCustomZoom = setCustomZoom;
79
+ // ---- Position Persistence ----
80
+ export function savePositions() {
81
+ try {
82
+ const positions = {};
83
+ nodes.forEach(n => {
84
+ positions[n.path] = { x: n.x, y: n.y };
85
+ });
86
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
87
+ positions,
88
+ camera: { x: camera.x, y: camera.y, zoom: camera.zoom }
89
+ }));
90
+ }
91
+ catch (e) {
92
+ console.warn('Failed to save positions:', e);
93
+ }
94
+ }
95
+ export function loadPositions() {
96
+ try {
97
+ const saved = localStorage.getItem(STORAGE_KEY);
98
+ if (!saved)
99
+ return false;
100
+ const data = JSON.parse(saved);
101
+ let restored = 0;
102
+ if (data.positions) {
103
+ nodes.forEach(n => {
104
+ if (data.positions[n.path]) {
105
+ n.x = data.positions[n.path].x;
106
+ n.y = data.positions[n.path].y;
107
+ restored++;
108
+ }
109
+ });
110
+ }
111
+ if (data.camera && restored > 0) {
112
+ camera.x = data.camera.x;
113
+ camera.y = data.camera.y;
114
+ camera.zoom = data.camera.zoom;
115
+ // Don't change defaultZoom - keep it at 1 (100%) so reset always goes to 100%
116
+ }
117
+ return restored > 0;
118
+ }
119
+ catch (e) {
120
+ console.warn('Failed to load positions:', e);
121
+ return false;
122
+ }
123
+ }
124
+ export function clearPositions() {
125
+ try {
126
+ localStorage.removeItem(STORAGE_KEY);
127
+ }
128
+ catch (e) {
129
+ console.warn('Failed to clear positions:', e);
130
+ }
131
+ }
132
+ // Save positions when node is moved
133
+ export function onNodeMoved() {
134
+ savePositions();
135
+ }
136
+ // ---- Drawing ----
137
+ export function draw() {
138
+ if (currentView === 'scenes') {
139
+ drawSceneView();
140
+ return;
141
+ }
142
+ // Ensure DPR transform is set for crisp rendering on high-DPI displays
143
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
144
+ // Use crisp line rendering
145
+ ctx.lineCap = 'round';
146
+ ctx.lineJoin = 'round';
147
+ ctx.clearRect(0, 0, W, H);
148
+ ctx.save();
149
+ ctx.translate(Math.round(W / 2), Math.round(H / 2));
150
+ ctx.scale(camera.zoom, camera.zoom);
151
+ ctx.translate(-camera.x, -camera.y);
152
+ // Build path index for quick lookup
153
+ const pathIdx = {};
154
+ nodes.forEach((n, i) => {
155
+ pathIdx[n.path] = i;
156
+ });
157
+ // Group edges by node pair, type, and direction for bundled drawing
158
+ const edgeGroups = {};
159
+ for (const e of edges) {
160
+ const si = pathIdx[e.from], ti = pathIdx[e.to];
161
+ if (si === undefined || ti === undefined)
162
+ continue;
163
+ // Keep direction (A->B is different from B->A)
164
+ const key = `${si}-${ti}-${e.type}`;
165
+ if (!edgeGroups[key]) {
166
+ edgeGroups[key] = { from: e.from, to: e.to, type: e.type, edges: [], si, ti };
167
+ }
168
+ edgeGroups[key].edges.push(e);
169
+ }
170
+ // Draw bundled edges
171
+ for (const key of Object.keys(edgeGroups)) {
172
+ const group = edgeGroups[key];
173
+ const s = nodes[group.si], t = nodes[group.ti];
174
+ const count = group.edges.length;
175
+ if (s.categoryVisible === false || t.categoryVisible === false)
176
+ continue;
177
+ if (searchTerm && s.visible === false && t.visible === false)
178
+ continue;
179
+ // Dim edges when one node is hidden, or when neither is highlighted
180
+ const bothVisible = s.visible !== false && t.visible !== false;
181
+ ctx.globalAlpha = (!bothVisible || (!s.highlighted && !t.highlighted)) ? 0.08 : 0.5;
182
+ // Calculate perpendicular offset for multiple edge types between same nodes
183
+ const angle = Math.atan2(t.y - s.y, t.x - s.x);
184
+ const perpAngle = angle + Math.PI / 2;
185
+ // Get offset based on edge type (so different types don't overlap)
186
+ const typeOffset = group.type === 'extends' ? 0 : group.type === 'preload' ? 8 : 16;
187
+ const offsetX = Math.cos(perpAngle) * typeOffset;
188
+ const offsetY = Math.sin(perpAngle) * typeOffset;
189
+ ctx.beginPath();
190
+ ctx.moveTo(s.x + offsetX, s.y + offsetY);
191
+ ctx.lineTo(t.x + offsetX, t.y + offsetY);
192
+ // Line widths scale with zoom (fixed world-space size)
193
+ if (group.type === 'extends') {
194
+ ctx.strokeStyle = '#7aa2f7';
195
+ ctx.setLineDash([]);
196
+ ctx.lineWidth = 2;
197
+ }
198
+ else if (group.type === 'preload') {
199
+ ctx.strokeStyle = '#d4a27f';
200
+ ctx.setLineDash([]);
201
+ ctx.lineWidth = 1.5;
202
+ }
203
+ else {
204
+ ctx.strokeStyle = '#a6e3a1';
205
+ ctx.setLineDash([4, 4]);
206
+ ctx.lineWidth = 1.5;
207
+ }
208
+ ctx.stroke();
209
+ ctx.setLineDash([]);
210
+ // Arrow at midpoint - fixed world-space size
211
+ const al = 10;
212
+ const mx = (s.x + t.x) / 2 + offsetX, my = (s.y + t.y) / 2 + offsetY;
213
+ ctx.beginPath();
214
+ ctx.moveTo(mx + Math.cos(angle) * al, my + Math.sin(angle) * al);
215
+ ctx.lineTo(mx + Math.cos(angle + 2.5) * al * 0.6, my + Math.sin(angle + 2.5) * al * 0.6);
216
+ ctx.lineTo(mx + Math.cos(angle - 2.5) * al * 0.6, my + Math.sin(angle - 2.5) * al * 0.6);
217
+ ctx.closePath();
218
+ ctx.fillStyle = ctx.strokeStyle;
219
+ ctx.fill();
220
+ // Draw count badge if multiple connections of same type
221
+ if (count > 1) {
222
+ const badgeX = mx + Math.cos(perpAngle) * 12;
223
+ const badgeY = my + Math.sin(perpAngle) * 12;
224
+ const badgeSize = 16;
225
+ ctx.globalAlpha = bothVisible ? 0.9 : 0.3;
226
+ ctx.beginPath();
227
+ ctx.arc(badgeX, badgeY, badgeSize / 2, 0, Math.PI * 2);
228
+ ctx.fillStyle = ctx.strokeStyle;
229
+ ctx.fill();
230
+ // Count text - scales with zoom
231
+ ctx.fillStyle = '#08090a';
232
+ ctx.font = `bold 10px -apple-system, system-ui, sans-serif`;
233
+ ctx.textAlign = 'center';
234
+ ctx.textBaseline = 'middle';
235
+ ctx.fillText(count.toString(), badgeX, badgeY);
236
+ }
237
+ }
238
+ ctx.globalAlpha = 1;
239
+ if (categoryGroupMode === 'grouped' && window.__categoryGroupBoxes) {
240
+ for (const box of window.__categoryGroupBoxes) {
241
+ const catInfo = categories.find(c => c.id === box.category);
242
+ if (!catInfo)
243
+ continue;
244
+ if (!activeCategories.has(box.category))
245
+ continue;
246
+ ctx.globalAlpha = 0.08;
247
+ ctx.beginPath();
248
+ roundRect(ctx, box.x, box.y, box.w, box.h, 16);
249
+ ctx.fillStyle = catInfo.color || categoryColorMap[box.category] || '#7aa2f7';
250
+ ctx.fill();
251
+ ctx.globalAlpha = 0.25;
252
+ ctx.strokeStyle = catInfo.color || categoryColorMap[box.category] || '#7aa2f7';
253
+ ctx.lineWidth = 1.5;
254
+ ctx.setLineDash([6, 4]);
255
+ ctx.stroke();
256
+ ctx.setLineDash([]);
257
+ ctx.globalAlpha = 0.9;
258
+ ctx.font = 'bold 14px -apple-system, system-ui, sans-serif';
259
+ ctx.fillStyle = catInfo.color || categoryColorMap[box.category] || '#7aa2f7';
260
+ ctx.textAlign = 'left';
261
+ ctx.textBaseline = 'top';
262
+ ctx.fillText(`${catInfo.label} (${catInfo.count})`, Math.round(box.x + 12), Math.round(box.y + 10));
263
+ ctx.globalAlpha = 1;
264
+ }
265
+ }
266
+ // Draw nodes
267
+ for (const n of nodes) {
268
+ if (n.categoryVisible === false)
269
+ continue;
270
+ // Skip hidden nodes during search
271
+ if (searchTerm && n.visible === false)
272
+ continue;
273
+ // Round coordinates for crisper rendering
274
+ const x = Math.round(n.x - NODE_W / 2);
275
+ const y = Math.round(n.y - NODE_H / 2);
276
+ const isHovered = n === hoveredNode, isSelected = n === selectedNode;
277
+ ctx.globalAlpha = n.highlighted ? 1 : 0.12;
278
+ // Shadow - fixed world-space size
279
+ const hasGitGlow = changesVisible && n.gitStatus;
280
+ if (hasGitGlow) {
281
+ const glowColors = {
282
+ modified: 'rgba(249, 226, 175, 0.4)',
283
+ added: 'rgba(166, 227, 161, 0.4)',
284
+ untracked: 'rgba(137, 180, 250, 0.3)'
285
+ };
286
+ ctx.shadowColor = glowColors[n.gitStatus] || 'rgba(0,0,0,0.4)';
287
+ ctx.shadowBlur = isHovered ? 20 : 12;
288
+ }
289
+ else {
290
+ ctx.shadowColor = 'rgba(0,0,0,0.4)';
291
+ ctx.shadowBlur = isHovered ? 16 : 8;
292
+ }
293
+ ctx.shadowOffsetY = 2;
294
+ // Background
295
+ ctx.beginPath();
296
+ roundRect(ctx, x, y, NODE_W, NODE_H, 10);
297
+ ctx.fillStyle = isSelected ? '#1e2028' : isHovered ? '#181a20' : '#0f1014';
298
+ ctx.fill();
299
+ ctx.shadowBlur = 0;
300
+ ctx.shadowOffsetY = 0;
301
+ // Border - fixed world-space width
302
+ if (changesVisible && n.gitStatus) {
303
+ const gitBorderColors = { modified: '#f9e2af', added: '#a6e3a1', untracked: '#89b4fa' };
304
+ ctx.strokeStyle = isSelected ? n.color : gitBorderColors[n.gitStatus] || 'rgba(255,255,255,0.06)';
305
+ ctx.lineWidth = isSelected ? 2 : 1.5;
306
+ if (n.gitStatus === 'untracked') {
307
+ ctx.setLineDash([4, 3]);
308
+ }
309
+ }
310
+ else {
311
+ ctx.strokeStyle = isSelected ? n.color : isHovered ? n.color : 'rgba(255,255,255,0.06)';
312
+ ctx.lineWidth = isSelected ? 2 : 1;
313
+ }
314
+ ctx.stroke();
315
+ ctx.setLineDash([]);
316
+ // Left accent bar
317
+ ctx.beginPath();
318
+ ctx.roundRect(x + 4, y + 8, 3, NODE_H - 16, 2);
319
+ ctx.fillStyle = n.color;
320
+ ctx.fill();
321
+ // Title - scales with node (no zoom compensation)
322
+ const titleSize = 14;
323
+ ctx.font = `600 ${titleSize}px -apple-system, system-ui, sans-serif`;
324
+ ctx.fillStyle = '#f0f0f5';
325
+ ctx.textBaseline = 'middle';
326
+ ctx.textAlign = 'left';
327
+ const displayName = n.class_name || n.filename.replace('.gd', '');
328
+ ctx.fillText(displayName, x + 16, y + NODE_H / 2 - 6);
329
+ // Subtitle with colored stats - scales with node
330
+ const subSize = 11;
331
+ const varCount = n.variables ? n.variables.length : 0;
332
+ const funcCount = n.functions ? n.functions.length : 0;
333
+ const sigCount = n.signals ? n.signals.length : 0;
334
+ // Draw subtitle parts with colors
335
+ ctx.font = `${subSize}px -apple-system, system-ui, sans-serif`;
336
+ const subY = y + NODE_H / 2 + 9;
337
+ let subX = x + 16;
338
+ // Extends
339
+ ctx.fillStyle = '#484f58';
340
+ const extendsText = (n.extends || 'Node') + ' · ';
341
+ ctx.fillText(extendsText, subX, subY);
342
+ subX += ctx.measureText(extendsText).width;
343
+ // Functions (cyan/teal)
344
+ ctx.fillStyle = '#89dceb';
345
+ ctx.fillText(funcCount + 'f', subX, subY);
346
+ subX += ctx.measureText(funcCount + 'f').width;
347
+ // Space
348
+ ctx.fillStyle = '#484f58';
349
+ ctx.fillText(' ', subX, subY);
350
+ subX += ctx.measureText(' ').width;
351
+ // Variables (purple)
352
+ ctx.fillStyle = '#cba6f7';
353
+ ctx.fillText(varCount + 'v', subX, subY);
354
+ subX += ctx.measureText(varCount + 'v').width;
355
+ // Space
356
+ ctx.fillStyle = '#484f58';
357
+ ctx.fillText(' ', subX, subY);
358
+ subX += ctx.measureText(' ').width;
359
+ // Signals (green)
360
+ ctx.fillStyle = '#a6e3a1';
361
+ ctx.fillText(sigCount + 's', subX, subY);
362
+ subX += ctx.measureText(sigCount + 's').width;
363
+ // Separator
364
+ ctx.fillStyle = '#484f58';
365
+ ctx.fillText(' · ', subX, subY);
366
+ subX += ctx.measureText(' · ').width;
367
+ // Lines (yellow/amber)
368
+ ctx.fillStyle = '#f9e2af';
369
+ ctx.fillText(n.line_count + 'L', subX, subY);
370
+ // Scene usage badge (top-right corner)
371
+ const usedInScenes = scriptToScenes[n.path];
372
+ if (usedInScenes && usedInScenes.length > 0) {
373
+ const badgeX = x + NODE_W - 8;
374
+ const badgeY = y + 8;
375
+ ctx.fillStyle = 'rgba(166, 227, 161, 0.2)';
376
+ ctx.beginPath();
377
+ ctx.roundRect(badgeX - 20, badgeY - 4, 24, 14, 3);
378
+ ctx.fill();
379
+ ctx.fillStyle = '#a6e3a1';
380
+ ctx.font = `600 9px -apple-system, system-ui, sans-serif`;
381
+ ctx.textAlign = 'right';
382
+ ctx.fillText('📦' + usedInScenes.length, badgeX, badgeY + 4);
383
+ ctx.textAlign = 'left';
384
+ }
385
+ if (changesVisible && n.gitStatus) {
386
+ const badgeConfigs = {
387
+ modified: { bg: 'rgba(249, 226, 175, 0.25)', fg: '#f9e2af', label: 'M' },
388
+ added: { bg: 'rgba(166, 227, 161, 0.25)', fg: '#a6e3a1', label: '+' },
389
+ untracked: { bg: 'rgba(137, 180, 250, 0.25)', fg: '#89b4fa', label: '?' }
390
+ };
391
+ const badge = badgeConfigs[n.gitStatus];
392
+ if (badge) {
393
+ const bx = x + 8;
394
+ const by = y + 8;
395
+ ctx.fillStyle = badge.bg;
396
+ ctx.beginPath();
397
+ ctx.roundRect(bx - 2, by - 4, 18, 14, 3);
398
+ ctx.fill();
399
+ ctx.fillStyle = badge.fg;
400
+ ctx.font = '600 9px -apple-system, system-ui, sans-serif';
401
+ ctx.textAlign = 'left';
402
+ ctx.fillText(badge.label, bx + 2, by + 5);
403
+ }
404
+ }
405
+ }
406
+ ctx.globalAlpha = 1;
407
+ ctx.restore();
408
+ }
409
+ // Scene view constants
410
+ const SCENE_CARD_W = 200; // Match NODE_W
411
+ const SCENE_CARD_H = 54; // Match NODE_H
412
+ const SCENE_NODE_MIN_W = 80; // Minimum node width
413
+ const SCENE_NODE_MAX_W = 200; // Maximum node width
414
+ const SCENE_NODE_H = 36;
415
+ const SCENE_NODE_GAP_X = 15; // Reduced for tighter layout
416
+ const SCENE_NODE_GAP_Y = 40;
417
+ // Calculate dynamic node width based on name
418
+ function calculateNodeWidth(name) {
419
+ // Approximate width: ~7px per character + padding
420
+ const textWidth = (name || 'Node').length * 7;
421
+ const padding = 35; // For script icon and margins
422
+ return Math.min(SCENE_NODE_MAX_W, Math.max(SCENE_NODE_MIN_W, textWidth + padding));
423
+ }
424
+ function drawSceneView() {
425
+ // Ensure DPR transform is set for crisp rendering on high-DPI displays
426
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
427
+ ctx.lineCap = 'round';
428
+ ctx.lineJoin = 'round';
429
+ ctx.clearRect(0, 0, W, H);
430
+ ctx.save();
431
+ ctx.translate(Math.round(W / 2), Math.round(H / 2));
432
+ ctx.scale(camera.zoom, camera.zoom);
433
+ ctx.translate(-camera.x, -camera.y);
434
+ if (!sceneData || !sceneData.scenes || sceneData.scenes.length === 0) {
435
+ drawSceneViewPlaceholder();
436
+ ctx.restore();
437
+ return;
438
+ }
439
+ // Check if we're in expanded mode
440
+ if (expandedScene && expandedSceneHierarchy) {
441
+ drawExpandedSceneView();
442
+ }
443
+ else {
444
+ drawSceneOverview();
445
+ }
446
+ ctx.restore();
447
+ }
448
+ function drawSceneOverview() {
449
+ const scenes = sceneData.scenes;
450
+ // Calculate positions if not set
451
+ scenes.forEach((scene, i) => {
452
+ if (!scenePositions[scene.path]) {
453
+ const cols = Math.max(1, Math.floor(Math.sqrt(scenes.length * 1.5)));
454
+ setScenePosition(scene.path, (i % cols) * (SCENE_CARD_W + 40) - ((cols - 1) * (SCENE_CARD_W + 40)) / 2, Math.floor(i / cols) * (SCENE_CARD_H + 30) - 100);
455
+ }
456
+ });
457
+ // Draw edges between scenes (instance relationships)
458
+ if (sceneData.edges) {
459
+ ctx.globalAlpha = 0.4;
460
+ for (const edge of sceneData.edges) {
461
+ const fromScene = scenes.find(s => s.path === edge.from);
462
+ const toScene = scenes.find(s => s.path === edge.to);
463
+ if (!fromScene || !toScene)
464
+ continue;
465
+ const fromPos = scenePositions[edge.from];
466
+ const toPos = scenePositions[edge.to];
467
+ if (!fromPos || !toPos)
468
+ continue;
469
+ const fromX = fromPos.x + SCENE_CARD_W / 2;
470
+ const fromY = fromPos.y + SCENE_CARD_H;
471
+ const toX = toPos.x + SCENE_CARD_W / 2;
472
+ const toY = toPos.y;
473
+ ctx.beginPath();
474
+ ctx.moveTo(fromX, fromY);
475
+ ctx.lineTo(toX, toY);
476
+ ctx.strokeStyle = '#89dceb';
477
+ ctx.lineWidth = 1.5;
478
+ ctx.setLineDash([4, 4]);
479
+ ctx.stroke();
480
+ ctx.setLineDash([]);
481
+ }
482
+ ctx.globalAlpha = 1;
483
+ }
484
+ // Draw scene cards
485
+ scenes.forEach((scene, i) => {
486
+ const pos = scenePositions[scene.path];
487
+ const x = pos.x;
488
+ const y = pos.y;
489
+ const isHovered = hoveredSceneNode && hoveredSceneNode.scenePath === scene.path && !hoveredSceneNode.nodePath;
490
+ const isExpanded = expandedScene === scene.path;
491
+ const sceneColor = getSceneColor(scene.path);
492
+ // Shadow - match script node styling
493
+ ctx.shadowColor = 'rgba(0,0,0,0.4)';
494
+ ctx.shadowBlur = isHovered ? 16 : 8;
495
+ ctx.shadowOffsetY = 2;
496
+ // Scene card background - match script node colors
497
+ ctx.beginPath();
498
+ roundRect(ctx, x, y, SCENE_CARD_W, SCENE_CARD_H, 10);
499
+ ctx.fillStyle = isExpanded ? '#1e2028' : isHovered ? '#181a20' : '#0f1014';
500
+ ctx.fill();
501
+ ctx.shadowBlur = 0;
502
+ ctx.shadowOffsetY = 0;
503
+ // Border - match script node styling
504
+ ctx.strokeStyle = isExpanded ? sceneColor : isHovered ? sceneColor : 'rgba(255,255,255,0.06)';
505
+ ctx.lineWidth = isExpanded ? 2 : 1;
506
+ ctx.stroke();
507
+ // Left accent bar (scene color)
508
+ ctx.beginPath();
509
+ ctx.roundRect(x + 4, y + 8, 3, SCENE_CARD_H - 16, 2);
510
+ ctx.fillStyle = sceneColor;
511
+ ctx.fill();
512
+ // Scene name (main label)
513
+ ctx.fillStyle = '#f0f0f5';
514
+ ctx.font = `600 13px -apple-system, system-ui, sans-serif`;
515
+ ctx.textAlign = 'left';
516
+ const sceneName = scene.name || scene.path.split('/').pop().replace('.tscn', '');
517
+ ctx.fillText(sceneName, x + 14, y + 22);
518
+ // Root type and stats on second line
519
+ const nodeCount = scene.node_count || (scene.nodes ? scene.nodes.length : 0);
520
+ ctx.fillStyle = '#484f58';
521
+ ctx.font = `11px -apple-system, system-ui, sans-serif`;
522
+ ctx.fillText(`${scene.root_type || 'Node'} · ${nodeCount} nodes`, x + 14, y + 40);
523
+ });
524
+ }
525
+ function drawExpandedSceneView() {
526
+ const hierarchy = expandedSceneHierarchy;
527
+ if (!hierarchy)
528
+ return;
529
+ // Draw back button area (handled by HTML overlay)
530
+ // Draw the node tree
531
+ const treeLayout = calculateTreeLayout(hierarchy);
532
+ // Draw connection lines first
533
+ drawTreeConnections(treeLayout.nodes);
534
+ // Draw nodes
535
+ for (const node of treeLayout.nodes) {
536
+ drawSceneNode(node);
537
+ }
538
+ }
539
+ function calculateTreeLayout(hierarchy) {
540
+ const nodes = [];
541
+ const LEVEL_HEIGHT = SCENE_NODE_H + SCENE_NODE_GAP_Y;
542
+ // Simple layout: each node positions its children directly below,
543
+ // centered on itself, without considering grandchildren widths
544
+ function processNode(node, depth, centerX) {
545
+ const nodeWidth = calculateNodeWidth(node.name);
546
+ const x = centerX - nodeWidth / 2;
547
+ const y = depth * LEVEL_HEIGHT;
548
+ const nodeLayout = {
549
+ ...node,
550
+ x,
551
+ y,
552
+ width: nodeWidth,
553
+ height: SCENE_NODE_H,
554
+ childPositions: []
555
+ };
556
+ nodes.push(nodeLayout);
557
+ // Layout children centered under this node
558
+ if (node.children && node.children.length > 0) {
559
+ // Calculate total width of direct children only
560
+ let totalChildrenWidth = 0;
561
+ for (const child of node.children) {
562
+ totalChildrenWidth += calculateNodeWidth(child.name) + SCENE_NODE_GAP_X;
563
+ }
564
+ totalChildrenWidth -= SCENE_NODE_GAP_X; // Remove last gap
565
+ // Start children centered under parent
566
+ let childX = centerX - totalChildrenWidth / 2;
567
+ for (const child of node.children) {
568
+ const childWidth = calculateNodeWidth(child.name);
569
+ const childCenterX = childX + childWidth / 2;
570
+ nodeLayout.childPositions.push({
571
+ x: childCenterX,
572
+ y: (depth + 1) * LEVEL_HEIGHT
573
+ });
574
+ processNode(child, depth + 1, childCenterX);
575
+ childX += childWidth + SCENE_NODE_GAP_X;
576
+ }
577
+ }
578
+ return nodeLayout;
579
+ }
580
+ processNode(hierarchy, 0, 0);
581
+ return { nodes };
582
+ }
583
+ function drawTreeConnections(nodes) {
584
+ ctx.strokeStyle = '#484f58';
585
+ ctx.lineWidth = 1.5;
586
+ ctx.setLineDash([]);
587
+ for (const node of nodes) {
588
+ if (node.childPositions && node.childPositions.length > 0) {
589
+ const parentX = node.x + node.width / 2;
590
+ const parentY = node.y + SCENE_NODE_H;
591
+ for (const childPos of node.childPositions) {
592
+ ctx.beginPath();
593
+ ctx.moveTo(parentX, parentY);
594
+ // Draw an elbow connector
595
+ const midY = parentY + (childPos.y - parentY) / 2;
596
+ ctx.lineTo(parentX, midY);
597
+ ctx.lineTo(childPos.x, midY);
598
+ ctx.lineTo(childPos.x, childPos.y);
599
+ ctx.stroke();
600
+ }
601
+ }
602
+ }
603
+ }
604
+ function drawSceneNode(node) {
605
+ const x = node.x;
606
+ const y = node.y;
607
+ const w = node.width;
608
+ const isSelected = selectedSceneNode && selectedSceneNode.path === node.path;
609
+ const isHovered = hoveredSceneNode && hoveredSceneNode.nodePath === node.path;
610
+ const isHighlighted = node.highlighted !== false; // Default to true if not set
611
+ // Node type color
612
+ const nodeColor = getNodeTypeColor(node.type);
613
+ // Dim non-highlighted nodes when searching
614
+ ctx.globalAlpha = isHighlighted ? 1 : 0.25;
615
+ // Shadow
616
+ ctx.shadowColor = 'rgba(0,0,0,0.25)';
617
+ ctx.shadowBlur = isHovered ? 12 : 6;
618
+ ctx.shadowOffsetY = 2;
619
+ // Background - highlight matching nodes with a glow
620
+ ctx.beginPath();
621
+ roundRect(ctx, x, y, w, SCENE_NODE_H, 6);
622
+ ctx.fillStyle = isSelected ? '#1e2028' : isHovered ? '#181a20' : '#0f1014';
623
+ ctx.fill();
624
+ ctx.shadowBlur = 0;
625
+ ctx.shadowOffsetY = 0;
626
+ // Border - use accent color for highlighted search results
627
+ const borderColor = isSelected ? nodeColor : isHovered ? nodeColor :
628
+ (isHighlighted && searchTerm ? '#f9e2af' : 'rgba(255,255,255,0.06)');
629
+ ctx.strokeStyle = borderColor;
630
+ ctx.lineWidth = (isSelected || (isHighlighted && searchTerm)) ? 2 : 1;
631
+ ctx.stroke();
632
+ // Left accent
633
+ ctx.beginPath();
634
+ ctx.roundRect(x + 3, y + 6, 2, SCENE_NODE_H - 12, 1);
635
+ ctx.fillStyle = nodeColor;
636
+ ctx.fill();
637
+ // Node name
638
+ ctx.fillStyle = '#f0f0f5';
639
+ ctx.font = `600 11px -apple-system, system-ui, sans-serif`;
640
+ ctx.textAlign = 'left';
641
+ ctx.textBaseline = 'middle';
642
+ const displayName = node.name || 'Node';
643
+ ctx.fillText(displayName, x + 10, y + SCENE_NODE_H / 2 - 4);
644
+ // Node type (smaller, below name)
645
+ ctx.fillStyle = '#484f58';
646
+ ctx.font = `9px -apple-system, system-ui, sans-serif`;
647
+ ctx.fillText(node.type, x + 10, y + SCENE_NODE_H / 2 + 7);
648
+ // Script indicator
649
+ if (node.script) {
650
+ ctx.fillStyle = '#a6e3a1';
651
+ ctx.font = `10px -apple-system, system-ui, sans-serif`;
652
+ ctx.textAlign = 'right';
653
+ ctx.fillText('📜', x + w - 6, y + SCENE_NODE_H / 2);
654
+ ctx.textAlign = 'left';
655
+ }
656
+ // Sibling index indicator (for node order)
657
+ if (node.index !== undefined && node.index > 0) {
658
+ ctx.fillStyle = '#484f58';
659
+ ctx.font = `9px -apple-system, system-ui, sans-serif`;
660
+ ctx.textAlign = 'right';
661
+ ctx.fillText(`#${node.index}`, x + w - 6, y + 10);
662
+ ctx.textAlign = 'left';
663
+ }
664
+ // Reset alpha
665
+ ctx.globalAlpha = 1;
666
+ }
667
+ function getSceneColor(scenePath) {
668
+ // Generate consistent color based on path
669
+ const colors = ['#89dceb', '#a6e3a1', '#f9e2af', '#cba6f7', '#f38ba8', '#fab387'];
670
+ let hash = 0;
671
+ for (let i = 0; i < scenePath.length; i++) {
672
+ hash = scenePath.charCodeAt(i) + ((hash << 5) - hash);
673
+ }
674
+ return colors[Math.abs(hash) % colors.length];
675
+ }
676
+ function getNodeTypeColor(nodeType) {
677
+ // Godot's actual node type colors
678
+ const GODOT_GREEN = '#8eef97'; // Control/UI nodes
679
+ const GODOT_BLUE = '#8da5f3'; // Node2D nodes
680
+ const GODOT_RED = '#fc7f7f'; // Node3D nodes
681
+ const GODOT_GRAY = '#b2b2b2'; // Base Node
682
+ // Control/UI nodes (green)
683
+ const controlTypes = [
684
+ 'Control', 'Label', 'Button', 'LineEdit', 'TextEdit', 'RichTextLabel',
685
+ 'Panel', 'PanelContainer', 'Container', 'BoxContainer', 'VBoxContainer',
686
+ 'HBoxContainer', 'GridContainer', 'MarginContainer', 'ScrollContainer',
687
+ 'TabContainer', 'ProgressBar', 'TextureRect', 'ColorRect', 'NinePatchRect',
688
+ 'CheckBox', 'CheckButton', 'OptionButton', 'SpinBox', 'Slider', 'HSlider',
689
+ 'VSlider', 'Tree', 'ItemList', 'MenuButton', 'LinkButton', 'CanvasLayer'
690
+ ];
691
+ // Node2D nodes (blue)
692
+ const node2DTypes = [
693
+ 'Node2D', 'Sprite2D', 'AnimatedSprite2D', 'CharacterBody2D', 'RigidBody2D',
694
+ 'StaticBody2D', 'Area2D', 'CollisionShape2D', 'CollisionPolygon2D',
695
+ 'Camera2D', 'Path2D', 'PathFollow2D', 'Line2D', 'Polygon2D', 'TileMap',
696
+ 'TileMapLayer', 'Marker2D', 'RemoteTransform2D', 'VisibleOnScreenNotifier2D',
697
+ 'GPUParticles2D', 'CPUParticles2D', 'LightOccluder2D', 'PointLight2D',
698
+ 'DirectionalLight2D', 'AudioStreamPlayer2D', 'NavigationRegion2D'
699
+ ];
700
+ // Node3D nodes (red)
701
+ const node3DTypes = [
702
+ 'Node3D', 'Sprite3D', 'AnimatedSprite3D', 'CharacterBody3D', 'RigidBody3D',
703
+ 'StaticBody3D', 'Area3D', 'CollisionShape3D', 'CollisionPolygon3D',
704
+ 'Camera3D', 'MeshInstance3D', 'MultiMeshInstance3D', 'CSGBox3D',
705
+ 'CSGCylinder3D', 'CSGSphere3D', 'CSGMesh3D', 'Path3D', 'PathFollow3D',
706
+ 'GPUParticles3D', 'CPUParticles3D', 'OmniLight3D', 'SpotLight3D',
707
+ 'DirectionalLight3D', 'AudioStreamPlayer3D', 'NavigationRegion3D'
708
+ ];
709
+ // Check exact matches first, then partial
710
+ for (const type of controlTypes) {
711
+ if (nodeType === type || nodeType.includes(type))
712
+ return GODOT_GREEN;
713
+ }
714
+ for (const type of node2DTypes) {
715
+ if (nodeType === type || nodeType.includes(type))
716
+ return GODOT_BLUE;
717
+ }
718
+ for (const type of node3DTypes) {
719
+ if (nodeType === type || nodeType.includes(type))
720
+ return GODOT_RED;
721
+ }
722
+ // Fallback: check for 2D/3D suffix
723
+ if (nodeType.endsWith('2D'))
724
+ return GODOT_BLUE;
725
+ if (nodeType.endsWith('3D'))
726
+ return GODOT_RED;
727
+ return GODOT_GRAY; // Default gray for base Node
728
+ }
729
+ function drawSceneViewPlaceholder() {
730
+ ctx.fillStyle = '#484f58';
731
+ ctx.font = `16px -apple-system, system-ui, sans-serif`;
732
+ ctx.textAlign = 'center';
733
+ ctx.fillText('No scenes found', 0, 0);
734
+ ctx.fillText('Create a .tscn file in your project', 0, 24);
735
+ ctx.textAlign = 'left';
736
+ }
737
+ // Export scene hit testing
738
+ export function sceneHitTest(wx, wy) {
739
+ if (!sceneData || !sceneData.scenes)
740
+ return null;
741
+ if (expandedScene && expandedSceneHierarchy) {
742
+ // Hit test expanded scene nodes
743
+ const treeLayout = calculateTreeLayout(expandedSceneHierarchy);
744
+ for (let i = treeLayout.nodes.length - 1; i >= 0; i--) {
745
+ const node = treeLayout.nodes[i];
746
+ if (wx >= node.x && wx <= node.x + node.width &&
747
+ wy >= node.y && wy <= node.y + SCENE_NODE_H) {
748
+ return { type: 'sceneNode', node, scenePath: expandedScene };
749
+ }
750
+ }
751
+ return null;
752
+ }
753
+ else {
754
+ // Hit test scene cards
755
+ for (const scene of sceneData.scenes) {
756
+ const pos = scenePositions[scene.path];
757
+ if (!pos)
758
+ continue;
759
+ if (wx >= pos.x && wx <= pos.x + SCENE_CARD_W &&
760
+ wy >= pos.y && wy <= pos.y + SCENE_CARD_H) {
761
+ return { type: 'sceneCard', scene, scenePath: scene.path };
762
+ }
763
+ }
764
+ return null;
765
+ }
766
+ }
767
+ export { SCENE_CARD_W, SCENE_CARD_H, SCENE_NODE_H };
768
+ export function roundRect(ctx, x, y, w, h, r) {
769
+ ctx.moveTo(x + r, y);
770
+ ctx.arcTo(x + w, y, x + w, y + h, r);
771
+ ctx.arcTo(x + w, y + h, x, y + h, r);
772
+ ctx.arcTo(x, y + h, x, y, r);
773
+ ctx.arcTo(x, y, x + w, y, r);
774
+ }
775
+ export function hitTest(wx, wy) {
776
+ for (let i = nodes.length - 1; i >= 0; i--) {
777
+ const n = nodes[i];
778
+ if (n.categoryVisible === false)
779
+ continue;
780
+ // Skip hidden nodes during search
781
+ if (searchTerm && n.visible === false)
782
+ continue;
783
+ if (wx >= n.x - NODE_W / 2 && wx <= n.x + NODE_W / 2 &&
784
+ wy >= n.y - NODE_H / 2 && wy <= n.y + NODE_H / 2)
785
+ return n;
786
+ }
787
+ return null;
788
+ }
789
+ export function groupBoxHitTest(wx, wy) {
790
+ if (!window.__categoryGroupBoxes)
791
+ return null;
792
+ for (const box of window.__categoryGroupBoxes) {
793
+ if (wx >= box.x && wx <= box.x + box.w &&
794
+ wy >= box.y && wy <= box.y + box.h) {
795
+ return box;
796
+ }
797
+ }
798
+ return null;
799
+ }
800
+ export function centerOnNodes(nodeList) {
801
+ if (!nodeList || nodeList.length === 0)
802
+ return;
803
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
804
+ nodeList.forEach(n => {
805
+ minX = Math.min(minX, n.x);
806
+ maxX = Math.max(maxX, n.x);
807
+ minY = Math.min(minY, n.y);
808
+ maxY = Math.max(maxY, n.y);
809
+ });
810
+ camera.x = (minX + maxX) / 2;
811
+ camera.y = (minY + maxY) / 2;
812
+ updateZoomIndicator();
813
+ }
814
+ export function fitToView(nodeList) {
815
+ if (!nodeList || nodeList.length === 0)
816
+ return;
817
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
818
+ nodeList.forEach(n => {
819
+ minX = Math.min(minX, n.x);
820
+ maxX = Math.max(maxX, n.x);
821
+ minY = Math.min(minY, n.y);
822
+ maxY = Math.max(maxY, n.y);
823
+ });
824
+ camera.x = (minX + maxX) / 2;
825
+ camera.y = (minY + maxY) / 2;
826
+ const spanX = (maxX - minX) + NODE_W * 2;
827
+ const spanY = (maxY - minY) + NODE_H * 2;
828
+ // Calculate zoom to fit all nodes, but cap at 100% (1.0) to avoid zooming in too much
829
+ camera.zoom = Math.min(1.0, W / spanX, H / spanY) * 0.9;
830
+ // Don't change defaultZoom - keep it at 1 (100%) so reset always goes to 100%
831
+ updateZoomIndicator();
832
+ }