Added three new experimental views

This commit is contained in:
Richard Osborne
2026-01-04 00:17:33 +01:00
parent 2845b1b879
commit eb90c5a9c8
110 changed files with 24591 additions and 2570 deletions

View File

@@ -73,6 +73,7 @@
"archiver": "^5.3.2",
"async": "^3.2.6",
"classnames": "^2.5.1",
"dagre": "^0.8.5",
"diff3": "0.0.4",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
@@ -103,6 +104,7 @@
"@babel/preset-react": "^7.27.1",
"@svgr/webpack": "^6.5.1",
"@types/checksum": "^0.1.35",
"@types/dagre": "^0.7.52",
"@types/jasmine": "^4.6.5",
"@types/jquery": "^3.5.33",
"@types/react": "^19.2.7",

View File

@@ -9,6 +9,7 @@ import { ProjectModel } from './models/projectmodel';
import { WarningsModel } from './models/warningsmodel';
import DebugInspector from './utils/debuginspector';
import * as Exporter from './utils/exporter';
import { triggerChainRecorder } from './utils/triggerChain';
const port = process.env.NOODLPORT || 8574;
@@ -105,6 +106,13 @@ export class ViewerConnection extends Model {
} else if (request.cmd === 'connectiondebugpulse' && request.type === 'viewer') {
const content = JSON.parse(request.content);
DebugInspector.instance.setConnectionsToPulse(content.connectionsToPulse);
// Also capture for trigger chain recorder if recording
if (triggerChainRecorder.isRecording()) {
content.connectionsToPulse.forEach((connectionId: string) => {
triggerChainRecorder.captureConnectionPulse(connectionId);
});
}
} else if (request.cmd === 'debuginspectorvalues' && request.type === 'viewer') {
DebugInspector.instance.setInspectorValues(request.content.inspectors);
} else if (request.cmd === 'connectionValue' && request.type === 'viewer') {

View File

@@ -13,6 +13,7 @@ import { CloudFunctionsPanel } from './views/panels/CloudFunctionsPanel/CloudFun
import { CloudServicePanel } from './views/panels/CloudServicePanel/CloudServicePanel';
import { ComponentPortsComponent } from './views/panels/componentports';
import { ComponentsPanel } from './views/panels/componentspanel';
import { ComponentXRayPanel } from './views/panels/ComponentXRayPanel';
import { DesignTokenPanel } from './views/panels/DesignTokenPanel/DesignTokenPanel';
import { EditorSettingsPanel } from './views/panels/EditorSettingsPanel/EditorSettingsPanel';
import { FileExplorerPanel } from './views/panels/FileExplorerPanel';
@@ -21,6 +22,8 @@ import { NodeReferencesPanel } from './views/panels/NodeReferencesPanel/NodeRefe
import { ProjectSettingsPanel } from './views/panels/ProjectSettingsPanel/ProjectSettingsPanel';
import { PropertyEditor } from './views/panels/propertyeditor';
import { SearchPanel } from './views/panels/search-panel/search-panel';
import { TopologyMapPanel } from './views/panels/TopologyMapPanel';
import { TriggerChainDebuggerPanel } from './views/panels/TriggerChainDebuggerPanel';
import { UndoQueuePanel } from './views/panels/UndoQueuePanel/UndoQueuePanel';
import { VersionControlPanel_ID } from './views/panels/VersionControlPanel';
import { VersionControlPanel } from './views/panels/VersionControlPanel/VersionControlPanel';
@@ -76,6 +79,26 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) {
panel: SearchPanel
});
SidebarModel.instance.register({
experimental: true,
id: 'topology',
name: 'Topology',
order: 3,
icon: IconName.Navigate,
panel: TopologyMapPanel
});
SidebarModel.instance.register({
experimental: true,
id: 'component-xray',
name: 'Component X-Ray',
description:
'Shows comprehensive information about the active component: usage, interface, structure, and dependencies.',
order: 4,
icon: IconName.SearchGrid,
panel: ComponentXRayPanel
});
SidebarModel.instance.register({
id: VersionControlPanel_ID,
name: 'Version control',
@@ -119,6 +142,16 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) {
panel: ProjectSettingsPanel
});
SidebarModel.instance.register({
experimental: true,
id: 'trigger-chain-debugger',
name: 'Trigger Chain Debugger',
description: 'Records and visualizes chains of events triggered from user interactions in the preview.',
order: 10,
icon: IconName.Play,
panel: TriggerChainDebuggerPanel
});
if (config.devMode) {
SidebarModel.instance.register({
experimental: true,

View File

@@ -19,16 +19,24 @@ import { ProjectsPage } from './pages/ProjectsPage';
import { DialogLayerContainer } from './views/DialogLayer';
import { ToastLayerContainer } from './views/ToastLayer';
// Store roots globally for HMR reuse
let toastLayerRoot: ReturnType<typeof createRoot> | null = null;
let dialogLayerRoot: ReturnType<typeof createRoot> | null = null;
function createToastLayer() {
const toastLayer = document.createElement('div');
toastLayer.classList.add('toast-layer');
$('body').append(toastLayer);
createRoot(toastLayer).render(React.createElement(ToastLayerContainer));
toastLayerRoot = createRoot(toastLayer);
toastLayerRoot.render(React.createElement(ToastLayerContainer));
if (import.meta.webpackHot) {
import.meta.webpackHot.accept('./views/ToastLayer', () => {
createRoot(toastLayer).render(React.createElement(ToastLayerContainer));
// Reuse existing root instead of creating a new one
if (toastLayerRoot) {
toastLayerRoot.render(React.createElement(ToastLayerContainer));
}
});
}
}
@@ -47,11 +55,15 @@ function createDialogLayer() {
dialogLayer.classList.add('dialog-layer');
$('body').append(dialogLayer);
createRoot(dialogLayer).render(React.createElement(DialogLayerContainer));
dialogLayerRoot = createRoot(dialogLayer);
dialogLayerRoot.render(React.createElement(DialogLayerContainer));
if (import.meta.webpackHot) {
import.meta.webpackHot.accept('./views/DialogLayer', () => {
createRoot(dialogLayer).render(React.createElement(DialogLayerContainer));
// Reuse existing root instead of creating a new one
if (dialogLayerRoot) {
dialogLayerRoot.render(React.createElement(DialogLayerContainer));
}
});
}
}

View File

@@ -0,0 +1,128 @@
/**
* HighlightHandle - Control interface for individual highlights
*
* Provides methods to update, dismiss, and query highlights.
* Handles are returned when creating highlights and should be kept
* for later manipulation.
*/
import type { IHighlightHandle, ConnectionRef } from './types';
/**
* Implementation of the highlight control interface
*/
export class HighlightHandle implements IHighlightHandle {
private _active: boolean = true;
private _nodeIds: string[];
private _connections: ConnectionRef[];
private _label: string | undefined;
/**
* Callback to notify manager of updates
*/
private readonly onUpdate: (handle: HighlightHandle) => void;
/**
* Callback to notify manager of dismissal
*/
private readonly onDismiss: (handle: HighlightHandle) => void;
constructor(
public readonly id: string,
public readonly channel: string,
nodeIds: string[],
connections: ConnectionRef[],
label: string | undefined,
onUpdate: (handle: HighlightHandle) => void,
onDismiss: (handle: HighlightHandle) => void
) {
this._nodeIds = [...nodeIds];
this._connections = [...connections];
this._label = label;
this.onUpdate = onUpdate;
this.onDismiss = onDismiss;
}
/**
* Update the highlighted nodes
*/
update(nodeIds: string[]): void {
if (!this._active) {
console.warn(`HighlightHandle: Cannot update inactive highlight ${this.id}`);
return;
}
this._nodeIds = [...nodeIds];
this.onUpdate(this);
}
/**
* Update the label displayed near the highlight
*/
setLabel(label: string): void {
if (!this._active) {
console.warn(`HighlightHandle: Cannot update label on inactive highlight ${this.id}`);
return;
}
this._label = label;
this.onUpdate(this);
}
/**
* Remove this highlight
*/
dismiss(): void {
if (!this._active) {
return;
}
this._active = false;
this.onDismiss(this);
}
/**
* Check if this highlight is still active
*/
isActive(): boolean {
return this._active;
}
/**
* Get the current node IDs
*/
getNodeIds(): string[] {
return [...this._nodeIds];
}
/**
* Get the current connection refs
*/
getConnections(): ConnectionRef[] {
return [...this._connections];
}
/**
* Get the current label
* @internal Used by HighlightManager
*/
getLabel(): string | undefined {
return this._label;
}
/**
* Update connections (internal method called by manager)
* @internal
*/
setConnections(connections: ConnectionRef[]): void {
this._connections = [...connections];
}
/**
* Mark this handle as inactive (internal method called by manager)
* @internal
*/
deactivate(): void {
this._active = false;
}
}

View File

@@ -0,0 +1,485 @@
/**
* HighlightManager - Core service for canvas highlighting
*
* Singleton service that manages multi-channel highlights on the node graph canvas.
* Extends EventDispatcher to notify listeners of highlight changes.
*
* Features:
* - Multi-channel organization (lineage, impact, selection, warning)
* - Persistent highlights that survive component navigation
* - Path highlighting across multiple nodes/connections
* - Event-based notifications for UI updates
*
* @example
* ```typescript
* const handle = HighlightManager.instance.highlightNodes(['node1', 'node2'], {
* channel: 'lineage',
* label: 'Data flow from Input'
* });
*
* // Later...
* handle.update(['node1', 'node2', 'node3']);
* handle.dismiss();
* ```
*/
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
import { getChannelConfig, isValidChannel } from './channels';
import { HighlightHandle } from './HighlightHandle';
import type {
HighlightOptions,
ConnectionRef,
PathDefinition,
IHighlightHandle,
HighlightInfo,
HighlightState,
HighlightManagerEvent,
HighlightEventCallback,
ComponentBoundary
} from './types';
/**
* Main highlighting service - manages all highlights across all channels
*/
export class HighlightManager extends EventDispatcher {
private static _instance: HighlightManager;
/**
* Get the singleton instance
*/
static get instance(): HighlightManager {
if (!HighlightManager._instance) {
HighlightManager._instance = new HighlightManager();
}
return HighlightManager._instance;
}
/**
* Internal state tracking all active highlights
*/
private highlights: Map<string, HighlightState> = new Map();
/**
* Counter for generating unique highlight IDs
*/
private nextId: number = 1;
/**
* Current component being viewed (for persistence tracking)
* Set by NodeGraphEditor when navigating components
*/
private currentComponentId: string | null = null;
private constructor() {
super();
}
/**
* Highlight specific nodes
*
* @param nodeIds - Array of node IDs to highlight
* @param options - Highlight configuration
* @returns Handle to control the highlight
*
* @example
* ```typescript
* const handle = HighlightManager.instance.highlightNodes(
* ['textNode', 'outputNode'],
* { channel: 'lineage', label: 'Text data flow' }
* );
* ```
*/
highlightNodes(nodeIds: string[], options: HighlightOptions): IHighlightHandle {
if (!isValidChannel(options.channel)) {
console.warn(`HighlightManager: Unknown channel "${options.channel}"`);
}
const id = `highlight-${this.nextId++}`;
const channelConfig = getChannelConfig(options.channel);
// Create the highlight state
const state: HighlightState = {
id,
channel: options.channel,
allNodeIds: [...nodeIds],
allConnections: [],
visibleNodeIds: [...nodeIds], // Will be filtered in Phase 3
visibleConnections: [],
options: {
...options,
color: options.color || channelConfig.color,
style: options.style || channelConfig.style,
persistent: options.persistent !== false // Default to true
},
createdAt: new Date(),
active: true
};
this.highlights.set(id, state);
// Create the handle
const handle = new HighlightHandle(
id,
options.channel,
nodeIds,
[],
options.label,
(h) => this.handleUpdate(h),
(h) => this.handleDismiss(h)
);
// Notify listeners
this.notifyListeners('highlightAdded', {
highlightId: id,
channel: options.channel,
highlight: this.getHighlightInfo(state)
});
return handle;
}
/**
* Highlight specific connections between nodes
*
* @param connections - Array of connection references
* @param options - Highlight configuration
* @returns Handle to control the highlight
*
* @example
* ```typescript
* const handle = HighlightManager.instance.highlightConnections(
* [{ fromNodeId: 'a', fromPort: 'out', toNodeId: 'b', toPort: 'in' }],
* { channel: 'warning', label: 'Invalid connection' }
* );
* ```
*/
highlightConnections(connections: ConnectionRef[], options: HighlightOptions): IHighlightHandle {
if (!isValidChannel(options.channel)) {
console.warn(`HighlightManager: Unknown channel "${options.channel}"`);
}
const id = `highlight-${this.nextId++}`;
const channelConfig = getChannelConfig(options.channel);
// Extract unique node IDs from connections
const nodeIds = new Set<string>();
connections.forEach((conn) => {
nodeIds.add(conn.fromNodeId);
nodeIds.add(conn.toNodeId);
});
const state: HighlightState = {
id,
channel: options.channel,
allNodeIds: Array.from(nodeIds),
allConnections: [...connections],
visibleNodeIds: Array.from(nodeIds), // Will be filtered in Phase 3
visibleConnections: [...connections], // Will be filtered in Phase 3
options: {
...options,
color: options.color || channelConfig.color,
style: options.style || channelConfig.style,
persistent: options.persistent !== false
},
createdAt: new Date(),
active: true
};
this.highlights.set(id, state);
const handle = new HighlightHandle(
id,
options.channel,
Array.from(nodeIds),
connections,
options.label,
(h) => this.handleUpdate(h),
(h) => this.handleDismiss(h)
);
this.notifyListeners('highlightAdded', {
highlightId: id,
channel: options.channel,
highlight: this.getHighlightInfo(state)
});
return handle;
}
/**
* Clear all highlights in a specific channel
*
* @param channel - Channel to clear
*
* @example
* ```typescript
* HighlightManager.instance.clearChannel('selection');
* ```
*/
clearChannel(channel: string): void {
const toRemove: string[] = [];
this.highlights.forEach((state, id) => {
if (state.channel === channel) {
state.active = false;
toRemove.push(id);
}
});
toRemove.forEach((id) => this.highlights.delete(id));
if (toRemove.length > 0) {
this.notifyListeners('channelCleared', { channel });
}
}
/**
* Clear all highlights across all channels
*/
clearAll(): void {
this.highlights.clear();
this.notifyListeners('allCleared', {});
}
/**
* Get all active highlights, optionally filtered by channel
*
* @param channel - Optional channel filter
* @returns Array of highlight information
*/
getHighlights(channel?: string): HighlightInfo[] {
const results: HighlightInfo[] = [];
this.highlights.forEach((state) => {
if (state.active && (!channel || state.channel === channel)) {
results.push(this.getHighlightInfo(state));
}
});
return results;
}
/**
* Set the current component being viewed
* Called by NodeGraphEditor when navigating
* Filters highlights to show only nodes/connections in the current component
*
* @internal
*/
setCurrentComponent(componentId: string | null): void {
if (this.currentComponentId === componentId) {
return; // No change
}
this.currentComponentId = componentId;
// Re-filter all active highlights for the new component
this.highlights.forEach((state) => {
this.filterVisibleElements(state);
});
// Notify listeners that highlights have changed
this.notifyListeners('highlightUpdated', {
channel: 'all'
});
}
/**
* Get the current component ID
* @internal
*/
getCurrentComponent(): string | null {
return this.currentComponentId;
}
/**
* Highlight a path through the node graph
*
* Supports cross-component paths with boundary detection.
*
* @param path - Path definition with nodes and connections
* @param options - Highlight configuration
* @returns Handle to control the highlight
*/
highlightPath(path: PathDefinition, options: HighlightOptions): IHighlightHandle {
if (!isValidChannel(options.channel)) {
console.warn(`HighlightManager: Unknown channel "${options.channel}"`);
}
const id = `highlight-${this.nextId++}`;
const channelConfig = getChannelConfig(options.channel);
// Detect component boundaries in the path
const boundaries = path.componentBoundaries || this.detectComponentBoundaries(path);
const state: HighlightState = {
id,
channel: options.channel,
allNodeIds: [...path.nodes],
allConnections: [...path.connections],
visibleNodeIds: [...path.nodes], // Will be filtered
visibleConnections: [...path.connections], // Will be filtered
componentBoundaries: boundaries,
options: {
...options,
color: options.color || channelConfig.color,
style: options.style || channelConfig.style,
persistent: options.persistent !== false
},
createdAt: new Date(),
active: true
};
// Filter for current component
this.filterVisibleElements(state);
this.highlights.set(id, state);
const handle = new HighlightHandle(
id,
options.channel,
path.nodes,
path.connections,
options.label,
(h) => this.handleUpdate(h),
(h) => this.handleDismiss(h)
);
this.notifyListeners('highlightAdded', {
highlightId: id,
channel: options.channel,
highlight: this.getHighlightInfo(state)
});
return handle;
}
/**
* Get visible highlights for the current component
* Returns only highlights with elements visible in the current component
*
* @internal
*/
getVisibleHighlights(): HighlightState[] {
return Array.from(this.highlights.values())
.filter((s) => s.active)
.filter((s) => s.visibleNodeIds.length > 0 || s.visibleConnections.length > 0);
}
/**
* Handle highlight update from a HighlightHandle
* @private
*/
private handleUpdate(handle: HighlightHandle): void {
const state = this.highlights.get(handle.id);
if (!state) return;
state.allNodeIds = handle.getNodeIds();
state.allConnections = handle.getConnections();
// Re-filter for current component
this.filterVisibleElements(state);
this.notifyListeners('highlightUpdated', {
highlightId: handle.id,
channel: handle.channel,
highlight: this.getHighlightInfo(state)
});
}
/**
* Handle highlight dismissal from a HighlightHandle
* @private
*/
private handleDismiss(handle: HighlightHandle): void {
const state = this.highlights.get(handle.id);
if (!state) return;
state.active = false;
this.highlights.delete(handle.id);
this.notifyListeners('highlightRemoved', {
highlightId: handle.id,
channel: handle.channel
});
}
/**
* Detect component boundaries in a path
* Identifies where the path crosses between parent and child components
*
* @private
*/
private detectComponentBoundaries(_path: PathDefinition): ComponentBoundary[] {
// This is a simplified implementation
// In a full implementation, we would:
// 1. Get the component owner for each node from the model
// 2. Detect transitions between different components
// 3. Identify entry/exit nodes (Component Input/Output nodes)
// For now, return empty array - will be enhanced when integrated with node models
return [];
}
/**
* Filter visible nodes and connections based on current component
* Updates the state's visibleNodeIds and visibleConnections arrays
*
* @private
*/
private filterVisibleElements(state: HighlightState): void {
if (!this.currentComponentId) {
// No component context - show everything
state.visibleNodeIds = [...state.allNodeIds];
state.visibleConnections = [...state.allConnections];
return;
}
// Filter nodes - for now, show all (will be enhanced with component ownership checks)
state.visibleNodeIds = [...state.allNodeIds];
state.visibleConnections = [...state.allConnections];
// TODO: When integrated with NodeGraphModel:
// - Check node.model.owner to determine component
// - Filter to only nodes belonging to currentComponentId
// - Filter connections to only those where both nodes are visible
}
/**
* Convert internal state to public HighlightInfo
* @private
*/
private getHighlightInfo(state: HighlightState): HighlightInfo {
return {
id: state.id,
channel: state.channel,
nodeIds: [...state.allNodeIds],
connections: [...state.allConnections],
options: { ...state.options },
createdAt: state.createdAt
};
}
/**
* Subscribe to highlight events
*
* @example
* ```typescript
* const context = {};
* HighlightManager.instance.on('highlightAdded', (data) => {
* console.log('New highlight:', data.highlightId);
* }, context);
* ```
*/
on(event: HighlightManagerEvent, callback: HighlightEventCallback, context: object): void {
// EventDispatcher expects a generic callback, cast to compatible type
super.on(event, callback as (data: unknown) => void, context);
}
/**
* Unsubscribe from highlight events
*/
off(context: object): void {
super.off(context);
}
}

View File

@@ -0,0 +1,81 @@
# 🎉 Canvas Highlighting API - Community Showcase
Welcome back to Noodl! After over a year of waiting, we're excited to show you what we've been building.
## Quick Start
1. **Open the Noodl editor** and load a component with nodes
2. **Open DevTools**: View → Toggle Developer Tools
3. **Run the demo** by pasting this in the console:
```javascript
noodlShowcase.start();
```
Sit back and watch the magic! ✨
## What You'll See
The demo showcases the new **Canvas Highlighting API** with:
- **🌊 Four Channels**: Lineage (blue), Impact (orange), Selection (white), Warning (red)
- **🌊 Wave Effect**: Rainbow cascade through your nodes
- **🎆 Grand Finale**: All nodes pulsing in synchronized harmony
## Manual API Usage
After the demo, try the API yourself:
```javascript
// Highlight specific nodes
HighlightManager.instance.highlightNodes(['node-id-1', 'node-id-2'], {
channel: 'lineage',
color: '#4A90D9',
style: 'glow',
label: 'My Highlight'
});
// Clear all highlights
HighlightManager.instance.clearAll();
// Or clear just the showcase
noodlShowcase.clear();
```
## API Features
### Channels
- `lineage` - Data flow traces (blue glow)
- `impact` - Change impact analysis (orange pulse)
- `selection` - Temporary selection states (white solid)
- `warning` - Errors and warnings (red pulse)
### Styles
- `glow` - Soft animated glow
- `pulse` - Pulsing attention-grabber
- `solid` - Clean, static outline
### Multi-Channel Support
Multiple highlights can coexist on different channels without interference!
## What's Next?
This API is the foundation for powerful new features coming to Noodl:
- **📊 Data Lineage Viewer** - Trace data flow through your app
- **💥 Impact Radar** - See what changes when you edit a node
- **🔍 Component X-Ray** - Visualize component hierarchies
- **🐛 Trigger Chain Debugger** - Debug event cascades
## Documentation
Full API documentation: `packages/noodl-editor/src/editor/src/services/HighlightManager/`
---
**Made with ❤️ by the Noodl team**
Worth the wait? We think so! 🚀

View File

@@ -0,0 +1,104 @@
/**
* Channel Configuration for Canvas Highlighting System
*
* Defines the visual appearance and behavior of each highlighting channel.
* Channels are used to organize different types of highlights:
* - lineage: Data flow traces (blue)
* - impact: Change impact visualization (orange)
* - selection: User selection state (white)
* - warning: Errors and validation warnings (red)
*/
import { ChannelConfig } from './types';
/**
* Channel definitions with colors, styles, and metadata
*/
export const CHANNELS: Record<string, ChannelConfig> = {
/**
* Data Lineage traces - shows how data flows through the graph
* Blue color with glow effect for visibility without being distracting
*/
lineage: {
color: '#4A90D9',
style: 'glow',
description: 'Data flow traces showing how data propagates through nodes',
zIndex: 10
},
/**
* Impact Radar - shows which nodes would be affected by a change
* Orange color with pulse effect to draw attention
*/
impact: {
color: '#F5A623',
style: 'pulse',
description: 'Downstream impact visualization for change analysis',
zIndex: 15
},
/**
* Selection state - temporary highlight for hover/focus states
* White color with solid effect for clarity
*/
selection: {
color: '#FFFFFF',
style: 'solid',
description: 'Temporary selection and hover states',
zIndex: 20
},
/**
* Warnings and errors - highlights problematic nodes/connections
* Red color with pulse effect for urgency
*/
warning: {
color: '#FF6B6B',
style: 'pulse',
description: 'Error and validation warning indicators',
zIndex: 25
}
};
/**
* Get channel configuration by name
* Returns default configuration if channel doesn't exist
*/
export function getChannelConfig(channel: string): ChannelConfig {
return (
CHANNELS[channel] || {
color: '#FFFFFF',
style: 'solid',
description: 'Custom channel',
zIndex: 5
}
);
}
/**
* Check if a channel exists
*/
export function isValidChannel(channel: string): boolean {
return channel in CHANNELS;
}
/**
* Get all available channel names
*/
export function getAvailableChannels(): string[] {
return Object.keys(CHANNELS);
}
/**
* Default z-index for highlights when not specified
*/
export const DEFAULT_HIGHLIGHT_Z_INDEX = 10;
/**
* Animation durations for different styles (in milliseconds)
*/
export const ANIMATION_DURATIONS = {
glow: 1000,
pulse: 1500,
solid: 0
};

View File

@@ -0,0 +1,153 @@
/**
* 🎉 CANVAS HIGHLIGHTING API - COMMUNITY SHOWCASE
*
* Welcome back to Noodl! After a year of waiting, here's something special.
*
* USAGE:
* 1. Open a component with nodes in the editor
* 2. Open DevTools (View → Toggle Developer Tools)
* 3. Paste this in the console:
*
* noodlShowcase.start()
*
* Then sit back and enjoy! 🚀
*/
import { HighlightManager } from './HighlightManager';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function getNodes() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const editor = (window as any).__nodeGraphEditor;
if (!editor?.roots) return [];
return editor.roots;
}
function log(emoji: string, msg: string) {
console.log(`%c${emoji} ${msg}`, 'font-size: 14px; font-weight: bold;');
}
async function intro() {
console.clear();
log('🎬', 'NOODL CANVAS HIGHLIGHTING API');
log('✨', "Worth the wait. Let's blow your mind.");
await sleep(1500);
}
async function channelDemo() {
const nodes = getNodes();
if (nodes.length < 4) {
log('⚠️', 'Need at least 4 nodes for full demo');
return;
}
const channels = [
{ name: 'lineage', color: '#4A90D9', emoji: '🌊', label: 'Data Flow' },
{ name: 'impact', color: '#F5A623', emoji: '💥', label: 'Impact' },
{ name: 'selection', color: '#FFFFFF', emoji: '✨', label: 'Selection' },
{ name: 'warning', color: '#FF6B6B', emoji: '🔥', label: 'Warning' }
];
for (let i = 0; i < channels.length; i++) {
const ch = channels[i];
log(ch.emoji, `Channel ${i + 1}: ${ch.label}`);
HighlightManager.instance.highlightNodes([nodes[i].id], {
channel: ch.name,
color: ch.color,
style: i % 2 === 0 ? 'glow' : 'pulse',
label: ch.label
});
await sleep(800);
}
await sleep(2000);
HighlightManager.instance.clearAll();
}
async function waveEffect() {
const nodes = getNodes();
if (nodes.length < 2) return;
log('🌊', 'Wave Effect');
const handles = [];
for (let i = 0; i < Math.min(nodes.length, 8); i++) {
handles.push(
HighlightManager.instance.highlightNodes([nodes[i].id], {
channel: 'lineage',
color: `hsl(${i * 40}, 70%, 60%)`,
style: 'glow'
})
);
await sleep(150);
}
await sleep(1500);
handles.forEach((h) => h.dismiss());
}
async function finale() {
const nodes = getNodes();
log('🎆', 'Grand Finale');
const colors = ['#4A90D9', '#F5A623', '#9C27B0', '#FF6B6B'];
const handles = [];
nodes.forEach((node, i) => {
handles.push(
HighlightManager.instance.highlightNodes([node.id], {
channel: 'impact',
color: colors[i % colors.length],
style: 'pulse'
})
);
});
await sleep(3000);
log('✨', 'Fading out...');
handles.forEach((h) => h.dismiss());
await sleep(500);
}
async function start() {
const nodes = getNodes();
if (!nodes || nodes.length === 0) {
console.error('❌ No nodes found. Open a component with nodes first!');
return;
}
try {
await intro();
await channelDemo();
await sleep(500);
await waveEffect();
await sleep(500);
await finale();
console.log('\n');
log('🎉', 'Demo Complete!');
log('📚', 'API Docs: Check HighlightManager.ts');
log('💡', 'Try: HighlightManager.instance.highlightNodes([...])');
log('🧹', 'Clear: HighlightManager.instance.clearAll()');
} catch (error) {
console.error('Demo error:', error);
}
}
// Export for console access
export const noodlShowcase = {
start,
clear: () => HighlightManager.instance.clearAll()
};
// Make globally available
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).noodlShowcase = noodlShowcase;
console.log('✅ Showcase loaded! Run: noodlShowcase.start()');
}

View File

@@ -0,0 +1,39 @@
/**
* Canvas Highlighting API
*
* Public exports for the HighlightManager service.
* Import from this file to use the highlighting system.
*
* @example
* ```typescript
* import { HighlightManager } from '@noodl/services/HighlightManager';
*
* const handle = HighlightManager.instance.highlightNodes(
* ['node1', 'node2'],
* { channel: 'lineage', label: 'Data flow' }
* );
* ```
*/
// Main service
export { HighlightManager } from './HighlightManager';
// Type exports
export type {
HighlightOptions,
ConnectionRef,
PathDefinition,
ComponentBoundary,
IHighlightHandle,
HighlightInfo,
HighlightState,
ChannelConfig,
HighlightManagerEvent,
HighlightEventCallback
} from './types';
// Channel utilities
export { CHANNELS, getChannelConfig, isValidChannel, getAvailableChannels } from './channels';
// Community showcase demo
export { noodlShowcase } from './community-showcase';

View File

@@ -0,0 +1,292 @@
/**
* Test/Demo functions for the Highlighting API
*
* This file provides helper functions to test the canvas highlighting system.
* Open Electron DevTools (View → Toggle Developer Tools) and run these in the console:
*
* Usage from Console:
* ```
* // Import the test helpers
* const { testHighlightManager } = require('./services/HighlightManager/test-highlights');
*
* // Run basic tests
* testHighlightManager.testBasicHighlight();
* testHighlightManager.testMultipleNodes();
* testHighlightManager.testAnimatedPulse();
* testHighlightManager.clearAll();
* ```
*/
import { HighlightManager } from './index';
/**
* Test highlighting the first visible node
*/
export function testBasicHighlight() {
console.log('🔍 Testing basic node highlight...');
// Get the active NodeGraphEditor instance
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const editor = (window as any).__nodeGraphEditor;
if (!editor) {
console.error('❌ No active NodeGraphEditor found. Open a component first.');
return;
}
// Get the first node
const firstNode = editor.roots[0];
if (!firstNode) {
console.error('❌ No nodes found in the current component.');
return;
}
console.log(`✅ Highlighting node: ${firstNode.id}`);
// Create a highlight
const handle = HighlightManager.instance.highlightNodes([firstNode.id], {
channel: 'impact',
color: '#00FF00',
style: 'glow',
label: 'Test Highlight'
});
console.log('✅ Highlight created! You should see a green glow around the first node.');
console.log('💡 Clear it with: testHighlightManager.clearAll()');
return handle;
}
/**
* Test highlighting multiple nodes
*/
export function testMultipleNodes() {
console.log('🔍 Testing multiple node highlights...');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const editor = (window as any).__nodeGraphEditor;
if (!editor) {
console.error('❌ No active NodeGraphEditor found.');
return;
}
// Get first 3 nodes
const nodeIds = editor.roots.slice(0, 3).map((n) => n.id);
if (nodeIds.length === 0) {
console.error('❌ No nodes found.');
return;
}
console.log(`✅ Highlighting ${nodeIds.length} nodes`);
const handle = HighlightManager.instance.highlightNodes(nodeIds, {
channel: 'selection',
color: '#FFA500',
style: 'solid',
label: 'Multi-Select Test'
});
console.log('✅ Multiple nodes highlighted in orange!');
return handle;
}
/**
* Test animated pulse highlight
*/
export function testAnimatedPulse() {
console.log('🔍 Testing animated pulse...');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const editor = (window as any).__nodeGraphEditor;
if (!editor || !editor.roots[0]) {
console.error('❌ No active editor or nodes found.');
return;
}
const firstNode = editor.roots[0];
console.log(`✅ Creating pulsing highlight on: ${firstNode.id}`);
const handle = HighlightManager.instance.highlightNodes([firstNode.id], {
channel: 'warning',
color: '#FF0000',
style: 'pulse',
label: 'Warning!'
});
console.log('✅ Pulsing red highlight created!');
return handle;
}
/**
* Test highlighting a connection (requires 2 connected nodes)
*/
export function testConnection() {
console.log('🔍 Testing connection highlight...');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const editor = (window as any).__nodeGraphEditor;
if (!editor) {
console.error('❌ No active NodeGraphEditor found.');
return;
}
// Find the first connection
const firstConnection = editor.connections[0];
if (!firstConnection) {
console.error('❌ No connections found. Create some connected nodes first.');
return;
}
const fromNode = firstConnection.fromNode;
const toNode = firstConnection.toNode;
if (!fromNode || !toNode) {
console.error('❌ Connection has invalid nodes.');
return;
}
console.log(`✅ Highlighting connection: ${fromNode.id}${toNode.id}`);
const handle = HighlightManager.instance.highlightConnections(
[
{
fromNodeId: fromNode.id,
fromPort: 'out', // Add required port fields
toNodeId: toNode.id,
toPort: 'in'
}
],
{
channel: 'lineage',
color: '#00FFFF',
style: 'solid'
}
);
console.log('✅ Connection highlighted in cyan!');
return handle;
}
/**
* Test highlighting a path (chain of connected nodes)
*/
export function testPath() {
console.log('🔍 Testing path highlight...');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const editor = (window as any).__nodeGraphEditor;
if (!editor) {
console.error('❌ No active NodeGraphEditor found.');
return;
}
// Get first 3 nodes (simulating a path)
const nodeIds = editor.roots.slice(0, 3).map((n) => n.id);
if (nodeIds.length < 2) {
console.error('❌ Need at least 2 nodes for a path test.');
return;
}
console.log(`✅ Highlighting path through ${nodeIds.length} nodes`);
const handle = HighlightManager.instance.highlightPath(
{
nodes: nodeIds,
connections: [], // Empty for this test
crossesComponents: false
},
{
channel: 'lineage',
color: '#9C27B0',
style: 'glow',
label: 'Execution Path'
}
);
console.log('✅ Path highlighted in purple!');
return handle;
}
/**
* Clear all highlights
*/
export function clearAll() {
console.log('🧹 Clearing all highlights...');
HighlightManager.instance.clearAll();
console.log('✅ All highlights cleared!');
}
/**
* Clear specific channel
*/
export function clearChannel(channel: string) {
console.log(`🧹 Clearing channel: ${channel}`);
HighlightManager.instance.clearChannel(channel);
console.log('✅ Channel cleared!');
}
/**
* Run a full demo sequence
*/
export async function runDemoSequence() {
console.log('🎬 Running highlight demo sequence...');
// Test 1: Basic highlight
console.log('\n1⃣ Basic single node highlight');
testBasicHighlight();
await sleep(2000);
clearAll();
await sleep(500);
// Test 2: Multiple nodes
console.log('\n2⃣ Multiple node highlight');
testMultipleNodes();
await sleep(2000);
clearAll();
await sleep(500);
// Test 3: Animated pulse
console.log('\n3⃣ Animated pulse');
testAnimatedPulse();
await sleep(3000);
clearAll();
await sleep(500);
// Test 4: Connection (if available)
console.log('\n4⃣ Connection highlight');
try {
testConnection();
await sleep(2000);
} catch (e) {
console.warn('⚠️ Connection test skipped (no connections available)');
}
clearAll();
console.log('\n✅ Demo sequence complete!');
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Export all test functions
export const testHighlightManager = {
testBasicHighlight,
testMultipleNodes,
testAnimatedPulse,
testConnection,
testPath,
clearAll,
clearChannel,
runDemoSequence
};
// Make available in window for console access
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).testHighlightManager = testHighlightManager;
console.log('✅ Highlight test utilities loaded!');
console.log('💡 Try: window.testHighlightManager.testBasicHighlight()');
}

View File

@@ -0,0 +1,164 @@
/**
* TypeScript interfaces for the Canvas Highlighting API
*
* This system enables persistent, multi-channel highlighting of nodes and connections
* on the canvas, used by Data Lineage and Impact Radar visualization views.
*/
/**
* Options for creating a highlight
*/
export interface HighlightOptions {
/** Channel identifier (e.g., 'lineage', 'impact', 'selection') */
channel: string;
/** Override the default channel color */
color?: string;
/** Visual style for the highlight */
style?: 'solid' | 'glow' | 'pulse';
/** Whether highlight persists across navigation (default: true) */
persistent?: boolean;
/** Optional label to display near the highlight */
label?: string;
}
/**
* Reference to a connection between two nodes
*/
export interface ConnectionRef {
fromNodeId: string;
fromPort: string;
toNodeId: string;
toPort: string;
}
/**
* Definition of a path through the node graph
*/
export interface PathDefinition {
/** Ordered list of node IDs in the path */
nodes: string[];
/** Connections between the nodes */
connections: ConnectionRef[];
/** Whether this path crosses component boundaries */
crossesComponents?: boolean;
/** Component boundaries crossed by this path */
componentBoundaries?: ComponentBoundary[];
}
/**
* Information about a component boundary crossing
*
* Represents a transition where a highlighted path crosses from one component to another.
*/
export interface ComponentBoundary {
/** Component where the path is coming from */
fromComponent: string;
/** Component where the path is going to */
toComponent: string;
/** Direction of crossing: 'up' = to parent, 'down' = to child */
direction: 'up' | 'down';
/** Node ID at the edge of the visible component where path crosses */
edgeNodeId: string;
/** Optional: Component Input node ID (for 'down' direction) */
entryNodeId?: string;
/** Optional: Component Output node ID (for 'up' direction) */
exitNodeId?: string;
}
/**
* Handle for controlling an active highlight
*/
export interface IHighlightHandle {
/** Unique identifier for this highlight */
readonly id: string;
/** Channel this highlight belongs to */
readonly channel: string;
/** Update the highlighted nodes */
update(nodeIds: string[]): void;
/** Update the label */
setLabel(label: string): void;
/** Remove this highlight */
dismiss(): void;
/** Check if this highlight is still active */
isActive(): boolean;
/** Get the current node IDs */
getNodeIds(): string[];
/** Get the current connection refs */
getConnections(): ConnectionRef[];
}
/**
* Information about an active highlight
*/
export interface HighlightInfo {
id: string;
channel: string;
nodeIds: string[];
connections: ConnectionRef[];
options: HighlightOptions;
createdAt: Date;
}
/**
* Internal state for a highlight
*/
export interface HighlightState {
id: string;
channel: string;
allNodeIds: string[];
allConnections: ConnectionRef[];
visibleNodeIds: string[];
visibleConnections: ConnectionRef[];
componentBoundaries?: ComponentBoundary[];
options: HighlightOptions;
createdAt: Date;
active: boolean;
}
/**
* Channel configuration
*/
export interface ChannelConfig {
color: string;
style: 'solid' | 'glow' | 'pulse';
description: string;
zIndex?: number;
}
/**
* Events emitted by HighlightManager
*/
export type HighlightManagerEvent =
| 'highlightAdded'
| 'highlightRemoved'
| 'highlightUpdated'
| 'channelCleared'
| 'allCleared';
/**
* Callback for highlight events
*/
export type HighlightEventCallback = (data: {
highlightId?: string;
channel?: string;
highlight?: HighlightInfo;
}) => void;

View File

@@ -9,6 +9,11 @@
<canvas id="nodegraphcanvas" width="1000" height="600" style="position: absolute; width: 100%; height: 100%"></canvas>
<!-- Highlight overlay layer (above canvas, below comments) -->
<div style="position: absolute; overflow: hidden; width: 100%; height: 100%; pointer-events: none">
<div id="highlight-overlay-layer"></div>
</div>
<!-- same div wrapper hack as above -->
<div style="position: absolute; overflow: hidden; width: 100%; height: 100%; pointer-events: none">
<div id="comment-layer-fg" style="pointer-events: all"></div>

View File

@@ -0,0 +1,254 @@
/**
* Node categorization utilities for semantic grouping and analysis
*/
import type { ComponentModel } from '@noodl-models/componentmodel';
import type { NodeGraphNode } from '@noodl-models/nodegraphmodel';
import type { CategorizedNodes, NodeCategory } from './types';
/**
* Node type to category mapping
* This mapping groups node types into semantic categories for analysis
*/
const NODE_TYPE_CATEGORIES: Record<string, NodeCategory> = {
// Visual nodes
Group: 'visual',
Text: 'visual',
Image: 'visual',
Video: 'visual',
Icon: 'visual',
Circle: 'visual',
Rectangle: 'visual',
'Page Stack': 'visual',
Columns: 'visual',
'Scroll View': 'visual',
// Data nodes
Variable: 'data',
Object: 'data',
Array: 'data',
Number: 'data',
String: 'data',
Boolean: 'data',
'Static Array': 'data',
'Array Filter': 'data',
'Array Map': 'data',
// Logic nodes
Condition: 'logic',
Expression: 'logic',
Switch: 'logic',
States: 'logic',
'Boolean To String': 'logic',
'String Mapper': 'logic',
'Number Remapper': 'logic',
// Event nodes
'Send Event': 'events',
'Receive Event': 'events',
'Component Inputs': 'events',
'Component Outputs': 'events',
'Receive Global Event': 'events',
'Send Global Event': 'events',
// API/Network nodes
REST: 'api',
'REST v2': 'api',
'Cloud Function': 'api',
'Cloud Function 2.0': 'api',
Function: 'api',
'Javascript Function': 'api',
// Navigation nodes
'Page Router': 'navigation',
Navigate: 'navigation',
'Navigate To Path': 'navigation',
'Navigate Back': 'navigation',
'External Link': 'navigation',
// Animation nodes
'Value Changed': 'animation',
'Did Mount': 'animation',
'Will Unmount': 'animation'
};
/**
* Categorize all nodes in a component by semantic type.
*
* @param component - Component to analyze
* @returns Categorized node information with totals
*
* @example
* ```typescript
* const categorized = categorizeNodes(component);
* categorized.totals.forEach(({ category, count }) => {
* console.log(`${category}: ${count} nodes`);
* });
* ```
*/
export function categorizeNodes(component: ComponentModel): CategorizedNodes {
const byCategory = new Map<NodeCategory, NodeGraphNode[]>();
const byType = new Map<string, NodeGraphNode[]>();
// Initialize category maps
const categories: NodeCategory[] = ['visual', 'data', 'logic', 'events', 'api', 'navigation', 'animation', 'utility'];
categories.forEach((cat) => byCategory.set(cat, []));
// Categorize each node
component.graph.nodeMap.forEach((node) => {
const category = getNodeCategory(node.typename);
// Add to category map
const categoryNodes = byCategory.get(category) || [];
categoryNodes.push(node);
byCategory.set(category, categoryNodes);
// Add to type map
const typeNodes = byType.get(node.typename) || [];
typeNodes.push(node);
byType.set(node.typename, typeNodes);
});
// Calculate totals
const totals = categories.map((category) => ({
category,
count: byCategory.get(category)?.length || 0
}));
return { byCategory, byType, totals };
}
/**
* Get the category for a specific node type.
*
* @param nodeType - Node type name
* @returns Category for the node type
*
* @example
* ```typescript
* const category = getNodeCategory('Variable');
* console.log(category); // 'data'
* ```
*/
export function getNodeCategory(nodeType: string): NodeCategory {
return NODE_TYPE_CATEGORIES[nodeType] || 'utility';
}
/**
* Check if a node is a visual node (has visual hierarchy).
*
* @param node - Node to check
* @returns True if the node is a visual node
*
* @example
* ```typescript
* if (isVisualNode(node)) {
* console.log('This node can have children in the visual hierarchy');
* }
* ```
*/
export function isVisualNode(node: NodeGraphNode): boolean {
const category = getNodeCategory(node.typename);
return category === 'visual';
}
/**
* Check if a node is a data source (Variable, Object, Array, etc.).
*
* @param node - Node to check
* @returns True if the node is a data source
*
* @example
* ```typescript
* if (isDataSourceNode(node)) {
* console.log('This node stores or provides data');
* }
* ```
*/
export function isDataSourceNode(node: NodeGraphNode): boolean {
const category = getNodeCategory(node.typename);
return category === 'data';
}
/**
* Check if a node is a logic node (Condition, Expression, etc.).
*
* @param node - Node to check
* @returns True if the node performs logical operations
*
* @example
* ```typescript
* if (isLogicNode(node)) {
* console.log('This node performs logical operations');
* }
* ```
*/
export function isLogicNode(node: NodeGraphNode): boolean {
const category = getNodeCategory(node.typename);
return category === 'logic';
}
/**
* Check if a node is an event node (Send Event, Receive Event, etc.).
*
* @param node - Node to check
* @returns True if the node handles events
*
* @example
* ```typescript
* if (isEventNode(node)) {
* console.log('This node handles event communication');
* }
* ```
*/
export function isEventNode(node: NodeGraphNode): boolean {
const category = getNodeCategory(node.typename);
return category === 'events';
}
/**
* Get a summary of node categories in a component.
*
* @param component - Component to analyze
* @returns Array of category counts sorted by count (descending)
*
* @example
* ```typescript
* const summary = getNodeCategorySummary(component);
* console.log('Most common category:', summary[0].category);
* ```
*/
export function getNodeCategorySummary(component: ComponentModel): { category: NodeCategory; count: number }[] {
const categorized = categorizeNodes(component);
return categorized.totals.filter((t) => t.count > 0).sort((a, b) => b.count - a.count);
}
/**
* Get a summary of node types in a component.
*
* @param component - Component to analyze
* @returns Array of type counts sorted by count (descending)
*
* @example
* ```typescript
* const summary = getNodeTypeSummary(component);
* console.log('Most common node type:', summary[0].type);
* ```
*/
export function getNodeTypeSummary(
component: ComponentModel
): { type: string; category: NodeCategory; count: number }[] {
const categorized = categorizeNodes(component);
const summary: { type: string; category: NodeCategory; count: number }[] = [];
categorized.byType.forEach((nodes, type) => {
summary.push({
type,
category: getNodeCategory(type),
count: nodes.length
});
});
return summary.sort((a, b) => b.count - a.count);
}

View File

@@ -0,0 +1,325 @@
/**
* Cross-component resolution utilities for tracing connections through component boundaries
*/
import { ComponentModel } from '@noodl-models/componentmodel';
import { ProjectModel } from '@noodl-models/projectmodel';
import type { ComponentUsage, ExternalConnection } from './types';
/**
* Find all places where a component is instantiated across the project.
*
* @param project - Project to search
* @param componentName - Name of the component to find usages of
* @returns Array of component usage information
*
* @example
* ```typescript
* const usages = findComponentUsages(project, 'UserCard');
* usages.forEach(usage => {
* console.log(`Used in ${usage.usedIn.name} as node ${usage.instanceNodeId}`);
* });
* ```
*/
export function findComponentUsages(project: ProjectModel, componentName: string): ComponentUsage[] {
const usages: ComponentUsage[] = [];
const targetComponent = project.getComponentWithName(componentName);
if (!targetComponent) {
return usages;
}
// Iterate through all components in the project
project.forEachComponent((component: ComponentModel) => {
// Skip the component itself
if (component.name === componentName) {
return;
}
// Check all nodes in this component
component.graph.nodeMap.forEach((node) => {
// Check if this node is an instance of the target component
if (node.type instanceof ComponentModel && node.type.name === componentName) {
// Find all connections to this component instance
const connectedPorts: ComponentUsage['connectedPorts'] = [];
const ports = node.getPorts('input');
ports.forEach((port) => {
const connections = component.graph.connections.filter(
(conn) => conn.toId === node.id && conn.toProperty === port.name
);
if (connections.length > 0) {
connectedPorts.push({
port: port.name,
connectedTo: connections.map((conn) => ({
nodeId: conn.fromId,
port: conn.fromProperty
}))
});
}
});
usages.push({
component: targetComponent,
usedIn: component,
instanceNodeId: node.id,
connectedPorts
});
}
});
});
return usages;
}
/**
* Resolve a Component Input/Output port to its external connections.
* Given a Component Inputs node, find what feeds into it from the parent component.
* Given a Component Outputs node, find what it feeds into in the parent component.
*
* @param project - Project containing the components
* @param component - Component containing the boundary node
* @param boundaryNodeId - ID of the Component Inputs or Component Outputs node
* @param portName - Name of the port on the boundary node
* @returns Array of external connections (empty if not found or no parent)
*
* @example
* ```typescript
* // Inside a "UserCard" component, find what connects to Component Inputs "userId" port
* const external = resolveComponentBoundary(
* project,
* userCardComponent,
* componentInputsNodeId,
* 'userId'
* );
* external.forEach(conn => {
* console.log(`Parent connects from node ${conn.parentNodeId}`);
* });
* ```
*/
export function resolveComponentBoundary(
project: ProjectModel,
component: ComponentModel,
boundaryNodeId: string,
portName: string
): ExternalConnection[] {
const boundaryNode = component.graph.nodeMap.get(boundaryNodeId);
if (!boundaryNode) {
return [];
}
const connections: ExternalConnection[] = [];
// Determine if this is an input or output boundary
const isInput = boundaryNode.typename === 'Component Inputs';
const isOutput = boundaryNode.typename === 'Component Outputs';
if (!isInput && !isOutput) {
return [];
}
// Find all instances of this component in other components
const usages = findComponentUsages(project, component.name);
for (const usage of usages) {
const parentComponent = usage.usedIn;
const instanceNode = parentComponent.graph.nodeMap.get(usage.instanceNodeId);
if (!instanceNode) continue;
if (isInput) {
// For Component Inputs, find connections in parent that feed into this port
const parentConnections = parentComponent.graph.connections.filter(
(conn) => conn.toId === usage.instanceNodeId && conn.toProperty === portName
);
for (const conn of parentConnections) {
connections.push({
parentNodeId: conn.fromId,
parentPort: conn.fromProperty,
childComponent: component,
childBoundaryNodeId: boundaryNodeId,
childPort: portName
});
}
} else if (isOutput) {
// For Component Outputs, find connections in parent that this port feeds into
const parentConnections = parentComponent.graph.connections.filter(
(conn) => conn.fromId === usage.instanceNodeId && conn.fromProperty === portName
);
for (const conn of parentConnections) {
connections.push({
parentNodeId: conn.toId,
parentPort: conn.toProperty,
childComponent: component,
childBoundaryNodeId: boundaryNodeId,
childPort: portName
});
}
}
}
return connections;
}
/**
* Build a complete component dependency graph for the project.
* Shows which components use which other components.
*
* @param project - Project to analyze
* @returns Object with nodes (components) and edges (usage relationships)
*
* @example
* ```typescript
* const graph = buildComponentDependencyGraph(project);
* console.log('Components:', graph.nodes.map(c => c.name));
* graph.edges.forEach(edge => {
* console.log(`${edge.from} uses ${edge.to} ${edge.count} times`);
* });
* ```
*/
export function buildComponentDependencyGraph(project: ProjectModel): {
nodes: ComponentModel[];
edges: { from: string; to: string; count: number }[];
} {
const nodes: ComponentModel[] = [];
const edgeMap = new Map<string, { from: string; to: string; count: number }>();
// Collect all components as nodes
project.forEachComponent((component: ComponentModel) => {
nodes.push(component);
});
// Build edges by finding component instances
project.forEachComponent((component: ComponentModel) => {
component.graph.nodeMap.forEach((node) => {
if (node.type instanceof ComponentModel) {
const usedComponentName = node.type.name;
const key = `${component.name}${usedComponentName}`;
if (edgeMap.has(key)) {
const edge = edgeMap.get(key)!;
edge.count++;
} else {
edgeMap.set(key, {
from: component.name,
to: usedComponentName,
count: 1
});
}
}
});
});
const edges = Array.from(edgeMap.values());
return { nodes, edges };
}
/**
* Check if a component is used (instantiated) anywhere in the project.
*
* @param project - Project to search
* @param componentName - Name of the component to check
* @returns True if the component is used at least once
*
* @example
* ```typescript
* if (!isComponentUsed(project, 'OldWidget')) {
* console.log('This component is not used and can be deleted');
* }
* ```
*/
export function isComponentUsed(project: ProjectModel, componentName: string): boolean {
return findComponentUsages(project, componentName).length > 0;
}
/**
* Find all components that are not used anywhere in the project.
* These might be candidates for cleanup.
*
* @param project - Project to analyze
* @returns Array of unused component names
*
* @example
* ```typescript
* const unused = findUnusedComponents(project);
* console.log('Unused components:', unused);
* ```
*/
export function findUnusedComponents(project: ProjectModel): string[] {
const unused: string[] = [];
project.forEachComponent((component: ComponentModel) => {
// Skip special components that might not be directly instantiated
// but are used via routing (like App Shell)
const rootComponent = project.getRootComponent();
if (rootComponent && component.name === rootComponent.name) {
return; // Skip root component
}
if (!isComponentUsed(project, component.name)) {
unused.push(component.name);
}
});
return unused;
}
/**
* Get the depth of a component in the component hierarchy.
* Depth 0 = root component
* Depth 1 = components used by root
* Depth 2 = components used by depth 1 components, etc.
*
* @param project - Project to analyze
* @param componentName - Name of the component
* @returns Depth in the hierarchy (0 for root, -1 if unused/unreachable)
*
* @example
* ```typescript
* const depth = getComponentDepth(project, 'UserCard');
* console.log(`UserCard is at depth ${depth} in the hierarchy`);
* ```
*/
export function getComponentDepth(project: ProjectModel, componentName: string): number {
const rootComponent = project.getRootComponent();
const rootName = rootComponent?.name;
if (!rootName || componentName === rootName) {
return componentName === rootName ? 0 : -1;
}
const visited = new Set<string>();
const queue: { name: string; depth: number }[] = [{ name: rootName, depth: 0 }];
while (queue.length > 0) {
const current = queue.shift()!;
if (visited.has(current.name)) {
continue;
}
visited.add(current.name);
const component = project.getComponentWithName(current.name);
if (!component) continue;
// Check all nodes in this component
component.graph.nodeMap.forEach((node) => {
if (node.type instanceof ComponentModel) {
const usedName = node.type.name;
if (usedName === componentName) {
return current.depth + 1; // Found it!
}
queue.push({ name: usedName, depth: current.depth + 1 });
}
});
}
return -1; // Not reachable from root
}

View File

@@ -0,0 +1,303 @@
/**
* Duplicate node detection utilities for finding potential naming conflicts
*/
import { ComponentModel } from '@noodl-models/componentmodel';
import type { NodeGraphNode } from '@noodl-models/nodegraphmodel';
import { ProjectModel } from '@noodl-models/projectmodel';
import { getConnectedNodes } from './traversal';
import type { ConflictAnalysis, DuplicateGroup } from './types';
/**
* Find potential duplicate nodes within a component.
* Duplicates = same type + same/similar name.
*
* @param component - Component to analyze
* @returns Array of duplicate groups found
*
* @example
* ```typescript
* const duplicates = findDuplicatesInComponent(component);
* duplicates.forEach(group => {
* console.log(`Found ${group.instances.length} nodes named "${group.name}"`);
* });
* ```
*/
export function findDuplicatesInComponent(component: ComponentModel): DuplicateGroup[] {
const groups = new Map<string, NodeGraphNode[]>();
// Group nodes by type and name
component.graph.nodeMap.forEach((node) => {
const name = node.label || node.typename;
const key = `${node.typename}:${name.toLowerCase().trim()}`;
const existing = groups.get(key) || [];
existing.push(node);
groups.set(key, existing);
});
// Filter to only groups with more than one node
const duplicates: DuplicateGroup[] = [];
groups.forEach((nodes, key) => {
if (nodes.length > 1) {
const [typename, name] = key.split(':');
// Calculate connection count for each instance
const instances = nodes.map((node) => {
const connections = getConnectedNodes(component, node.id);
const connectionCount = connections.inputs.length + connections.outputs.length;
return {
node,
component,
connectionCount
};
});
// Determine severity
let severity: DuplicateGroup['severity'] = 'info';
let reason = 'Multiple nodes with the same name';
// Higher severity for data nodes (potential conflicts)
if (['Variable', 'Object', 'Array'].includes(typename)) {
severity = 'warning';
reason = 'Multiple data nodes with the same name may cause confusion';
}
// Critical for Send/Receive Event with same name
if (['Send Event', 'Receive Event'].includes(typename)) {
severity = 'error';
reason = 'Multiple event nodes with the same channel name will all trigger';
}
duplicates.push({
name,
type: typename,
instances,
severity,
reason
});
}
});
return duplicates;
}
/**
* Find potential duplicate nodes across the entire project.
*
* @param project - Project to analyze
* @returns Array of duplicate groups found across all components
*
* @example
* ```typescript
* const duplicates = findDuplicatesInProject(project);
* duplicates.forEach(group => {
* const components = new Set(group.instances.map(i => i.component.name));
* console.log(`"${group.name}" found in ${components.size} components`);
* });
* ```
*/
export function findDuplicatesInProject(project: ProjectModel): DuplicateGroup[] {
const allDuplicates: DuplicateGroup[] = [];
project.forEachComponent((component: ComponentModel) => {
const componentDuplicates = findDuplicatesInComponent(component);
allDuplicates.push(...componentDuplicates);
});
return allDuplicates;
}
/**
* Analyze if duplicates might cause conflicts.
* E.g., two Variables with same name writing to same output.
*
* @param duplicates - Array of duplicate groups to analyze
* @returns Array of conflict analyses
*
* @example
* ```typescript
* const duplicates = findDuplicatesInComponent(component);
* const conflicts = analyzeDuplicateConflicts(duplicates);
* conflicts.forEach(conflict => {
* console.log(`${conflict.conflictType}: ${conflict.description}`);
* });
* ```
*/
export function analyzeDuplicateConflicts(duplicates: DuplicateGroup[]): ConflictAnalysis[] {
const conflicts: ConflictAnalysis[] = [];
for (const group of duplicates) {
// Check for Variable conflicts (same name, potentially connected to same outputs)
if (group.type === 'Variable') {
const connectedOutputs = new Map<string, number>();
for (const instance of group.instances) {
const connections = getConnectedNodes(instance.component, instance.node.id);
// Count connections to each output node
connections.outputs.forEach((outputNode) => {
const key = `${outputNode.id}:${outputNode.typename}`;
connectedOutputs.set(key, (connectedOutputs.get(key) || 0) + 1);
});
}
// If multiple variables connect to the same output, it's a conflict
connectedOutputs.forEach((count, key) => {
if (count > 1) {
conflicts.push({
group,
conflictType: 'data-race',
description: `Multiple variables named "${group.name}" connect to the same output node. Last write wins.`,
affectedNodes: group.instances.map((i) => i.node.id)
});
}
});
}
// Check for Event name collisions
if (group.type === 'Send Event' || group.type === 'Receive Event') {
// Events with same channel name will all trigger
conflicts.push({
group,
conflictType: 'name-collision',
description: `Multiple ${group.type} nodes use channel "${group.name}". All receivers will trigger when any sender fires.`,
affectedNodes: group.instances.map((i) => i.node.id)
});
}
// Check for Object/Array naming conflicts
if (group.type === 'Object' || group.type === 'Array') {
conflicts.push({
group,
conflictType: 'state-conflict',
description: `Multiple ${group.type} nodes named "${group.name}" may cause confusion about which instance holds the current state.`,
affectedNodes: group.instances.map((i) => i.node.id)
});
}
}
return conflicts;
}
/**
* Find nodes with similar (but not identical) names that might be duplicates.
*
* @param component - Component to analyze
* @param similarityThreshold - Similarity threshold (0-1, default 0.8)
* @returns Array of potential duplicate groups
*
* @example
* ```typescript
* // Find nodes like "userData" and "userdata" (case variations)
* const similar = findSimilarlyNamedNodes(component, 0.9);
* ```
*/
export function findSimilarlyNamedNodes(
component: ComponentModel,
similarityThreshold: number = 0.8
): DuplicateGroup[] {
const nodes: NodeGraphNode[] = [];
component.graph.nodeMap.forEach((node) => nodes.push(node));
const groups: DuplicateGroup[] = [];
const processed = new Set<string>();
for (let i = 0; i < nodes.length; i++) {
if (processed.has(nodes[i].id)) continue;
const similar: NodeGraphNode[] = [nodes[i]];
const name1 = (nodes[i].label || nodes[i].typename).toLowerCase().trim();
for (let j = i + 1; j < nodes.length; j++) {
if (processed.has(nodes[j].id)) continue;
if (nodes[i].typename !== nodes[j].typename) continue;
const name2 = (nodes[j].label || nodes[j].typename).toLowerCase().trim();
// Calculate similarity (simple Levenshtein-based)
const similarity = calculateStringSimilarity(name1, name2);
if (similarity >= similarityThreshold && similarity < 1.0) {
similar.push(nodes[j]);
processed.add(nodes[j].id);
}
}
if (similar.length > 1) {
processed.add(nodes[i].id);
const instances = similar.map((node) => {
const connections = getConnectedNodes(component, node.id);
return {
node,
component,
connectionCount: connections.inputs.length + connections.outputs.length
};
});
groups.push({
name: nodes[i].label || nodes[i].typename,
type: nodes[i].typename,
instances,
severity: 'info',
reason: 'Nodes have similar names that might be typos or duplicates'
});
}
}
return groups;
}
/**
* Calculate string similarity using Levenshtein distance.
* Returns a value between 0 (completely different) and 1 (identical).
*
* @param str1 - First string
* @param str2 - Second string
* @returns Similarity score (0-1)
*/
function calculateStringSimilarity(str1: string, str2: string): number {
const maxLength = Math.max(str1.length, str2.length);
if (maxLength === 0) return 1.0;
const distance = levenshteinDistance(str1, str2);
return 1.0 - distance / maxLength;
}
/**
* Calculate Levenshtein distance between two strings.
* @param str1 - First string
* @param str2 - Second string
* @returns Edit distance
*/
function levenshteinDistance(str1: string, str2: string): number {
const matrix: number[][] = [];
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str1.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[str2.length][str1.length];
}

View File

@@ -0,0 +1,53 @@
/**
* Graph Analysis Utilities
*
* This module provides utilities for analyzing node graphs, tracing connections,
* resolving cross-component relationships, and detecting potential issues.
*
* @module graphAnalysis
* @since 1.3.0
*/
// Export all types
export type * from './types';
// Export traversal utilities
export {
traceConnectionChain,
getConnectedNodes,
getPortConnections,
buildAdjacencyList,
getAllConnections,
findNodesOfType,
type TraceOptions
} from './traversal';
// Export cross-component utilities
export {
findComponentUsages,
resolveComponentBoundary,
buildComponentDependencyGraph,
isComponentUsed,
findUnusedComponents,
getComponentDepth
} from './crossComponent';
// Export categorization utilities
export {
categorizeNodes,
getNodeCategory,
isVisualNode,
isDataSourceNode,
isLogicNode,
isEventNode,
getNodeCategorySummary,
getNodeTypeSummary
} from './categorization';
// Export duplicate detection utilities
export {
findDuplicatesInComponent,
findDuplicatesInProject,
analyzeDuplicateConflicts,
findSimilarlyNamedNodes
} from './duplicateDetection';

View File

@@ -0,0 +1,363 @@
/**
* Graph traversal utilities for analyzing node connections and data flow
*/
import type { ComponentModel } from '@noodl-models/componentmodel';
import type { NodeGraphNode } from '@noodl-models/nodegraphmodel';
import type { ConnectionPath, ConnectionRef, ComponentCrossing, TraversalResult } from './types';
/**
* Options for connection chain tracing
*/
export interface TraceOptions {
/** Maximum depth to traverse (default: 100) */
maxDepth?: number;
/** Whether to cross component boundaries via Component Inputs/Outputs (default: false) */
crossComponents?: boolean;
/** Node types to stop at (e.g., ['Variable', 'Object']) */
stopAtTypes?: string[];
/** Stop at first branch (default: false, follows all branches) */
stopAtBranch?: boolean;
}
/**
* Trace a connection chain from a starting point.
* Follows connections upstream (to sources) or downstream (to sinks).
*
* @param component - Component containing the starting node
* @param startNodeId - ID of the node to start from
* @param startPort - Port name to start from
* @param direction - 'upstream' (find sources) or 'downstream' (find sinks)
* @param options - Traversal options
* @returns Traversal result with path and metadata
*
* @example
* ```typescript
* // Find what feeds into a Text node's 'text' input
* const result = traceConnectionChain(
* component,
* textNodeId,
* 'text',
* 'upstream'
* );
* console.log('Path:', result.path.map(p => p.node.label));
* ```
*/
export function traceConnectionChain(
component: ComponentModel,
startNodeId: string,
startPort: string,
direction: 'upstream' | 'downstream',
options: TraceOptions = {}
): TraversalResult {
const maxDepth = options.maxDepth ?? 100;
const crossComponents = options.crossComponents ?? false;
const stopAtTypes = options.stopAtTypes ?? [];
const stopAtBranch = options.stopAtBranch ?? false;
const path: ConnectionPath[] = [];
const crossedComponents: ComponentCrossing[] = [];
const visited = new Set<string>();
const startNode = component.graph.nodeMap.get(startNodeId);
if (!startNode) {
return {
path: [],
crossedComponents: [],
terminatedAt: 'source'
};
}
// Add starting point
path.push({
node: startNode,
port: startPort,
direction: direction === 'upstream' ? 'input' : 'output'
});
let currentNodes: { nodeId: string; port: string }[] = [{ nodeId: startNodeId, port: startPort }];
let depth = 0;
while (currentNodes.length > 0 && depth < maxDepth) {
const nextNodes: { nodeId: string; port: string }[] = [];
for (const current of currentNodes) {
const key = `${current.nodeId}:${current.port}`;
if (visited.has(key)) {
continue; // Skip cycles
}
visited.add(key);
const connections = getPortConnections(
component,
current.nodeId,
current.port,
direction === 'upstream' ? 'input' : 'output'
);
if (connections.length === 0) {
// Dead end - no more connections
continue;
}
if (stopAtBranch && connections.length > 1) {
// Multiple branches - stop here if requested
return {
path,
crossedComponents,
terminatedAt: 'sink'
};
}
for (const conn of connections) {
const targetNodeId = direction === 'upstream' ? conn.fromNodeId : conn.toNodeId;
const targetPort = direction === 'upstream' ? conn.fromPort : conn.toPort;
const targetNode = component.graph.nodeMap.get(targetNodeId);
if (!targetNode) continue;
// Check if we should stop at this node type
if (stopAtTypes.includes(targetNode.typename)) {
path.push({
node: targetNode,
port: targetPort,
direction: direction === 'upstream' ? 'output' : 'input',
connection: conn
});
return {
path,
crossedComponents,
terminatedAt: 'source'
};
}
// Check for component boundary
if (targetNode.typename === 'Component Inputs' || targetNode.typename === 'Component Outputs') {
if (crossComponents) {
// TODO: Cross component boundary resolution
// This requires finding the parent component instance and resolving connections
return {
path,
crossedComponents,
terminatedAt: 'component-boundary'
};
} else {
path.push({
node: targetNode,
port: targetPort,
direction: direction === 'upstream' ? 'output' : 'input',
connection: conn
});
return {
path,
crossedComponents,
terminatedAt: 'component-boundary'
};
}
}
// Add to path and continue
path.push({
node: targetNode,
port: targetPort,
direction: direction === 'upstream' ? 'output' : 'input',
connection: conn
});
nextNodes.push({ nodeId: targetNodeId, port: targetPort });
}
}
currentNodes = nextNodes;
depth++;
}
// Determine termination reason
if (depth >= maxDepth) {
return { path, crossedComponents, terminatedAt: 'cycle' };
}
return {
path,
crossedComponents,
terminatedAt: direction === 'upstream' ? 'source' : 'sink'
};
}
/**
* Get all nodes directly connected to a given node.
*
* @param component - Component containing the node
* @param nodeId - ID of the node to check
* @returns Object with arrays of connected input and output nodes
*
* @example
* ```typescript
* const neighbors = getConnectedNodes(component, nodeId);
* console.log('Inputs from:', neighbors.inputs.map(n => n.label));
* console.log('Outputs to:', neighbors.outputs.map(n => n.label));
* ```
*/
export function getConnectedNodes(
component: ComponentModel,
nodeId: string
): { inputs: NodeGraphNode[]; outputs: NodeGraphNode[] } {
const inputs: NodeGraphNode[] = [];
const outputs: NodeGraphNode[] = [];
const inputSet = new Set<string>();
const outputSet = new Set<string>();
for (const conn of component.graph.connections) {
// Find nodes that feed into this node (inputs)
if (conn.toId === nodeId) {
if (!inputSet.has(conn.fromId)) {
const node = component.graph.nodeMap.get(conn.fromId);
if (node) {
inputs.push(node);
inputSet.add(conn.fromId);
}
}
}
// Find nodes that this node feeds into (outputs)
if (conn.fromId === nodeId) {
if (!outputSet.has(conn.toId)) {
const node = component.graph.nodeMap.get(conn.toId);
if (node) {
outputs.push(node);
outputSet.add(conn.toId);
}
}
}
}
return { inputs, outputs };
}
/**
* Get all connections for a specific port.
*
* @param component - Component containing the node
* @param nodeId - ID of the node
* @param portName - Name of the port
* @param direction - Port direction ('input' or 'output')
* @returns Array of connection references
*
* @example
* ```typescript
* const connections = getPortConnections(component, nodeId, 'value', 'output');
* console.log('Sends to:', connections.map(c => c.toNodeId));
* ```
*/
export function getPortConnections(
component: ComponentModel,
nodeId: string,
portName: string,
direction: 'input' | 'output'
): ConnectionRef[] {
const connections: ConnectionRef[] = [];
for (const conn of component.graph.connections) {
if (direction === 'input' && conn.toId === nodeId && conn.toProperty === portName) {
connections.push({
fromNodeId: conn.fromId,
fromPort: conn.fromProperty,
toNodeId: conn.toId,
toPort: conn.toProperty
});
} else if (direction === 'output' && conn.fromId === nodeId && conn.fromProperty === portName) {
connections.push({
fromNodeId: conn.fromId,
fromPort: conn.fromProperty,
toNodeId: conn.toId,
toPort: conn.toProperty
});
}
}
return connections;
}
/**
* Build an adjacency list representation of the node graph.
* Useful for graph algorithms and analysis.
*
* @param component - Component to analyze
* @returns Map of node IDs to their connected node IDs (inputs and outputs)
*
* @example
* ```typescript
* const adjacency = buildAdjacencyList(component);
* const nodeConnections = adjacency.get(nodeId);
* console.log('Inputs:', nodeConnections.inputs);
* console.log('Outputs:', nodeConnections.outputs);
* ```
*/
export function buildAdjacencyList(component: ComponentModel): Map<string, { inputs: string[]; outputs: string[] }> {
const adjacency = new Map<string, { inputs: string[]; outputs: string[] }>();
// Initialize all nodes
component.graph.nodeMap.forEach((node) => {
adjacency.set(node.id, { inputs: [], outputs: [] });
});
// Add connections
for (const conn of component.graph.connections) {
const fromEntry = adjacency.get(conn.fromId);
const toEntry = adjacency.get(conn.toId);
if (fromEntry) {
fromEntry.outputs.push(conn.toId);
}
if (toEntry) {
toEntry.inputs.push(conn.fromId);
}
}
return adjacency;
}
/**
* Get all connections in a component.
*
* @param component - Component to analyze
* @returns Array of all connection references
*/
export function getAllConnections(component: ComponentModel): ConnectionRef[] {
return component.graph.connections.map((conn) => ({
fromNodeId: conn.fromId,
fromPort: conn.fromProperty,
toNodeId: conn.toId,
toPort: conn.toProperty
}));
}
/**
* Find all nodes of a specific type in a component.
*
* @param component - Component to search
* @param typename - Node type name to find
* @returns Array of matching nodes
*
* @example
* ```typescript
* const variables = findNodesOfType(component, 'Variable');
* console.log('Variables:', variables.map(n => n.label));
* ```
*/
export function findNodesOfType(component: ComponentModel, typename: string): NodeGraphNode[] {
const nodes: NodeGraphNode[] = [];
component.graph.nodeMap.forEach((node) => {
if (node.typename === typename) {
nodes.push(node);
}
});
return nodes;
}

View File

@@ -0,0 +1,158 @@
/**
* Shared type definitions for graph analysis utilities
*/
import type { ComponentModel } from '@noodl-models/componentmodel';
import type { NodeGraphNode } from '@noodl-models/nodegraphmodel';
/**
* Node category for semantic grouping
*/
export type NodeCategory =
| 'visual' // Groups, Text, Image, etc.
| 'data' // Variables, Objects, Arrays
| 'logic' // Conditions, Expressions, Switches
| 'events' // Send Event, Receive Event, Component I/O
| 'api' // REST, Function, Cloud Functions
| 'navigation' // Page Router, Navigate
| 'animation' // Transitions, States (animation-related)
| 'utility'; // Other/misc
/**
* Reference to a connection between ports
*/
export interface ConnectionRef {
fromNodeId: string;
fromPort: string;
toNodeId: string;
toPort: string;
}
/**
* A point in a connection path
*/
export interface ConnectionPath {
node: NodeGraphNode;
port: string;
direction: 'input' | 'output';
connection?: ConnectionRef;
}
/**
* Result of tracing a connection chain
*/
export interface TraversalResult {
path: ConnectionPath[];
crossedComponents: ComponentCrossing[];
terminatedAt: 'source' | 'sink' | 'cycle' | 'component-boundary';
}
/**
* Information about crossing a component boundary
*/
export interface ComponentCrossing {
fromComponent: ComponentModel;
toComponent: ComponentModel;
viaPort: string;
direction: 'into' | 'outof';
}
/**
* Summary of a node's basic properties
*/
export interface NodeSummary {
id: string;
type: string;
displayName: string;
label: string | null;
category: NodeCategory;
inputCount: number;
outputCount: number;
connectedInputs: number;
connectedOutputs: number;
hasChildren: boolean;
childCount: number;
}
/**
* Summary of a connection
*/
export interface ConnectionSummary {
fromNode: NodeSummary;
fromPort: string;
toNode: NodeSummary;
toPort: string;
}
/**
* Summary of a component
*/
export interface ComponentSummary {
name: string;
fullName: string;
nodeCount: number;
connectionCount: number;
inputPorts: string[];
outputPorts: string[];
usedComponents: string[];
usedByComponents: string[];
categories: { category: NodeCategory; count: number }[];
}
/**
* Component usage information
*/
export interface ComponentUsage {
component: ComponentModel;
usedIn: ComponentModel;
instanceNodeId: string;
connectedPorts: {
port: string;
connectedTo: { nodeId: string; port: string }[];
}[];
}
/**
* External connection resolved across component boundary
*/
export interface ExternalConnection {
parentNodeId: string;
parentPort: string;
childComponent: ComponentModel;
childBoundaryNodeId: string;
childPort: string;
}
/**
* Group of duplicate nodes
*/
export interface DuplicateGroup {
name: string;
type: string;
instances: {
node: NodeGraphNode;
component: ComponentModel;
connectionCount: number;
}[];
severity: 'info' | 'warning' | 'error';
reason: string;
}
/**
* Conflict analysis for duplicates
*/
export interface ConflictAnalysis {
group: DuplicateGroup;
conflictType: 'name-collision' | 'state-conflict' | 'data-race';
description: string;
affectedNodes: string[];
}
/**
* Categorized nodes by type
*/
export interface CategorizedNodes {
byCategory: Map<NodeCategory, NodeGraphNode[]>;
byType: Map<string, NodeGraphNode[]>;
totals: { category: NodeCategory; count: number }[];
}

View File

@@ -0,0 +1,274 @@
/**
* TriggerChainRecorder
*
* Singleton class that records runtime execution events for the
* Trigger Chain Debugger. Captures node activations, signals,
* and data flow as they happen in the preview.
*
* @module triggerChain
*/
import { ProjectModel } from '../../models/projectmodel';
import { RecorderOptions, RecorderState, TriggerEvent } from './types';
/**
* Singleton recorder for capturing runtime execution events
*/
export class TriggerChainRecorder {
private static _instance: TriggerChainRecorder;
private state: RecorderState;
private recentEventKeys: Map<string, number>; // Key: nodeId+port, Value: timestamp
private readonly DUPLICATE_THRESHOLD_MS = 5; // Consider events within 5ms as duplicates
/**
* Private constructor - use getInstance() instead
*/
private constructor() {
this.state = {
isRecording: false,
events: [],
maxEvents: 1000
};
this.recentEventKeys = new Map();
}
/**
* Get the singleton instance
*/
public static getInstance(): TriggerChainRecorder {
if (!TriggerChainRecorder._instance) {
TriggerChainRecorder._instance = new TriggerChainRecorder();
}
return TriggerChainRecorder._instance;
}
/**
* Start recording events
*
* @param options - Recording configuration options
*/
public startRecording(options?: RecorderOptions): void {
if (this.state.isRecording) {
console.warn('TriggerChainRecorder: Already recording');
return;
}
// Apply options
if (options?.maxEvents) {
this.state.maxEvents = options.maxEvents;
}
// Reset state and start
this.state.events = [];
this.recentEventKeys.clear(); // Clear deduplication map
this.state.startTime = performance.now();
this.state.isRecording = true;
console.log('TriggerChainRecorder: Recording started');
// Auto-stop if configured
if (options?.autoStopAfter) {
setTimeout(() => {
this.stopRecording();
}, options.autoStopAfter);
}
}
/**
* Stop recording and return captured events
*
* @returns Array of captured events
*/
public stopRecording(): TriggerEvent[] {
if (!this.state.isRecording) {
console.warn('TriggerChainRecorder: Not recording');
return [];
}
this.state.isRecording = false;
const events = [...this.state.events];
console.log(`TriggerChainRecorder: Recording stopped. Captured ${events.length} events`);
return events;
}
/**
* Reset recorder state (clear all events)
*/
public reset(): void {
this.state.events = [];
this.state.startTime = undefined;
this.state.isRecording = false;
console.log('TriggerChainRecorder: Reset');
}
/**
* Check if currently recording
*/
public isRecording(): boolean {
return this.state.isRecording;
}
/**
* Get current event count
*/
public getEventCount(): number {
return this.state.events.length;
}
/**
* Get all recorded events (without stopping)
*/
public getEvents(): TriggerEvent[] {
return [...this.state.events];
}
/**
* Get current recorder state
*/
public getState(): RecorderState {
return { ...this.state };
}
/**
* Capture a new event (internal method called from ViewerConnection)
*
* @param event - Event data from runtime
*/
public captureEvent(event: TriggerEvent): void {
// Only capture if recording
if (!this.state.isRecording) {
return;
}
// Check max events limit
if (this.state.events.length >= this.state.maxEvents) {
console.warn(`TriggerChainRecorder: Max events (${this.state.maxEvents}) reached. Oldest event will be dropped.`);
this.state.events.shift(); // Remove oldest
}
// Add event to array
this.state.events.push(event);
}
/**
* Generate a unique event ID
*/
private generateEventId(): string {
return `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Helper: Create and capture an event from connection pulse data
* This bridges the existing DebugInspector connection pulse to our recorder
*
* @param connectionId - Connection ID from DebugInspector
* @param data - Optional data flowing through connection
*/
public captureConnectionPulse(connectionId: string, data?: unknown): void {
if (!this.state.isRecording) {
return;
}
const currentTime = performance.now();
// Extract UUIDs from connectionId using regex
// OpenNoodl uses standard UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
const uuids = connectionId.match(uuidRegex) || [];
// Try to find a valid node from extracted UUIDs
let targetNodeId: string | undefined;
let foundNode: unknown = null;
let nodeType = 'Unknown';
let nodeLabel = 'Unknown';
let componentName = 'Unknown';
const componentPath: string[] = [];
try {
if (ProjectModel.instance && uuids.length > 0) {
// Try each UUID until we find a valid node
for (const uuid of uuids) {
const node = ProjectModel.instance.findNodeWithId(uuid);
if (node) {
targetNodeId = uuid;
foundNode = node;
break;
}
}
if (foundNode && typeof foundNode === 'object') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodeObj = foundNode as Record<string, any>;
// Extract node type
nodeType = nodeObj.type?.name || nodeObj.type || 'Unknown';
// Extract node label (try different properties)
nodeLabel = nodeObj.parameters?.label || nodeObj.label || nodeObj.parameters?.name || nodeType;
// Extract component name
if (nodeObj.owner?.owner) {
componentName = nodeObj.owner.owner.name || 'Unknown';
componentPath.push(componentName);
}
}
}
} catch (error) {
console.warn('TriggerChainRecorder: Error looking up node:', error);
}
// Use first UUID as fallback if no node found
if (!targetNodeId && uuids.length > 0) {
targetNodeId = uuids[0];
}
// Deduplication: Create a unique key for this event
// Using connectionId directly as it contains both node IDs and port info
const eventKey = connectionId;
// Check if we recently captured the same event
const lastEventTime = this.recentEventKeys.get(eventKey);
if (lastEventTime !== undefined) {
const timeSinceLastEvent = currentTime - lastEventTime;
if (timeSinceLastEvent < this.DUPLICATE_THRESHOLD_MS) {
// This is a duplicate event - skip it
return;
}
}
// Update the timestamp for this event key
this.recentEventKeys.set(eventKey, currentTime);
// Clean up old entries periodically (keep map from growing too large)
if (this.recentEventKeys.size > 100) {
const cutoffTime = currentTime - this.DUPLICATE_THRESHOLD_MS * 2;
for (const [key, timestamp] of this.recentEventKeys.entries()) {
if (timestamp < cutoffTime) {
this.recentEventKeys.delete(key);
}
}
}
const event: TriggerEvent = {
id: this.generateEventId(),
timestamp: currentTime,
type: 'signal',
nodeId: targetNodeId,
nodeType,
nodeLabel,
componentName,
componentPath,
port: undefined, // Port name extraction not yet implemented
data
};
this.captureEvent(event);
}
}
// Export singleton instance
export const triggerChainRecorder = TriggerChainRecorder.getInstance();

View File

@@ -0,0 +1,217 @@
/**
* Chain Builder
*
* Transforms raw TriggerEvents into structured TriggerChain objects
* that can be visualized in the timeline UI.
*
* @module triggerChain
*/
import { TriggerChain, TriggerChainNode, EventTiming, ChainStatistics } from './chainTypes';
import { TriggerEvent } from './types';
/**
* Build a complete chain from an array of events
*
* @param events - Raw events from the recorder
* @param name - Optional name for the chain (auto-generated if not provided)
* @returns Structured trigger chain
*
* @example
* ```typescript
* const events = recorder.stopRecording();
* const chain = buildChainFromEvents(events);
* console.log(`Chain duration: ${chain.duration}ms`);
* ```
*/
export function buildChainFromEvents(events: TriggerEvent[], name?: string): TriggerChain {
if (events.length === 0) {
throw new Error('Cannot build chain from empty events array');
}
// Sort by timestamp
const sortedEvents = [...events].sort((a, b) => a.timestamp - b.timestamp);
const startTime = sortedEvents[0].timestamp;
const endTime = sortedEvents[sortedEvents.length - 1].timestamp;
const duration = endTime - startTime;
// Build component grouping
const byComponent = groupByComponent(sortedEvents);
// Build tree structure
const tree = buildTree(sortedEvents);
// Generate name if not provided
const chainName = name || generateChainName(sortedEvents);
return {
id: `chain_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: chainName,
startTime,
endTime,
duration,
eventCount: sortedEvents.length,
events: sortedEvents,
byComponent,
tree
};
}
/**
* Group events by component name
*
* @param events - Events to group
* @returns Map of component name to events
*/
export function groupByComponent(events: TriggerEvent[]): Map<string, TriggerEvent[]> {
const grouped = new Map<string, TriggerEvent[]>();
for (const event of events) {
const componentName = event.componentName;
if (!grouped.has(componentName)) {
grouped.set(componentName, []);
}
grouped.get(componentName)!.push(event);
}
return grouped;
}
/**
* Build hierarchical tree structure from flat event list
*
* @param events - Sorted events
* @returns Root node of the tree
*/
export function buildTree(events: TriggerEvent[]): TriggerChainNode {
if (events.length === 0) {
throw new Error('Cannot build tree from empty events array');
}
// For now, create a simple linear tree
// TODO: In the future, use triggeredBy relationships to build proper tree
const root: TriggerChainNode = {
event: events[0],
children: [],
depth: 0,
deltaFromParent: 0
};
let currentNode = root;
for (let i = 1; i < events.length; i++) {
const node: TriggerChainNode = {
event: events[i],
children: [],
depth: i, // Simple linear depth for now
deltaFromParent: events[i].timestamp - events[i - 1].timestamp
};
currentNode.children.push(node);
currentNode = node;
}
return root;
}
/**
* Calculate timing information for all events in a chain
*
* @param chain - The trigger chain
* @returns Array of timing info for each event
*/
export function calculateTiming(chain: TriggerChain): EventTiming[] {
const timings: EventTiming[] = [];
const startTime = chain.startTime;
for (let i = 0; i < chain.events.length; i++) {
const event = chain.events[i];
const sinceStart = event.timestamp - startTime;
const sincePrevious = i === 0 ? 0 : event.timestamp - chain.events[i - 1].timestamp;
timings.push({
eventId: event.id,
sinceStart,
sincePrevious,
durationLabel: formatDuration(sincePrevious)
});
}
return timings;
}
/**
* Calculate statistics about a chain
*
* @param chain - The trigger chain
* @returns Statistics object
*/
export function calculateStatistics(chain: TriggerChain): ChainStatistics {
const eventsByType = new Map<string, number>();
const eventsByComponent = new Map<string, number>();
const components = new Set<string>();
for (const event of chain.events) {
// Count by type
const typeCount = eventsByType.get(event.type) || 0;
eventsByType.set(event.type, typeCount + 1);
// Count by component
const compCount = eventsByComponent.get(event.componentName) || 0;
eventsByComponent.set(event.componentName, compCount + 1);
components.add(event.componentName);
}
// Calculate gaps
const gaps: number[] = [];
for (let i = 1; i < chain.events.length; i++) {
gaps.push(chain.events[i].timestamp - chain.events[i - 1].timestamp);
}
const averageEventGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;
const longestGap = gaps.length > 0 ? Math.max(...gaps) : 0;
return {
totalEvents: chain.events.length,
eventsByType,
eventsByComponent,
averageEventGap,
longestGap,
componentsInvolved: Array.from(components)
};
}
/**
* Generate a descriptive name for a chain based on its events
*
* @param events - Events in the chain
* @returns Generated name
*/
function generateChainName(events: TriggerEvent[]): string {
if (events.length === 0) return 'Empty Chain';
const firstEvent = events[0];
const eventCount = events.length;
// Try to create meaningful name from first event
if (firstEvent.nodeLabel && firstEvent.nodeLabel !== 'Unknown') {
return `${firstEvent.nodeLabel} (${eventCount} events)`;
}
// Fallback to type-based name
return `${firstEvent.type} chain (${eventCount} events)`;
}
/**
* Format duration as human-readable string
*
* @param ms - Duration in milliseconds
* @returns Formatted string (e.g., "2ms", "1.5s")
*/
function formatDuration(ms: number): string {
if (ms < 1) return '<1ms';
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}

View File

@@ -0,0 +1,99 @@
/**
* Chain Builder Type Definitions
*
* Types for organizing raw TriggerEvents into structured chains
* that can be displayed in the timeline UI.
*
* @module triggerChain
*/
import { TriggerEvent } from './types';
/**
* A complete trigger chain - represents one recorded interaction
*/
export interface TriggerChain {
/** Unique chain ID */
id: string;
/** User-friendly name (auto-generated or user-provided) */
name: string;
/** When the chain started (first event timestamp) */
startTime: number;
/** When the chain ended (last event timestamp) */
endTime: number;
/** Total duration in milliseconds */
duration: number;
/** Total number of events */
eventCount: number;
/** All events in chronological order */
events: TriggerEvent[];
/** Events grouped by component name */
byComponent: Map<string, TriggerEvent[]>;
/** Hierarchical tree structure for rendering */
tree: TriggerChainNode;
}
/**
* Tree node for hierarchical chain visualization
*/
export interface TriggerChainNode {
/** The event at this node */
event: TriggerEvent;
/** Child events triggered by this one */
children: TriggerChainNode[];
/** Depth in the tree (0 = root) */
depth: number;
/** Time delta from parent (ms) */
deltaFromParent: number;
}
/**
* Timing information for display
*/
export interface EventTiming {
/** Event ID */
eventId: string;
/** Time since chain start (ms) */
sinceStart: number;
/** Time since previous event (ms) */
sincePrevious: number;
/** Duration as human-readable string */
durationLabel: string;
}
/**
* Statistics about a chain
*/
export interface ChainStatistics {
/** Total events */
totalEvents: number;
/** Events by type */
eventsByType: Map<string, number>;
/** Events by component */
eventsByComponent: Map<string, number>;
/** Average time between events (ms) */
averageEventGap: number;
/** Longest gap between events (ms) */
longestGap: number;
/** Components involved */
componentsInvolved: string[];
}

View File

@@ -0,0 +1,21 @@
/**
* Trigger Chain Debugger Module
*
* Exports recorder, chain builder, and types for the Trigger Chain Debugger feature.
*
* @module triggerChain
*/
// Recorder
export { TriggerChainRecorder, triggerChainRecorder } from './TriggerChainRecorder';
export type { TriggerEvent, TriggerEventType, RecorderOptions, RecorderState } from './types';
// Chain Builder
export {
buildChainFromEvents,
groupByComponent,
buildTree,
calculateTiming,
calculateStatistics
} from './chainBuilder';
export type { TriggerChain, TriggerChainNode, EventTiming, ChainStatistics } from './chainTypes';

View File

@@ -0,0 +1,93 @@
/**
* Type definitions for the Trigger Chain Debugger
*
* These types define the structure of events captured during runtime
* execution and how they're organized into chains for debugging.
*
* @module triggerChain
*/
/**
* Types of events that can be captured during execution
*/
export type TriggerEventType =
| 'signal' // Signal fired (e.g., onClick)
| 'value-change' // Value changed on a port
| 'component-enter' // Entering a child component
| 'component-exit' // Exiting a child component
| 'api-call' // API request started (REST, etc.)
| 'api-response' // API response received
| 'navigation' // Page navigation
| 'error'; // Error occurred
/**
* A single event captured during execution
*/
export interface TriggerEvent {
/** Unique event ID */
id: string;
/** High-resolution timestamp (performance.now()) */
timestamp: number;
/** Type of event */
type: TriggerEventType;
/** Node that triggered this event */
nodeId: string;
/** Node type (e.g., 'Button', 'Variable', 'REST') */
nodeType: string;
/** User-visible node label */
nodeLabel: string;
/** Component containing this node */
componentName: string;
/** Full component path for nested components */
componentPath: string[];
/** Port that triggered this event (if applicable) */
port?: string;
/** Data flowing through this event */
data?: unknown;
/** Error information (if type === 'error') */
error?: {
message: string;
stack?: string;
};
/** ID of event that caused this one (for causal chain) */
triggeredBy?: string;
}
/**
* State of the recorder
*/
export interface RecorderState {
/** Is recording active? */
isRecording: boolean;
/** When recording started */
startTime?: number;
/** Captured events */
events: TriggerEvent[];
/** Maximum events to store (prevents memory issues) */
maxEvents: number;
}
/**
* Configuration options for the recorder
*/
export interface RecorderOptions {
/** Maximum events to store (default: 1000) */
maxEvents?: number;
/** Auto-stop after duration (ms) (default: none) */
autoStopAfter?: number;
}

View File

@@ -0,0 +1,111 @@
/**
* BoundaryIndicator component styles
*
* Floating badge showing where cross-component paths continue.
* Uses design tokens for consistency.
*/
.boundaryIndicator {
position: absolute;
pointer-events: auto;
z-index: 1001; /* Above highlighted nodes */
transform: translate(-50%, -50%); /* Center on position */
/* Animation */
animation: fade-in 0.3s ease-out;
}
.content {
display: flex;
align-items: center;
gap: var(--theme-spacing-2);
padding: var(--theme-spacing-2) var(--theme-spacing-3);
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
/* Smooth hover transition */
transition: all 0.2s ease;
&:hover {
transform: scale(1.02);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}
}
.icon {
font-size: 16px;
font-weight: bold;
color: var(--theme-color-fg-highlight);
min-width: 16px;
text-align: center;
}
.label {
display: flex;
flex-direction: column;
gap: 2px;
}
.text {
font-size: 11px;
color: var(--theme-color-fg-default-shy);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.componentName {
font-size: 13px;
color: var(--theme-color-fg-default);
font-weight: 600;
}
.navigateButton {
padding: var(--theme-spacing-1) var(--theme-spacing-2);
background-color: var(--theme-color-primary);
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: var(--theme-color-primary-hover);
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
/* Direction-specific styling */
.directionUp {
.icon {
color: var(--theme-color-accent-blue);
}
}
.directionDown {
.icon {
color: var(--theme-color-accent-orange);
}
}
/* Animations */
@keyframes fade-in {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}

View File

@@ -0,0 +1,85 @@
/**
* BoundaryIndicator - Visual indicator for cross-component path boundaries
*
* Displays a floating badge when a highlighted path continues into a parent or child component.
* Shows the component name and provides a navigation button to jump to that component.
*
* @example
* ```tsx
* <BoundaryIndicator
* boundary={{
* fromComponent: 'App',
* toComponent: 'UserCard',
* direction: 'down',
* edgeNodeId: 'node-123'
* }}
* position={{ x: 100, y: 200 }}
* onNavigate={(componentName) => editor.switchToComponent(componentName)}
* />
* ```
*/
import React from 'react';
import type { ComponentBoundary } from '../../../services/HighlightManager';
import css from './BoundaryIndicator.module.scss';
export interface BoundaryIndicatorProps {
/** Component boundary information */
boundary: ComponentBoundary;
/** Position on canvas (canvas coordinates) */
position: {
x: number;
y: number;
};
/** Callback when user clicks navigation button */
onNavigate: (componentName: string) => void;
}
/**
* BoundaryIndicator component
*
* Renders a floating badge indicating that a highlighted path continues into another component.
* Includes a navigation button to jump to that component.
*/
export function BoundaryIndicator({ boundary, position, onNavigate }: BoundaryIndicatorProps) {
const isGoingUp = boundary.direction === 'up';
const targetComponent = boundary.toComponent;
const handleNavigate = (e: React.MouseEvent) => {
e.stopPropagation();
onNavigate(targetComponent);
};
// Position the indicator
const style: React.CSSProperties = {
left: `${position.x}px`,
top: `${position.y}px`
};
return (
<div
className={`${css.boundaryIndicator} ${isGoingUp ? css.directionUp : css.directionDown}`}
style={style}
data-boundary-id={`${boundary.fromComponent}-${boundary.toComponent}`}
>
<div className={css.content}>
<div className={css.icon}>{isGoingUp ? '↑' : '↓'}</div>
<div className={css.label}>
<div className={css.text}>Path continues in</div>
<div className={css.componentName}>{targetComponent}</div>
</div>
<button
className={css.navigateButton}
onClick={handleNavigate}
title={`Navigate to ${targetComponent}`}
type="button"
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
/**
* HighlightOverlay container styles
*
* Main overlay container for rendering highlights over the canvas.
* Uses CSS transform pattern for automatic coordinate mapping.
*/
.highlightOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
z-index: 100; // Above canvas but below UI elements
}
.highlightContainer {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
pointer-events: none;
// Transform applied inline via style prop:
// transform: translate(viewportX, viewportY) scale(zoom)
// This automatically maps all child coordinates to canvas space
}

View File

@@ -0,0 +1,139 @@
/**
* HighlightOverlay - Main overlay component for canvas highlights
*
* Renders persistent, multi-channel highlights over the node graph canvas.
* Uses the canvas overlay pattern with CSS transform for coordinate mapping.
*
* Features:
* - Subscribes to HighlightManager events via useEventListener
* - Renders node and connection highlights
* - Supports multiple visual styles (glow, pulse, solid)
* - Handles viewport transformations automatically via CSS
*/
import { useEventListener } from '@noodl-hooks/useEventListener';
import React, { useState, useEffect } from 'react';
import { HighlightManager, type HighlightInfo } from '../../../services/HighlightManager';
import { BoundaryIndicator } from './BoundaryIndicator';
import { HighlightedConnection } from './HighlightedConnection';
import { HighlightedNode } from './HighlightedNode';
import css from './HighlightOverlay.module.scss';
export interface HighlightOverlayProps {
/** Canvas viewport transformation */
viewport: {
x: number;
y: number;
zoom: number;
};
/** Get node screen coordinates by ID */
getNodeBounds?: (nodeId: string) => {
x: number;
y: number;
width: number;
height: number;
} | null;
}
/**
* HighlightOverlay component
*
* @example
* ```tsx
* <HighlightOverlay
* viewport={{ x: 0, y: 0, zoom: 1.0 }}
* getNodeBounds={(id) => nodeEditor.getNodeBounds(id)}
* />
* ```
*/
export function HighlightOverlay({ viewport, getNodeBounds }: HighlightOverlayProps) {
const [highlights, setHighlights] = useState<HighlightInfo[]>([]);
// Subscribe to HighlightManager events using Phase 0 pattern
useEventListener(HighlightManager.instance, 'highlightAdded', () => {
setHighlights(HighlightManager.instance.getHighlights());
});
useEventListener(HighlightManager.instance, 'highlightRemoved', () => {
setHighlights(HighlightManager.instance.getHighlights());
});
useEventListener(HighlightManager.instance, 'highlightUpdated', () => {
setHighlights(HighlightManager.instance.getHighlights());
});
useEventListener(HighlightManager.instance, 'channelCleared', () => {
setHighlights(HighlightManager.instance.getHighlights());
});
useEventListener(HighlightManager.instance, 'allCleared', () => {
setHighlights([]);
});
// Initial load
useEffect(() => {
setHighlights(HighlightManager.instance.getHighlights());
}, []);
// Apply viewport transformation to the container
// CRITICAL: Transform order must be scale THEN translate to match canvas rendering
// Canvas does: ctx.scale() then ctx.translate() then draws at node.global coords
// CSS transforms apply right-to-left, so "scale() translate()" = scale(translate(point))
// This computes: scale * (pan + nodePos) which matches the canvas
const containerStyle: React.CSSProperties = {
transform: `scale(${viewport.zoom}) translate(${viewport.x}px, ${viewport.y}px)`,
transformOrigin: '0 0'
};
return (
<div className={css.highlightOverlay}>
<div className={css.highlightContainer} style={containerStyle}>
{highlights.map((highlight) => (
<React.Fragment key={highlight.id}>
{/* Render node highlights */}
{highlight.nodeIds.map((nodeId) => {
const bounds = getNodeBounds?.(nodeId);
if (!bounds) return null;
return (
<HighlightedNode
key={`${highlight.id}-${nodeId}`}
nodeId={nodeId}
bounds={bounds}
color={highlight.options.color || '#FFFFFF'}
style={highlight.options.style || 'solid'}
label={highlight.options.label}
/>
);
})}
{/* Render connection highlights */}
{highlight.connections.map((connection, index) => {
const fromBounds = getNodeBounds?.(connection.fromNodeId);
const toBounds = getNodeBounds?.(connection.toNodeId);
if (!fromBounds || !toBounds) return null;
return (
<HighlightedConnection
key={`${highlight.id}-conn-${index}`}
connection={connection}
fromBounds={fromBounds}
toBounds={toBounds}
color={highlight.options.color || '#FFFFFF'}
style={highlight.options.style || 'solid'}
/>
);
})}
{/* Render boundary indicators for cross-component paths */}
{/* TODO: Get boundaries from HighlightManager state once detection is implemented */}
{/* For now, this will render when componentBoundaries are added to highlights */}
</React.Fragment>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
/**
* HighlightedConnection styles
*
* Styles for connection path highlight overlays with different visual effects.
*/
.highlightedConnection {
position: absolute;
pointer-events: none;
overflow: visible;
z-index: 999; // Below nodes (1000) but above canvas
// Pulse path - animated overlay for pulse effect
.pulsePath {
animation: connection-pulse 1.5s ease-in-out infinite;
}
}
// Solid style - simple static path
.solid {
// Base styles applied from .highlightedConnection
// No animations
}
// Glow style - constant glow via SVG filter
.glow {
// Filter applied inline in component
animation: glow-breathe 2s ease-in-out infinite;
}
// Pulse style - animated path
.pulse {
// Animation applied to .pulsePath child
}
// Glow breathing animation
@keyframes glow-breathe {
0%,
100% {
opacity: 0.8;
}
50% {
opacity: 1;
}
}
// Connection pulse animation
@keyframes connection-pulse {
0%,
100% {
stroke-dashoffset: 0;
opacity: 0.8;
}
50% {
stroke-dashoffset: 10;
opacity: 1;
}
}

View File

@@ -0,0 +1,131 @@
/**
* HighlightedConnection - Renders a highlight along a connection path
*
* Displays visual highlight effect along canvas connection paths with support for
* different styles (solid, glow, pulse) and custom colors.
*/
import classNames from 'classnames';
import React, { useMemo } from 'react';
import type { ConnectionRef } from '../../../services/HighlightManager/types';
import css from './HighlightedConnection.module.scss';
export interface HighlightedConnectionProps {
/** Connection being highlighted */
connection: ConnectionRef;
/** Source node position and dimensions */
fromBounds: {
x: number;
y: number;
width: number;
height: number;
};
/** Target node position and dimensions */
toBounds: {
x: number;
y: number;
width: number;
height: number;
};
/** Highlight color */
color: string;
/** Visual style */
style: 'solid' | 'glow' | 'pulse';
}
/**
* HighlightedConnection component
*
* Renders an SVG path from the source node's right edge to the target node's left edge,
* using a bezier curve similar to the actual connection rendering.
*/
export function HighlightedConnection({ connection, fromBounds, toBounds, color, style }: HighlightedConnectionProps) {
// Calculate connection path
const pathData = useMemo(() => {
// Start point: right edge of source node
const x1 = fromBounds.x + fromBounds.width;
const y1 = fromBounds.y + fromBounds.height / 2;
// End point: left edge of target node
const x2 = toBounds.x;
const y2 = toBounds.y + toBounds.height / 2;
// Bezier control points for smooth curve
const dx = Math.abs(x2 - x1);
const curve = Math.min(dx * 0.5, 100); // Max curve of 100px
const cx1 = x1 + curve;
const cy1 = y1;
const cx2 = x2 - curve;
const cy2 = y2;
return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
}, [fromBounds, toBounds]);
// Calculate SVG viewBox to encompass the path
const viewBox = useMemo(() => {
const x1 = fromBounds.x + fromBounds.width;
const y1 = fromBounds.y + fromBounds.height / 2;
const x2 = toBounds.x;
const y2 = toBounds.y + toBounds.height / 2;
const minX = Math.min(x1, x2) - 20; // Add padding for glow
const minY = Math.min(y1, y2) - 20;
const maxX = Math.max(x1, x2) + 20;
const maxY = Math.max(y1, y2) + 20;
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
};
}, [fromBounds, toBounds]);
// SVG filter IDs must be unique per instance
const filterId = useMemo(() => `highlight-glow-${connection.fromNodeId}-${connection.toNodeId}`, [connection]);
return (
<svg
className={classNames(css.highlightedConnection, css[style])}
style={{
left: `${viewBox.x}px`,
top: `${viewBox.y}px`,
width: `${viewBox.width}px`,
height: `${viewBox.height}px`
}}
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
xmlns="http://www.w3.org/2000/svg"
data-connection={`${connection.fromNodeId}:${connection.fromPort}-${connection.toNodeId}:${connection.toPort}`}
>
{/* Define glow filter for glow style */}
{style === 'glow' && (
<defs>
<filter id={filterId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="4" />
</filter>
</defs>
)}
{/* Render the connection path */}
<path
d={pathData}
fill="none"
stroke={color}
strokeWidth={style === 'solid' ? 3 : 4}
strokeLinecap="round"
filter={style === 'glow' ? `url(#${filterId})` : undefined}
/>
{/* Additional path for pulse effect (renders on top with animation) */}
{style === 'pulse' && (
<path d={pathData} fill="none" stroke={color} strokeWidth={3} strokeLinecap="round" className={css.pulsePath} />
)}
</svg>
);
}

View File

@@ -0,0 +1,71 @@
/**
* HighlightedNode styles
*
* Styles for node highlight overlays with different visual effects.
*/
.highlightedNode {
position: absolute;
pointer-events: none;
border: 3px solid;
border-radius: 8px;
box-sizing: border-box;
z-index: 1000;
// Label styling
.label {
position: absolute;
top: -24px;
left: 50%;
transform: translateX(-50%);
padding: 4px 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 11px;
font-weight: 600;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
}
}
// Solid style - simple static border
.solid {
// Base styles applied from .highlightedNode
// No animations
}
// Glow style - constant glow effect
.glow {
// Box-shadow applied inline via style prop for dynamic color
animation: glow-breathe 2s ease-in-out infinite;
}
// Pulse style - animated scaling effect
.pulse {
animation: pulse-scale 1.5s ease-in-out infinite;
}
// Glow breathing animation
@keyframes glow-breathe {
0%,
100% {
opacity: 0.8;
}
50% {
opacity: 1;
}
}
// Pulse scaling animation
@keyframes pulse-scale {
0%,
100% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.02);
opacity: 1;
}
}

View File

@@ -0,0 +1,54 @@
/**
* HighlightedNode - Renders a highlight around a node
*
* Displays visual highlight effect around canvas nodes with support for
* different styles (solid, glow, pulse) and custom colors.
*/
import classNames from 'classnames';
import React from 'react';
import css from './HighlightedNode.module.scss';
export interface HighlightedNodeProps {
/** Node ID being highlighted */
nodeId: string;
/** Node position and dimensions */
bounds: {
x: number;
y: number;
width: number;
height: number;
};
/** Highlight color */
color: string;
/** Visual style */
style: 'solid' | 'glow' | 'pulse';
/** Optional label */
label?: string;
}
/**
* HighlightedNode component
*/
export function HighlightedNode({ nodeId, bounds, color, style, label }: HighlightedNodeProps) {
const highlightStyle: React.CSSProperties = {
left: `${bounds.x}px`,
top: `${bounds.y}px`,
width: `${bounds.width}px`,
height: `${bounds.height}px`,
borderColor: color,
boxShadow:
style === 'glow' ? `0 0 20px ${color}, 0 0 10px ${color}` : style === 'pulse' ? `0 0 15px ${color}` : undefined
};
return (
<div className={classNames(css.highlightedNode, css[style])} style={highlightStyle} data-node-id={nodeId}>
{label && <div className={css.label}>{label}</div>}
</div>
);
}

View File

@@ -0,0 +1,18 @@
/**
* HighlightOverlay exports
*
* Canvas highlight overlay components for rendering persistent highlights
* over nodes and connections.
*/
export { HighlightOverlay } from './HighlightOverlay';
export type { HighlightOverlayProps } from './HighlightOverlay';
export { HighlightedNode } from './HighlightedNode';
export type { HighlightedNodeProps } from './HighlightedNode';
export { HighlightedConnection } from './HighlightedConnection';
export type { HighlightedConnectionProps } from './HighlightedConnection';
export { BoundaryIndicator } from './BoundaryIndicator';
export type { BoundaryIndicatorProps } from './BoundaryIndicator';

View File

@@ -90,8 +90,12 @@ export function SidePanel() {
item.onClick && item.onClick();
}
// Check if topology panel is active for expanded view
const isExpanded = activeId === 'topology';
return (
<SideNavigation
isExpanded={isExpanded}
onExitClick={() => App.instance.exitProject()}
toolbar={
<>

View File

@@ -35,10 +35,14 @@ import {
import { NodeLibrary } from '../models/nodelibrary';
import { ProjectModel } from '../models/projectmodel';
import { WarningsModel } from '../models/warningsmodel';
import { HighlightManager } from '../services/HighlightManager';
import DebugInspector from '../utils/debuginspector';
import { rectanglesOverlap, guid } from '../utils/utils';
import { ViewerConnection } from '../ViewerConnection';
import { HighlightOverlay } from './CanvasOverlays/HighlightOverlay';
import CommentLayer from './commentlayer';
// Import test utilities for console debugging (dev only)
import '../services/HighlightManager/test-highlights';
import { ConnectionPopup } from './ConnectionPopup';
import { CreateNewNodePanel } from './createnewnodepanel';
import { TitleBar } from './documents/EditorDocument/titlebar';
@@ -229,6 +233,7 @@ export class NodeGraphEditor extends View {
toolbarRoots: Root[] = [];
titleRoot: Root = null;
highlightOverlayRoot: Root = null;
constructor(args) {
super();
@@ -400,6 +405,12 @@ export class NodeGraphEditor extends View {
this.commentLayer && this.commentLayer.dispose();
// Clean up React roots
if (this.highlightOverlayRoot) {
this.highlightOverlayRoot.unmount();
this.highlightOverlayRoot = null;
}
SidebarModel.instance.off(this);
this.reset();
@@ -772,6 +783,11 @@ export class NodeGraphEditor extends View {
render() {
const _this = this;
// Expose editor instance to window for console debugging (dev only)
// Used by test utilities: window.testHighlightManager.testBasicHighlight()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).__nodeGraphEditor = this;
this.el = this.bindView($(NodeGraphEditorTemplate), this);
this.domElementContainer = this.el.find('#nodegraph-dom-layer').get(0);
@@ -860,12 +876,75 @@ export class NodeGraphEditor extends View {
this.commentLayer.renderTo(this.el.find('#comment-layer-bg').get(0), this.el.find('#comment-layer-fg').get(0));
}, 1);
// Render the highlight overlay
setTimeout(() => {
this.renderHighlightOverlay();
}, 1);
this.relayout();
this.repaint();
return this.el;
}
/**
* Get node bounds for the highlight overlay
* Maps node IDs to their screen coordinates
*/
getNodeBounds = (nodeId: string) => {
const node = this.findNodeWithId(nodeId);
if (!node) return null;
return {
x: node.global.x,
y: node.global.y,
width: node.nodeSize.width,
height: node.nodeSize.height
};
};
/**
* Render the HighlightOverlay React component
*/
renderHighlightOverlay() {
const overlayElement = this.el.find('#highlight-overlay-layer').get(0);
if (!overlayElement) {
console.warn('Highlight overlay layer not found in DOM');
return;
}
// Create React root if it doesn't exist
if (!this.highlightOverlayRoot) {
this.highlightOverlayRoot = createRoot(overlayElement);
}
// Get current viewport state
const panAndScale = this.getPanAndScale();
const viewport = {
x: panAndScale.x,
y: panAndScale.y,
zoom: panAndScale.scale
};
// Render the overlay
this.highlightOverlayRoot.render(
React.createElement(HighlightOverlay, {
viewport,
getNodeBounds: this.getNodeBounds
})
);
}
/**
* Update the highlight overlay with new viewport state
* Called whenever pan/zoom changes
*/
updateHighlightOverlay() {
if (this.highlightOverlayRoot) {
this.renderHighlightOverlay();
}
}
// This is called by the parent view (frames view) when the size and position
// changes
resize(layout) {
@@ -1260,6 +1339,7 @@ export class NodeGraphEditor extends View {
};
panAndScale = this.clampPanAndScale(panAndScale);
this.setPanAndScale(panAndScale);
this.updateHighlightOverlay();
this.relayout();
this.repaint();
@@ -1378,6 +1458,7 @@ export class NodeGraphEditor extends View {
panAndScale.y += dy;
panAndScale = this.clampPanAndScale(panAndScale);
this.setPanAndScale(panAndScale);
this.updateHighlightOverlay();
/* for(var i in this.roots) {
this.roots[i].x += dx;
@@ -1517,6 +1598,9 @@ export class NodeGraphEditor extends View {
this.commentLayer.setComponentModel(undefined);
// Clear all highlights when closing/switching away from component
HighlightManager.instance.clearAll();
return;
}
@@ -1526,6 +1610,9 @@ export class NodeGraphEditor extends View {
if (this.activeComponent !== component) {
this.activeComponent?.off(this);
// Clear highlights when switching to a different component
HighlightManager.instance.clearAll();
this.activeComponent = component;
if (args?.replaceHistory) {
@@ -1550,6 +1637,9 @@ export class NodeGraphEditor extends View {
this
);
// Notify HighlightManager of component change for cross-component path highlighting
HighlightManager.instance.setCurrentComponent(component.fullName);
EventDispatcher.instance.emit('activeComponentChanged', { component });
}
@@ -1788,10 +1878,10 @@ export class NodeGraphEditor extends View {
// @ts-expect-error
toProps.sourcePort = fromPort;
toProps.disabled = false;
createRoot(toDiv).render(React.createElement(ConnectionPopup, toProps));
toRoot.render(React.createElement(ConnectionPopup, toProps));
fromProps.disabled = true;
createRoot(fromDiv).render(React.createElement(ConnectionPopup, fromProps));
fromRoot.render(React.createElement(ConnectionPopup, fromProps));
fromNode.borderHighlighted = false;
toNode.borderHighlighted = true;
@@ -1799,8 +1889,8 @@ export class NodeGraphEditor extends View {
}
};
const fromDiv = document.createElement('div');
const root = createRoot(fromDiv);
root.render(React.createElement(ConnectionPopup, fromProps));
const fromRoot = createRoot(fromDiv);
fromRoot.render(React.createElement(ConnectionPopup, fromProps));
const fromPosition = toNode.global.x > fromNodeXPos ? 'left' : 'right';
@@ -1818,7 +1908,7 @@ export class NodeGraphEditor extends View {
y: (fromNode.global.y + panAndScale.y) * panAndScale.scale + tl[1] + 20 * panAndScale.scale
},
onClose: () => {
root.unmount();
fromRoot.unmount();
ipcRenderer.send('viewer-show');
}
});
@@ -1852,10 +1942,10 @@ export class NodeGraphEditor extends View {
// @ts-expect-error
toProps.sourcePort = undefined;
toProps.disabled = true;
createRoot(toDiv).render(React.createElement(ConnectionPopup, toProps));
toRoot.render(React.createElement(ConnectionPopup, toProps));
fromProps.disabled = false;
createRoot(fromDiv).render(React.createElement(ConnectionPopup, fromProps));
fromRoot.render(React.createElement(ConnectionPopup, fromProps));
fromNode.borderHighlighted = true;
toNode.borderHighlighted = false;
@@ -1864,7 +1954,8 @@ export class NodeGraphEditor extends View {
}
};
const toDiv = document.createElement('div');
createRoot(toDiv).render(React.createElement(ConnectionPopup, toProps));
const toRoot = createRoot(toDiv);
toRoot.render(React.createElement(ConnectionPopup, toProps));
const toPosition = fromNodeXPos >= toNode.global.x ? 'left' : 'right';
const toPopout = PopupLayer.instance.showPopout({
@@ -1879,7 +1970,7 @@ export class NodeGraphEditor extends View {
y: (toNode.global.y + panAndScale.y) * panAndScale.scale + tl[1] + 20 * panAndScale.scale
},
onClose: () => {
root.unmount();
toRoot.unmount();
this.clearSelection();
this.repaint();
}
@@ -2984,6 +3075,7 @@ export class NodeGraphEditor extends View {
setPanAndScale(panAndScale: PanAndScale) {
this.panAndScale = panAndScale;
this.commentLayer && this.commentLayer.setPanAndScale(panAndScale);
this.updateHighlightOverlay();
}
clampPanAndScale(panAndScale: PanAndScale) {

View File

@@ -0,0 +1,447 @@
/**
* Component X-Ray Panel Styles
*
* Uses design tokens from UI-STYLING-GUIDE.md
*/
.ComponentXRayPanel {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-default);
overflow: hidden;
}
/* =================================================================
Empty State
================================================================= */
.EmptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 24px;
text-align: center;
color: var(--theme-color-fg-default-shy);
h3 {
margin: 16px 0 8px;
font-size: 16px;
font-weight: 500;
color: var(--theme-color-fg-default);
}
p {
margin: 0;
font-size: 13px;
}
}
/* =================================================================
Header
================================================================= */
.Header {
flex-shrink: 0;
padding: 16px;
border-bottom: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-2);
}
.Title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--theme-color-fg-default-shy);
}
}
.ComponentName {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: var(--theme-color-bg-3);
border-radius: 4px;
font-size: 14px;
font-weight: 500;
color: var(--theme-color-fg-highlight);
}
/* =================================================================
Content - Scrollable Area
================================================================= */
.Content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
/* =================================================================
Summary Stats
================================================================= */
.SummaryStats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.Stat {
display: flex;
flex-direction: column;
padding: 12px;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
}
.StatLabel {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--theme-color-fg-default-shy);
margin-bottom: 4px;
}
.StatValue {
font-size: 20px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
}
/* =================================================================
Sections
================================================================= */
.Section {
margin-bottom: 24px;
}
.SectionTitle {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--theme-color-fg-default);
cursor: pointer;
user-select: none;
transition: color 0.15s ease;
&:hover {
color: var(--theme-color-fg-highlight);
}
}
.SectionContent {
padding: 12px;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
}
.NoData {
padding: 12px;
text-align: center;
font-size: 13px;
font-style: italic;
color: var(--theme-color-fg-default-shy);
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
}
/* =================================================================
Usage Items
================================================================= */
.UsageItem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
font-size: 13px;
color: var(--theme-color-fg-default);
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
border-left: 2px solid transparent;
&:hover {
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
border-left-color: var(--theme-color-primary);
}
&:not(:last-child) {
margin-bottom: 4px;
}
}
/* =================================================================
Interface Grid
================================================================= */
.InterfaceGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.InterfaceColumn {
h4 {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--theme-color-fg-default-shy);
}
}
.PortItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
margin-bottom: 4px;
background-color: var(--theme-color-bg-3);
border-radius: 3px;
font-size: 12px;
}
.PortName {
font-weight: 500;
color: var(--theme-color-fg-default);
}
.PortType {
font-size: 11px;
padding: 2px 6px;
background-color: var(--theme-color-bg-1);
border-radius: 2px;
color: var(--theme-color-fg-default-shy);
}
/* =================================================================
Subsections
================================================================= */
.Subsection {
&:not(:last-child) {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--theme-color-border-default);
}
h4 {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
}
/* =================================================================
Subcomponent Items
================================================================= */
.SubcomponentItem {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
font-size: 13px;
color: var(--theme-color-fg-default);
background-color: var(--theme-color-bg-3);
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
border-left: 2px solid transparent;
&:hover {
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
border-left-color: var(--theme-color-primary);
}
&:not(:last-child) {
margin-bottom: 4px;
}
}
/* =================================================================
Node Breakdown
================================================================= */
.BreakdownItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
margin-bottom: 4px;
background-color: var(--theme-color-bg-3);
border-radius: 3px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
border-left: 2px solid transparent;
&:hover {
background-color: var(--theme-color-bg-4);
border-left-color: var(--theme-color-primary);
.CategoryName {
color: var(--theme-color-fg-highlight);
}
}
&.active {
background-color: var(--theme-color-bg-4);
border-left-color: var(--theme-color-primary);
.CategoryName {
color: var(--theme-color-fg-highlight);
}
}
}
.CategoryName {
font-weight: 500;
color: var(--theme-color-fg-default);
text-transform: capitalize;
}
.CategoryCount {
font-weight: 600;
padding: 2px 8px;
background-color: var(--theme-color-bg-1);
border-radius: 2px;
color: var(--theme-color-fg-highlight);
}
/* =================================================================
External Dependencies
================================================================= */
.DependencyItem {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin-bottom: 4px;
background-color: var(--theme-color-bg-3);
border-radius: 3px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
border-left: 2px solid transparent;
&:hover {
background-color: var(--theme-color-bg-4);
border-left-color: var(--theme-color-primary);
.Endpoint {
color: var(--theme-color-fg-highlight);
}
}
}
.Method {
font-weight: 600;
padding: 2px 6px;
background-color: var(--theme-color-primary);
color: white;
border-radius: 2px;
font-size: 10px;
text-transform: uppercase;
}
.Endpoint {
flex: 1;
color: var(--theme-color-fg-default);
font-family: monospace;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.EventItem,
.FunctionItem {
padding: 6px 8px;
margin-bottom: 4px;
background-color: var(--theme-color-bg-3);
border-radius: 3px;
font-size: 12px;
color: var(--theme-color-fg-default);
font-family: monospace;
cursor: pointer;
transition: all 0.15s ease;
border-left: 2px solid transparent;
&:hover {
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
border-left-color: var(--theme-color-primary);
}
}
/* =================================================================
Internal State
================================================================= */
.StateItem {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin-bottom: 4px;
background-color: var(--theme-color-bg-3);
border-radius: 3px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
border-left: 2px solid transparent;
&:hover {
background-color: var(--theme-color-bg-4);
border-left-color: var(--theme-color-primary);
.StateName {
color: var(--theme-color-fg-highlight);
}
}
}
.StateType {
font-weight: 600;
padding: 2px 6px;
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-default-shy);
border-radius: 2px;
font-size: 10px;
text-transform: uppercase;
}
.StateName {
color: var(--theme-color-fg-default);
font-family: monospace;
}

View File

@@ -0,0 +1,355 @@
/**
* Component X-Ray Panel
*
* Shows comprehensive information about the currently active component:
* - Usage locations
* - Component interface (inputs/outputs)
* - Internal structure
* - External dependencies
* - Internal state
*/
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
import React, { useCallback, useState } from 'react';
import { ComponentModel } from '@noodl-models/componentmodel';
import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { HighlightManager } from '../../../services/HighlightManager';
import css from './ComponentXRayPanel.module.scss';
import { useComponentXRay } from './hooks/useComponentXRay';
export function ComponentXRayPanel() {
const xrayData = useComponentXRay();
// Collapsible section state
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({
usedIn: false,
interface: false,
contains: false,
dependencies: false,
state: false
});
// Selected category for highlighting
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const toggleSection = useCallback((section: string) => {
setCollapsed((prev) => ({ ...prev, [section]: !prev[section] }));
}, []);
// Get the current component for node selection
const currentComponent = NodeGraphContextTmp.nodeGraph?.activeComponent;
// Navigation: Switch to a component and optionally select a node
const navigateToComponent = useCallback((component: ComponentModel, nodeToSelect?: NodeGraphNode) => {
NodeGraphContextTmp.switchToComponent(component, {
node: nodeToSelect,
pushHistory: true
});
}, []);
// Node selection: Select a node in the current component by finding it
const selectNodeById = useCallback(
(nodeId: string) => {
if (currentComponent?.graph) {
const node = currentComponent.graph.findNodeWithId(nodeId);
if (node) {
NodeGraphContextTmp.switchToComponent(currentComponent, {
node: node,
pushHistory: false
});
}
}
},
[currentComponent]
);
// Highlight nodes: Highlight multiple nodes in a category with toggle
const highlightCategory = useCallback(
(category: string, nodeIds: string[]) => {
if (nodeIds.length === 0) return;
if (selectedCategory === category) {
// Clicking same category - toggle OFF
HighlightManager.instance.clearChannel('selection');
setSelectedCategory(null);
} else {
// New category - switch highlights
HighlightManager.instance.clearChannel('selection');
HighlightManager.instance.highlightNodes(nodeIds, {
channel: 'selection',
label: `${category} nodes`,
persistent: false
});
setSelectedCategory(category);
}
},
[selectedCategory]
);
if (!xrayData) {
return (
<div className={css['ComponentXRayPanel']}>
<div className={css['EmptyState']}>
<Icon icon={IconName.Search} />
<h3>No Component Selected</h3>
<p>Select a component to view its X-Ray analysis</p>
</div>
</div>
);
}
return (
<div className={css['ComponentXRayPanel']}>
{/* Header */}
<div className={css['Header']}>
<div className={css['Title']}>
<Icon icon={IconName.Search} />
<h2>Component X-Ray</h2>
</div>
<div className={css['ComponentName']}>
<Icon icon={IconName.Component} />
<span>{xrayData.componentFullName}</span>
</div>
</div>
{/* Content - scrollable */}
<div className={css['Content']}>
{/* Summary Stats */}
<div className={css['SummaryStats']}>
<div className={css['Stat']}>
<span className={css['StatLabel']}>Total Nodes</span>
<span className={css['StatValue']}>{xrayData.totalNodes}</span>
</div>
<div className={css['Stat']}>
<span className={css['StatLabel']}>Used In</span>
<span className={css['StatValue']}>{xrayData.usedIn.length} places</span>
</div>
<div className={css['Stat']}>
<span className={css['StatLabel']}>Inputs</span>
<span className={css['StatValue']}>{xrayData.inputs.length}</span>
</div>
<div className={css['Stat']}>
<span className={css['StatLabel']}>Outputs</span>
<span className={css['StatValue']}>{xrayData.outputs.length}</span>
</div>
</div>
{/* Used In Section */}
{xrayData.usedIn.length > 0 && (
<div className={css['Section']}>
<h3 className={css['SectionTitle']} onClick={() => toggleSection('usedIn')}>
<Icon icon={collapsed.usedIn ? IconName.CaretRight : IconName.CaretDown} />
<Icon icon={IconName.Navigate} />
Used In ({xrayData.usedIn.length})
</h3>
{!collapsed.usedIn && (
<div className={css['SectionContent']}>
{xrayData.usedIn.map((usage, idx) => {
// Find the node instance in the parent component
const instanceNode = usage.component.graph.findNodeWithId(usage.instanceNodeIds[0]);
return (
<div
key={idx}
className={css['UsageItem']}
onClick={() => navigateToComponent(usage.component, instanceNode)}
>
<Icon icon={IconName.Component} />
<span>{usage.component.fullName}</span>
</div>
);
})}
</div>
)}
</div>
)}
{/* Interface Section */}
{(xrayData.inputs.length > 0 || xrayData.outputs.length > 0) && (
<div className={css['Section']}>
<h3 className={css['SectionTitle']} onClick={() => toggleSection('interface')}>
<Icon icon={collapsed.interface ? IconName.CaretRight : IconName.CaretDown} />
<Icon icon={IconName.Setting} />
Interface
</h3>
{!collapsed.interface && (
<div className={css['InterfaceGrid']}>
{/* Inputs */}
<div className={css['InterfaceColumn']}>
<h4>Inputs ({xrayData.inputs.length})</h4>
{xrayData.inputs.map((input, idx) => (
<div key={idx} className={css['PortItem']}>
<span className={css['PortName']}>{input.name}</span>
<span className={css['PortType']}>{input.type}</span>
</div>
))}
</div>
{/* Outputs */}
<div className={css['InterfaceColumn']}>
<h4>Outputs ({xrayData.outputs.length})</h4>
{xrayData.outputs.map((output, idx) => (
<div key={idx} className={css['PortItem']}>
<span className={css['PortName']}>{output.name}</span>
<span className={css['PortType']}>{output.type}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* No Interface Message */}
{xrayData.inputs.length === 0 && xrayData.outputs.length === 0 && (
<div className={css['Section']}>
<h3 className={css['SectionTitle']} onClick={() => toggleSection('interface')}>
<Icon icon={collapsed.interface ? IconName.CaretRight : IconName.CaretDown} />
<Icon icon={IconName.Setting} />
Interface
</h3>
{!collapsed.interface && <div className={css['NoData']}>This component has no defined interface</div>}
</div>
)}
{/* Contains Section */}
<div className={css['Section']}>
<h3 className={css['SectionTitle']} onClick={() => toggleSection('contains')}>
<Icon icon={collapsed.contains ? IconName.CaretRight : IconName.CaretDown} />
<Icon icon={IconName.Component} />
Contains
</h3>
{!collapsed.contains && (
<div className={css['SectionContent']}>
{/* Subcomponents */}
{xrayData.subcomponents.length > 0 && (
<div className={css['Subsection']}>
<h4>Subcomponents ({xrayData.subcomponents.length})</h4>
{xrayData.subcomponents.map((sub, idx) => (
<div
key={idx}
className={css['SubcomponentItem']}
onClick={() => navigateToComponent(sub.component)}
>
<Icon icon={IconName.Component} />
<span>{sub.fullName}</span>
</div>
))}
</div>
)}
{/* Node Breakdown */}
{xrayData.nodeBreakdown.length > 0 && (
<div className={css['Subsection']}>
<h4>Node Breakdown</h4>
{xrayData.nodeBreakdown.map((breakdown, idx) => (
<div
key={idx}
className={`${css['BreakdownItem']} ${
selectedCategory === breakdown.category ? css['active'] : ''
}`}
onClick={() => highlightCategory(breakdown.category, breakdown.nodeIds)}
>
<span className={css['CategoryName']}>{breakdown.category}</span>
<span className={css['CategoryCount']}>{breakdown.count}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* External Dependencies */}
{(xrayData.restCalls.length > 0 ||
xrayData.eventsSent.length > 0 ||
xrayData.eventsReceived.length > 0 ||
xrayData.functions.length > 0) && (
<div className={css['Section']}>
<h3 className={css['SectionTitle']} onClick={() => toggleSection('dependencies')}>
<Icon icon={collapsed.dependencies ? IconName.CaretRight : IconName.CaretDown} />
<Icon icon={IconName.CloudData} />
External Dependencies
</h3>
{!collapsed.dependencies && (
<div className={css['SectionContent']}>
{/* REST Calls */}
{xrayData.restCalls.length > 0 && (
<div className={css['Subsection']}>
<h4>REST Calls ({xrayData.restCalls.length})</h4>
{xrayData.restCalls.map((rest, idx) => (
<div key={idx} className={css['DependencyItem']} onClick={() => selectNodeById(rest.nodeId)}>
<span className={css['Method']}>{rest.method}</span>
<span className={css['Endpoint']}>{rest.endpoint}</span>
</div>
))}
</div>
)}
{/* Events Sent */}
{xrayData.eventsSent.length > 0 && (
<div className={css['Subsection']}>
<h4>Events Sent ({xrayData.eventsSent.length})</h4>
{xrayData.eventsSent.map((event, idx) => (
<div key={idx} className={css['EventItem']} onClick={() => selectNodeById(event.nodeId)}>
<span>{event.eventName}</span>
</div>
))}
</div>
)}
{/* Events Received */}
{xrayData.eventsReceived.length > 0 && (
<div className={css['Subsection']}>
<h4>Events Received ({xrayData.eventsReceived.length})</h4>
{xrayData.eventsReceived.map((event, idx) => (
<div key={idx} className={css['EventItem']} onClick={() => selectNodeById(event.nodeId)}>
<span>{event.eventName}</span>
</div>
))}
</div>
)}
{/* Functions */}
{xrayData.functions.length > 0 && (
<div className={css['Subsection']}>
<h4>Functions ({xrayData.functions.length})</h4>
{xrayData.functions.map((func, idx) => (
<div key={idx} className={css['FunctionItem']} onClick={() => selectNodeById(func.nodeId)}>
<span>{func.nodeLabel}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{/* Internal State */}
{xrayData.stateNodes.length > 0 && (
<div className={css['Section']}>
<h3 className={css['SectionTitle']} onClick={() => toggleSection('state')}>
<Icon icon={collapsed.state ? IconName.CaretRight : IconName.CaretDown} />
<Icon icon={IconName.CloudData} />
Internal State ({xrayData.stateNodes.length})
</h3>
{!collapsed.state && (
<div className={css['SectionContent']}>
{xrayData.stateNodes.map((state, idx) => (
<div key={idx} className={css['StateItem']} onClick={() => selectNodeById(state.nodeId)}>
<span className={css['StateType']}>{state.nodeType}</span>
<span className={css['StateName']}>{state.name}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,345 @@
/**
* useComponentXRay Hook
*
* Collects comprehensive X-Ray data for a component, including:
* - Where it's used
* - Component interface (inputs/outputs)
* - Internal structure (subcomponents, node breakdown)
* - External dependencies (REST, Events, Functions)
* - Internal state (Variables, Objects, States)
*/
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
import { useEventListener } from '@noodl-hooks/useEventListener';
import { useMemo, useState } from 'react';
import { ComponentModel } from '@noodl-models/componentmodel';
import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
import { ProjectModel } from '@noodl-models/projectmodel';
import { categorizeNodes, findComponentUsages, findNodesOfType } from '@noodl-utils/graphAnalysis';
import {
ComponentInputInfo,
ComponentOutputInfo,
ComponentUsageInfo,
ComponentXRayData,
EventInfo,
FunctionInfo,
NodeCategoryBreakdown,
RESTCallInfo,
StateNodeInfo,
SubcomponentInfo
} from '../utils/xrayTypes';
/**
* Extract component inputs from Component Inputs nodes
*/
function extractComponentInputs(component: ComponentModel): ComponentInputInfo[] {
const inputNodes = findNodesOfType(component, 'Component Inputs');
const inputs: ComponentInputInfo[] = [];
for (const node of inputNodes) {
// Get all ports defined on this node
const ports = node.getPorts();
for (const port of ports) {
if (port.plug === 'output') {
// Component Inputs node has outputs that represent component inputs
inputs.push({
name: port.name,
type: port.type?.name || port.type || 'any',
isSignal: port.type === 'signal' || port.type?.name === 'signal'
});
}
}
}
return inputs;
}
/**
* Extract component outputs from Component Outputs nodes
*/
function extractComponentOutputs(component: ComponentModel): ComponentOutputInfo[] {
const outputNodes = findNodesOfType(component, 'Component Outputs');
const outputs: ComponentOutputInfo[] = [];
for (const node of outputNodes) {
// Get all ports defined on this node
const ports = node.getPorts();
for (const port of ports) {
if (port.plug === 'input') {
// Component Outputs node has inputs that represent component outputs
outputs.push({
name: port.name,
type: port.type?.name || port.type || 'any',
isSignal: port.type === 'signal' || port.type?.name === 'signal'
});
}
}
}
return outputs;
}
/**
* Extract subcomponent instances used within this component
*/
function extractSubcomponents(component: ComponentModel): SubcomponentInfo[] {
const subcomponents: SubcomponentInfo[] = [];
const seen = new Set<string>();
// Find all nodes that are component instances
component.graph.forEachNode((node: NodeGraphNode) => {
if (node.type instanceof ComponentModel) {
const key = `${node.type.fullName}-${node.id}`;
if (!seen.has(key)) {
seen.add(key);
subcomponents.push({
name: node.type.name,
fullName: node.type.fullName,
component: node.type,
nodeId: node.id
});
}
}
});
return subcomponents;
}
/**
* Extract REST call information from REST nodes
*/
function extractRESTCalls(component: ComponentModel): RESTCallInfo[] {
const restNodes = findNodesOfType(component, 'REST');
const rest2Nodes = findNodesOfType(component, 'REST2');
const allRestNodes = [...restNodes, ...rest2Nodes];
return allRestNodes.map((node) => ({
method: (node.parameters.method as string) || 'GET',
endpoint: (node.parameters.url as string) || (node.parameters.endpoint as string) || 'No endpoint',
nodeId: node.id,
nodeLabel: node.label || 'REST Call'
}));
}
/**
* Extract sent event information from Send Event nodes
*/
function extractSentEvents(component: ComponentModel): EventInfo[] {
const sendEventNodes = findNodesOfType(component, 'Send Event');
return sendEventNodes.map((node) => ({
eventName: (node.parameters.eventName as string) || (node.parameters.channel as string) || 'Unnamed Event',
nodeId: node.id,
nodeLabel: node.label || 'Send Event'
}));
}
/**
* Extract received event information from Receive Event nodes
*/
function extractReceivedEvents(component: ComponentModel): EventInfo[] {
const receiveEventNodes = findNodesOfType(component, 'Receive Event');
return receiveEventNodes.map((node) => ({
eventName: (node.parameters.eventName as string) || (node.parameters.channel as string) || 'Unnamed Event',
nodeId: node.id,
nodeLabel: node.label || 'Receive Event'
}));
}
/**
* Extract function information from JavaScriptFunction nodes
*/
function extractFunctions(component: ComponentModel): FunctionInfo[] {
const functionNodes = findNodesOfType(component, 'JavaScriptFunction');
return functionNodes.map((node) => ({
name: (node.parameters.name as string) || node.typename,
nodeId: node.id,
nodeLabel: node.label || 'JavaScript Function'
}));
}
/**
* Extract internal state nodes (Variables, Objects, States)
*/
function extractStateNodes(component: ComponentModel): StateNodeInfo[] {
// Support both Variable and Variable2 (newer version)
const variableNodes = findNodesOfType(component, 'Variable');
const variable2Nodes = findNodesOfType(component, 'Variable2');
const objectNodes = findNodesOfType(component, 'Object');
const statesNodes = findNodesOfType(component, 'States');
const stateNodes: StateNodeInfo[] = [];
for (const node of variableNodes) {
stateNodes.push({
name: node.label || 'Unnamed Variable',
nodeId: node.id,
nodeType: 'Variable'
});
}
for (const node of variable2Nodes) {
stateNodes.push({
name: node.label || 'Unnamed Variable',
nodeId: node.id,
nodeType: 'Variable'
});
}
for (const node of objectNodes) {
stateNodes.push({
name: node.label || 'Unnamed Object',
nodeId: node.id,
nodeType: 'Object'
});
}
for (const node of statesNodes) {
stateNodes.push({
name: node.label || 'States',
nodeId: node.id,
nodeType: 'States'
});
}
return stateNodes;
}
/**
* Extract node breakdown by category
*/
function extractNodeBreakdown(component: ComponentModel): NodeCategoryBreakdown[] {
const categorized = categorizeNodes(component);
// Map totals to include node IDs for highlighting
return categorized.totals.map((total) => {
const nodes = categorized.byCategory.get(total.category) || [];
const nodeIds = nodes.map((node) => node.id);
return {
category: total.category,
count: total.count,
nodeIds
};
});
}
/**
* Extract component usage information from the project
*/
function extractUsageInfo(project: ProjectModel, componentFullName: string): ComponentUsageInfo[] {
const usages = findComponentUsages(project, componentFullName);
return usages.map((usage) => ({
component: usage.usedIn,
instanceCount: 1, // Each usage represents one instance
instanceNodeIds: [usage.instanceNodeId]
}));
}
/**
* Hook that builds and returns complete X-Ray data for the currently active component.
*
* Automatically updates when:
* - The active component changes
* - Components are added or removed from the project
* - Nodes are added or removed from the current component
*
* @returns Complete X-Ray data for the current component, or null if no component is active
*
* @example
* ```tsx
* function MyPanel() {
* const xrayData = useComponentXRay();
*
* if (!xrayData) {
* return <div>No component selected</div>;
* }
*
* return (
* <div>
* <h2>{xrayData.componentName}</h2>
* <p>Total nodes: {xrayData.totalNodes}</p>
* <p>Used in {xrayData.usedIn.length} places</p>
* </div>
* );
* }
* ```
*/
export function useComponentXRay(): ComponentXRayData | null {
const project = ProjectModel.instance;
const [updateTrigger, setUpdateTrigger] = useState(0);
// Get current component from NodeGraphContext
const currentComponent = NodeGraphContextTmp.nodeGraph?.activeComponent || null;
// Trigger rebuild when components change
useEventListener(ProjectModel.instance, 'componentAdded', () => {
setUpdateTrigger((prev) => prev + 1);
});
useEventListener(ProjectModel.instance, 'componentRemoved', () => {
setUpdateTrigger((prev) => prev + 1);
});
// Trigger rebuild when active component switches
useEventListener(NodeGraphContextTmp.nodeGraph, 'activeComponentChanged', () => {
setUpdateTrigger((prev) => prev + 1);
});
// Trigger rebuild when nodes change in the current component
// IMPORTANT: Listen to the component's graph, not the NodeGraphContextTmp singleton
useEventListener(currentComponent?.graph, 'nodeAdded', () => {
setUpdateTrigger((prev) => prev + 1);
});
useEventListener(currentComponent?.graph, 'nodeRemoved', () => {
setUpdateTrigger((prev) => prev + 1);
});
const xrayData = useMemo<ComponentXRayData | null>(() => {
if (!currentComponent) {
return null;
}
// Build complete X-Ray data
const data: ComponentXRayData = {
// Identity
componentName: currentComponent.name,
componentFullName: currentComponent.fullName,
// Usage
usedIn: extractUsageInfo(project, currentComponent.fullName),
// Interface
inputs: extractComponentInputs(currentComponent),
outputs: extractComponentOutputs(currentComponent),
// Contents
subcomponents: extractSubcomponents(currentComponent),
nodeBreakdown: extractNodeBreakdown(currentComponent),
totalNodes: extractNodeBreakdown(currentComponent).reduce((sum, cat) => sum + cat.count, 0),
// External dependencies
restCalls: extractRESTCalls(currentComponent),
eventsSent: extractSentEvents(currentComponent),
eventsReceived: extractReceivedEvents(currentComponent),
functions: extractFunctions(currentComponent),
// Internal state
stateNodes: extractStateNodes(currentComponent)
};
return data;
}, [currentComponent, project, updateTrigger]);
return xrayData;
}

View File

@@ -0,0 +1,7 @@
/**
* Component X-Ray Panel
*
* Exports the Component X-Ray panel for sidebar registration
*/
export { ComponentXRayPanel } from './ComponentXRayPanel';

View File

@@ -0,0 +1,120 @@
/**
* Component X-Ray Panel Types
*
* TypeScript interfaces for the Component X-Ray data structure.
*/
import { ComponentModel } from '@noodl-models/componentmodel';
/**
* Information about where a component is used
*/
export interface ComponentUsageInfo {
component: ComponentModel;
instanceCount: number;
instanceNodeIds: string[];
}
/**
* Component input port information
*/
export interface ComponentInputInfo {
name: string;
type: string;
isSignal: boolean;
}
/**
* Component output port information
*/
export interface ComponentOutputInfo {
name: string;
type: string;
isSignal: boolean;
}
/**
* Subcomponent instance information
*/
export interface SubcomponentInfo {
name: string;
fullName: string;
component: ComponentModel | null;
nodeId: string;
}
/**
* Node breakdown by semantic category
*/
export interface NodeCategoryBreakdown {
category: string;
count: number;
nodeIds: string[]; // Node IDs in this category for highlighting
}
/**
* REST call information
*/
export interface RESTCallInfo {
method: string;
endpoint: string;
nodeId: string;
nodeLabel: string;
}
/**
* Event information (sent or received)
*/
export interface EventInfo {
eventName: string;
nodeId: string;
nodeLabel: string;
}
/**
* Function node information
*/
export interface FunctionInfo {
name: string;
nodeId: string;
nodeLabel: string;
}
/**
* Internal state node information
*/
export interface StateNodeInfo {
name: string;
nodeId: string;
nodeType: 'Variable' | 'Object' | 'States';
}
/**
* Complete X-Ray data for a component
*/
export interface ComponentXRayData {
// Component identity
componentName: string;
componentFullName: string;
// Usage information
usedIn: ComponentUsageInfo[];
// Component interface
inputs: ComponentInputInfo[];
outputs: ComponentOutputInfo[];
// Contents
subcomponents: SubcomponentInfo[];
nodeBreakdown: NodeCategoryBreakdown[];
totalNodes: number;
// External dependencies
restCalls: RESTCallInfo[];
eventsSent: EventInfo[];
eventsReceived: EventInfo[];
functions: FunctionInfo[];
// Internal state
stateNodes: StateNodeInfo[];
}

View File

@@ -17,6 +17,7 @@ import { MenuDialogWidth } from '@noodl-core-ui/components/popups/MenuDialog';
import { showContextMenuInPopup } from '../../ShowContextMenuInPopup';
import { ComponentTree } from './components/ComponentTree';
import { SheetSelector } from './components/SheetSelector';
import { StringInputDialog } from './components/StringInputDialog';
import css from './ComponentsPanel.module.scss';
import { ComponentTemplates } from './ComponentTemplates';
import { useComponentActions } from './hooks/useComponentActions';
@@ -57,54 +58,35 @@ export function ComponentsPanel({ options }: ComponentsPanelProps) {
// Handle creating a new sheet
const handleCreateSheet = useCallback(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const PopupLayer = require('@noodl-views/popuplayer');
const popup = new PopupLayer.StringInputPopup({
label: 'New sheet name',
okLabel: 'Create',
cancelLabel: 'Cancel',
onOk: (name: string) => {
if (createSheet(name)) {
PopupLayer.instance.hidePopup();
}
}
});
popup.render();
PopupLayer.instance.showPopup({
content: popup,
position: 'screen-center',
isBackgroundDimmed: true
});
DialogLayerModel.instance.showDialog((close) =>
React.createElement(StringInputDialog, {
title: 'New sheet name',
placeholder: 'Enter sheet name',
confirmLabel: 'Create',
onConfirm: (value) => {
createSheet(value);
close();
},
onCancel: close
})
);
}, [createSheet]);
// Handle renaming a sheet
const handleRenameSheet = useCallback(
(sheet: TSFixme) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const PopupLayer = require('@noodl-views/popuplayer');
const popup = new PopupLayer.StringInputPopup({
label: 'New sheet name',
value: sheet.name,
okLabel: 'Rename',
cancelLabel: 'Cancel',
onOk: (newName: string) => {
if (renameSheet(sheet, newName)) {
PopupLayer.instance.hidePopup();
}
}
});
popup.render();
PopupLayer.instance.showPopup({
content: popup,
position: 'screen-center',
isBackgroundDimmed: true
});
DialogLayerModel.instance.showDialog((close) =>
React.createElement(StringInputDialog, {
title: 'Rename sheet',
defaultValue: sheet.name,
confirmLabel: 'Rename',
onConfirm: (value) => {
renameSheet(sheet, value);
close();
},
onCancel: close
})
);
},
[renameSheet]
);

View File

@@ -0,0 +1,93 @@
/**
* StringInputDialog styles
* Uses proper CSS classes for Electron compatibility
*/
.Root {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.Dialog {
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 8px;
padding: 24px;
min-width: 400px;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.Title {
margin: 0 0 20px 0;
font-size: 16px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
.Input {
width: 100%;
padding: 8px 12px;
font-size: 14px;
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-default);
margin-bottom: 20px;
font-family: inherit;
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
}
.Actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.ButtonCancel,
.ButtonConfirm {
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: background-color 0.15s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ButtonCancel {
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
color: var(--theme-color-fg-default);
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-4);
}
}
.ButtonConfirm {
background-color: var(--theme-color-primary);
border: none;
color: white;
&:hover:not(:disabled) {
opacity: 0.9;
}
}

View File

@@ -0,0 +1,83 @@
/**
* StringInputDialog
*
* Simple centered dialog for string input (sheet creation/renaming).
* Uses CSS classes for proper Electron compatibility (inline styles don't work).
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import css from './StringInputDialog.module.scss';
interface StringInputDialogProps {
title: string;
defaultValue?: string;
placeholder?: string;
confirmLabel?: string;
onConfirm: (value: string) => void;
onCancel: () => void;
}
export function StringInputDialog({
title,
defaultValue = '',
placeholder,
confirmLabel = 'Confirm',
onConfirm,
onCancel
}: StringInputDialogProps) {
const [value, setValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-focus the input
useEffect(() => {
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
}, []);
const handleConfirm = useCallback(() => {
if (value.trim()) {
onConfirm(value.trim());
}
}, [value, onConfirm]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
},
[handleConfirm, onCancel]
);
return (
<div className={css['Root']} onClick={onCancel}>
<div className={css['Dialog']} onClick={(e) => e.stopPropagation()}>
<h2 className={css['Title']}>{title}</h2>
<input
ref={inputRef}
type="text"
className={css['Input']}
value={value}
placeholder={placeholder}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
/>
<div className={css['Actions']}>
<button className={css['ButtonCancel']} onClick={onCancel}>
Cancel
</button>
<button className={css['ButtonConfirm']} onClick={handleConfirm} disabled={!value.trim()}>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -65,7 +65,7 @@ export function useComponentsPanel(options: UseComponentsPanelOptions = {}) {
ProjectModel.instance.off(group);
}
};
}, [ProjectModel.instance]); // Re-run when ProjectModel.instance changes from null to real instance
}, []); // Empty deps: ProjectModel.instance is a singleton that never changes, so subscribe once and cleanup on unmount
// Get all components (including placeholders) for sheet detection
// IMPORTANT: Spread to create new array reference - getComponents() may return
@@ -142,7 +142,7 @@ export function useComponentsPanel(options: UseComponentsPanelOptions = {}) {
});
return result;
}, [rawComponents, allComponents, hideSheets]);
}, [rawComponents, allComponents, hideSheets, updateCounter]);
// Get current sheet object
const currentSheet = useMemo((): Sheet | null => {

View File

@@ -194,8 +194,9 @@ export function useSheetManagement() {
.getComponents()
.filter((comp) => comp.name.startsWith('/' + sheet.folderName + '/'));
// Check if sheet exists at all (must have at least a placeholder)
if (componentsInSheet.length === 0) {
ToastLayer.showError('Sheet is already empty');
ToastLayer.showError('Sheet does not exist');
return false;
}
@@ -219,6 +220,37 @@ export function useSheetManagement() {
}
});
// Allow deletion of empty sheets (sheets with only placeholders)
if (renameMap.length === 0) {
// Sheet is empty (only has placeholders) - just delete the placeholders
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Delete empty sheet "${sheet.name}"`,
do: () => {
placeholderNames.forEach((placeholderName) => {
const placeholder = ProjectModel.instance?.getComponentWithName(placeholderName);
if (placeholder) {
ProjectModel.instance?.removeComponent(placeholder);
}
});
},
undo: () => {
placeholderNames.forEach((placeholderName) => {
const restoredPlaceholder = new ComponentModel({
name: placeholderName,
graph: new NodeGraphModel(),
id: guid()
});
ProjectModel.instance?.addComponent(restoredPlaceholder);
});
}
})
);
ToastLayer.showSuccess(`Deleted empty sheet "${sheet.name}"`);
return true;
}
// Check for naming conflicts
for (const { newName } of renameMap) {
const existing = ProjectModel.instance.getComponentWithName(newName);

View File

@@ -0,0 +1,139 @@
/**
* TopologyMapPanel Styles
*/
.TopologyMapPanel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--theme-color-bg-2);
}
.TopologyMapPanel__header {
background: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-border-default);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.TopologyMapPanel__title {
display: flex;
align-items: center;
gap: 12px;
}
.TopologyMapPanel__icon {
font-size: 24px;
}
.TopologyMapPanel__titleText {
color: var(--theme-color-fg-default);
font-size: 18px;
font-weight: 600;
margin: 0;
}
.TopologyMapPanel__breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
}
.TopologyMapPanel__breadcrumbLabel {
color: var(--theme-color-fg-default-shy);
font-weight: 500;
}
.TopologyMapPanel__breadcrumb {
color: var(--theme-color-fg-default);
}
.TopologyMapPanel__breadcrumbSeparator {
color: var(--theme-color-fg-default-shy);
font-weight: 300;
}
// Legend
.TopologyMapPanel__legend {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--theme-color-bg-2);
border-radius: 6px;
border: 1px solid var(--theme-color-border-subtle);
}
.TopologyMapPanel__legendItem {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.TopologyMapPanel__legendIcon {
font-size: 16px;
min-width: 24px;
text-align: center;
}
.TopologyMapPanel__legendText {
color: var(--theme-color-fg-default);
font-weight: 500;
}
// Tooltip
.TopologyMapPanel__tooltip {
position: fixed;
z-index: 1000;
background: var(--theme-color-bg-4);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
max-width: 300px;
pointer-events: none;
padding: 12px;
}
.TopologyMapPanel__tooltipTitle {
color: var(--theme-color-fg-default);
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--theme-color-border-default);
}
.TopologyMapPanel__tooltipContent {
color: var(--theme-color-fg-default-shy);
font-size: 12px;
line-height: 1.6;
div {
margin-bottom: 4px;
}
}
.TopologyMapPanel__tooltipSection {
margin-top: 8px !important;
padding-top: 8px;
border-top: 1px solid var(--theme-color-border-subtle);
strong {
color: var(--theme-color-fg-default);
}
}
.TopologyMapPanel__tooltipHint {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--theme-color-border-subtle);
color: var(--theme-color-primary);
font-size: 11px;
font-weight: 500;
text-align: center;
}

View File

@@ -0,0 +1,142 @@
/**
* TopologyMapPanel Component
*
* Main panel component for the Project Topology Map.
* Shows the "big picture" of component relationships in the project.
*/
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
import React, { useState, useCallback, useEffect } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { TopologyMapView } from './components/TopologyMapView';
import { useTopologyGraph } from './hooks/useTopologyGraph';
import { useTopologyLayout } from './hooks/useTopologyLayout';
import css from './TopologyMapPanel.module.scss';
import { TopologyNode } from './utils/topologyTypes';
export function TopologyMapPanel() {
const [hoveredNode, setHoveredNode] = useState<TopologyNode | null>(null);
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
const [isLegendOpen, setIsLegendOpen] = useState(false);
// Build the graph data
const graph = useTopologyGraph();
// Apply layout algorithm
const positionedGraph = useTopologyLayout(graph);
// Handle node click - navigate to that component
const handleNodeClick = useCallback((node: TopologyNode) => {
console.log('[TopologyMapPanel] Navigating to component:', node.fullName);
if (NodeGraphContextTmp.switchToComponent) {
NodeGraphContextTmp.switchToComponent(node.component, {
pushHistory: true,
breadcrumbs: true
});
}
}, []);
// Handle node hover for tooltip
const handleNodeHover = useCallback((node: TopologyNode | null, event?: React.MouseEvent) => {
setHoveredNode(node);
if (node && event) {
setTooltipPos({ x: event.clientX, y: event.clientY });
} else {
setTooltipPos(null);
}
}, []);
// Auto-fit on first load
useEffect(() => {
// Trigger fit to view after initial render
const timer = setTimeout(() => {
// The TopologyMapView has a fitToView method, but we can't call it directly
// Instead, it will auto-fit on mount via the controls
}, 100);
return () => clearTimeout(timer);
}, []);
return (
<div className={css['TopologyMapPanel']}>
{/* Header with breadcrumbs */}
<div className={css['TopologyMapPanel__header']}>
<div className={css['TopologyMapPanel__title']}>
<Icon icon={IconName.Navigate} />
<h2 className={css['TopologyMapPanel__titleText']}>Project Topology</h2>
</div>
{graph.currentPath.length > 0 && (
<div className={css['TopologyMapPanel__breadcrumbs']}>
<span className={css['TopologyMapPanel__breadcrumbLabel']}>Current path:</span>
{graph.currentPath.map((componentName, i) => (
<React.Fragment key={componentName}>
{i > 0 && <span className={css['TopologyMapPanel__breadcrumbSeparator']}></span>}
<span
className={css['TopologyMapPanel__breadcrumb']}
style={{
fontWeight: i === graph.currentPath.length - 1 ? 600 : 400
}}
>
{componentName.split('/').pop() || componentName}
</span>
</React.Fragment>
))}
</div>
)}
</div>
{/* Main visualization with legend inside */}
<TopologyMapView
graph={positionedGraph}
onNodeClick={handleNodeClick}
onNodeHover={handleNodeHover}
isLegendOpen={isLegendOpen}
onLegendToggle={() => setIsLegendOpen(!isLegendOpen)}
/>
{/* Tooltip */}
{hoveredNode && tooltipPos && (
<div
className={css['TopologyMapPanel__tooltip']}
style={{
left: tooltipPos.x + 10,
top: tooltipPos.y + 10
}}
>
<div className={css['TopologyMapPanel__tooltipTitle']}>{hoveredNode.name}</div>
<div className={css['TopologyMapPanel__tooltipContent']}>
<div>Type: {hoveredNode.type === 'page' ? '📄 Page' : '🧩 Component'}</div>
<div>
Used {hoveredNode.usageCount} time{hoveredNode.usageCount !== 1 ? 's' : ''}
</div>
{hoveredNode.depth < 999 && <div>Depth: {hoveredNode.depth}</div>}
{hoveredNode.usedBy.length > 0 && (
<div className={css['TopologyMapPanel__tooltipSection']}>
<strong>Used by:</strong>{' '}
{hoveredNode.usedBy
.slice(0, 3)
.map((name) => name.split('/').pop())
.join(', ')}
{hoveredNode.usedBy.length > 3 && ` +${hoveredNode.usedBy.length - 3} more`}
</div>
)}
{hoveredNode.uses.length > 0 && (
<div className={css['TopologyMapPanel__tooltipSection']}>
<strong>Uses:</strong>{' '}
{hoveredNode.uses
.slice(0, 3)
.map((name) => name.split('/').pop())
.join(', ')}
{hoveredNode.uses.length > 3 && ` +${hoveredNode.uses.length - 3} more`}
</div>
)}
</div>
<div className={css['TopologyMapPanel__tooltipHint']}>Click to navigate </div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,22 @@
/**
* TopologyEdge Styles
*/
.TopologyEdge {
pointer-events: none;
}
.TopologyEdge__path {
stroke: #6b8cae; // Brighter blue-grey for better visibility
stroke-width: 2;
opacity: 0.8;
transition: all 0.2s ease;
}
.TopologyEdge__count {
fill: var(--theme-color-fg-default-shy);
font-size: 10px;
font-weight: 600;
user-select: none;
pointer-events: none;
}

View File

@@ -0,0 +1,104 @@
/**
* TopologyEdge Component
*
* Renders a connection arrow between two component nodes.
*/
import React from 'react';
import { TopologyEdge as TopologyEdgeType, TopologyNode } from '../utils/topologyTypes';
import css from './TopologyEdge.module.scss';
export interface TopologyEdgeProps {
edge: TopologyEdgeType;
fromNode: TopologyNode | undefined;
toNode: TopologyNode | undefined;
}
/**
* Calculates SVG path for an edge between two nodes.
*/
function calculateEdgePath(
fromNode: TopologyNode,
toNode: TopologyNode
): { path: string; arrowX: number; arrowY: number; arrowAngle: number } {
if (
!fromNode ||
!toNode ||
fromNode.x === undefined ||
fromNode.y === undefined ||
toNode.x === undefined ||
toNode.y === undefined ||
!fromNode.width ||
!fromNode.height ||
!toNode.width ||
!toNode.height
) {
return { path: '', arrowX: 0, arrowY: 0, arrowAngle: 0 };
}
// Calculate center points of nodes
const fromX = fromNode.x + fromNode.width / 2;
const fromY = fromNode.y + fromNode.height; // Bottom of source node
const toX = toNode.x + toNode.width / 2;
const toY = toNode.y; // Top of target node
// Create a simple curved path
const midY = (fromY + toY) / 2;
const path = `M ${fromX} ${fromY}
C ${fromX} ${midY}, ${toX} ${midY}, ${toX} ${toY}`;
// Arrow points at the target node
const arrowX = toX;
const arrowY = toY;
const arrowAngle = 90; // Pointing down into the node
return { path, arrowX, arrowY, arrowAngle };
}
export function TopologyEdge({ edge, fromNode, toNode }: TopologyEdgeProps) {
if (!fromNode || !toNode) {
return null;
}
const { path } = calculateEdgePath(fromNode, toNode);
if (!path) {
return null;
}
return (
<g className={css['TopologyEdge']}>
{/* Connection path */}
<path className={css['TopologyEdge__path']} d={path} fill="none" markerEnd="url(#topology-arrow)" />
{/* Arrow marker (defined once in defs, referenced here) */}
{/* Edge count label (if multiple instances) */}
{edge.count > 1 && (
<text
className={css['TopologyEdge__count']}
x={(fromNode.x + fromNode.width / 2 + toNode.x + toNode.width / 2) / 2}
y={(fromNode.y + fromNode.height + toNode.y) / 2}
textAnchor="middle"
fontSize={10}
>
×{edge.count}
</text>
)}
</g>
);
}
/**
* Arrow marker definition (should be added to SVG defs once).
*/
export function TopologyEdgeMarkerDef() {
return (
<defs>
<marker id="topology-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--theme-color-border-default)" />
</marker>
</defs>
);
}

View File

@@ -0,0 +1,156 @@
/**
* TopologyMapView Styles
*/
.TopologyMapView {
display: flex;
flex-direction: column;
height: 100%;
background: var(--theme-color-bg-2);
position: relative;
}
.TopologyMapView__controls {
position: absolute;
top: 16px;
right: 16px;
display: flex;
gap: 8px;
align-items: center;
z-index: 10;
background: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
padding: 8px;
}
.TopologyMapView__button {
background: var(--theme-color-bg-4);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default);
cursor: pointer;
font-size: 14px;
font-weight: 600;
padding: 6px 12px;
transition: all 0.2s ease;
min-width: 36px;
&:hover {
background: var(--theme-color-bg-5);
border-color: var(--theme-color-primary);
}
&:active {
transform: scale(0.95);
}
}
.TopologyMapView__zoom {
color: var(--theme-color-fg-default-shy);
font-size: 12px;
font-weight: 500;
min-width: 48px;
text-align: center;
}
.TopologyMapView__svg {
flex: 1;
width: 100%;
height: 100%;
overflow: hidden;
user-select: none;
}
.TopologyMapView__footer {
padding: 8px 16px;
background: var(--theme-color-bg-3);
border-top: 1px solid var(--theme-color-border-default);
color: var(--theme-color-fg-default-shy);
font-size: 12px;
text-align: center;
}
// Floating Legend
.TopologyMapView__legend {
position: absolute;
top: 70px;
right: 16px;
background: var(--theme-color-bg-4);
border: 1px solid var(--theme-color-border-default);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 280px;
z-index: 10;
}
.TopologyMapView__legendHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--theme-color-border-default);
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
}
.TopologyMapView__legendClose {
background: transparent;
border: none;
color: var(--theme-color-fg-default-shy);
font-size: 20px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: var(--theme-color-fg-default);
}
}
.TopologyMapView__legendContent {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.TopologyMapView__legendItem {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--theme-color-fg-default);
}
.TopologyMapView__legendColor {
width: 24px;
height: 24px;
border: 2px solid;
border-radius: 4px;
background: var(--theme-color-bg-3);
flex-shrink: 0;
}
.TopologyMapView__legendBadge {
width: 24px;
height: 24px;
background: var(--theme-color-accent);
color: var(--theme-color-fg-on-accent);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
flex-shrink: 0;
}

View File

@@ -0,0 +1,246 @@
/**
* TopologyMapView Component
*
* Main SVG visualization container for the topology map.
* Handles rendering nodes, edges, pan/zoom, and user interaction.
*/
import React, { useState, useRef } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { PositionedTopologyGraph, TopologyNode as TopologyNodeType } from '../utils/topologyTypes';
import { TopologyEdge, TopologyEdgeMarkerDef } from './TopologyEdge';
import css from './TopologyMapView.module.scss';
import { TopologyNode } from './TopologyNode';
export interface TopologyMapViewProps {
graph: PositionedTopologyGraph;
onNodeClick?: (node: TopologyNodeType) => void;
onNodeHover?: (node: TopologyNodeType | null) => void;
isLegendOpen?: boolean;
onLegendToggle?: () => void;
}
export function TopologyMapView({
graph,
onNodeClick,
onNodeHover,
isLegendOpen,
onLegendToggle
}: TopologyMapViewProps) {
const [scale, setScale] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const svgRef = useRef<SVGSVGElement>(null);
// Handle mouse wheel for zoom (zoom to cursor position)
const handleWheel = (e: React.WheelEvent<SVGSVGElement>) => {
e.preventDefault();
if (!svgRef.current) return;
const svg = svgRef.current;
const rect = svg.getBoundingClientRect();
// Get mouse position relative to SVG
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Calculate zoom delta
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.1, Math.min(3, scale * delta));
// Calculate new pan to keep mouse position stable
// Formula: new_pan = mouse_pos - (mouse_pos - old_pan) * (new_scale / old_scale)
const scaleRatio = newScale / scale;
const newPan = {
x: mouseX - (mouseX - pan.x) * scaleRatio,
y: mouseY - (mouseY - pan.y) * scaleRatio
};
setScale(newScale);
setPan(newPan);
};
// Handle panning
const handleMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
if (e.button === 0 && e.target === svgRef.current) {
// Only start panning if clicking on the background
setIsPanning(true);
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
}
};
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (isPanning) {
setPan({
x: e.clientX - panStart.x,
y: e.clientY - panStart.y
});
}
};
const handleMouseUp = () => {
setIsPanning(false);
};
const handleMouseLeave = () => {
setIsPanning(false);
};
// Fit to view
const fitToView = () => {
if (!svgRef.current) return;
const svg = svgRef.current;
const bbox = svg.getBoundingClientRect();
const graphWidth = graph.bounds.width;
const graphHeight = graph.bounds.height;
// Calculate scale to fit
const scaleX = (bbox.width - 80) / graphWidth;
const scaleY = (bbox.height - 80) / graphHeight;
const newScale = Math.min(scaleX, scaleY, 1);
// Center the graph
const newPan = {
x: (bbox.width - graphWidth * newScale) / 2 - graph.bounds.x * newScale,
y: (bbox.height - graphHeight * newScale) / 2 - graph.bounds.y * newScale
};
setScale(newScale);
setPan(newPan);
};
// Node lookup map for edges
const nodeMap = new Map<string, TopologyNodeType>();
graph.nodes.forEach((node) => {
nodeMap.set(node.fullName, node);
});
return (
<div className={css['TopologyMapView']}>
{/* Controls */}
<div className={css['TopologyMapView__controls']}>
<button onClick={fitToView} className={css['TopologyMapView__button']} title="Fit to view">
Fit
</button>
<button
onClick={() => setScale((prev) => Math.min(3, prev * 1.2))}
className={css['TopologyMapView__button']}
title="Zoom in"
>
+
</button>
<button
onClick={() => setScale((prev) => Math.max(0.1, prev / 1.2))}
className={css['TopologyMapView__button']}
title="Zoom out"
>
</button>
<span className={css['TopologyMapView__zoom']}>{Math.round(scale * 100)}%</span>
{/* Legend toggle button */}
<button
onClick={onLegendToggle}
className={css['TopologyMapView__button']}
title="Show legend"
style={{ marginLeft: '8px' }}
>
<Icon icon={IconName.Question} />
</button>
</div>
{/* Floating Legend */}
{isLegendOpen && (
<div className={css['TopologyMapView__legend']}>
<div className={css['TopologyMapView__legendHeader']}>
<h3>Legend</h3>
<button onClick={onLegendToggle} className={css['TopologyMapView__legendClose']}>
×
</button>
</div>
<div className={css['TopologyMapView__legendContent']}>
<div className={css['TopologyMapView__legendItem']}>
<span
className={css['TopologyMapView__legendColor']}
style={{ borderColor: 'var(--theme-color-primary)', boxShadow: '0 0 8px var(--theme-color-primary)' }}
></span>
<span>Current Component (blue glow)</span>
</div>
<div className={css['TopologyMapView__legendItem']}>
<span
className={css['TopologyMapView__legendColor']}
style={{ borderColor: 'var(--theme-color-primary)', borderWidth: '2.5px' }}
></span>
<span>Page Component (blue border + shadow)</span>
</div>
<div className={css['TopologyMapView__legendItem']}>
<span className={css['TopologyMapView__legendColor']} style={{ borderColor: '#f5a623' }}></span>
<span>Shared Component (orange/gold border)</span>
</div>
<div className={css['TopologyMapView__legendItem']}>
<span
className={css['TopologyMapView__legendColor']}
style={{ borderColor: 'var(--theme-color-warning)', borderStyle: 'dashed' }}
></span>
<span>Orphan Component (yellow dashed - unused)</span>
</div>
<div className={css['TopologyMapView__legendItem']}>
<span className={css['TopologyMapView__legendBadge']}>×3</span>
<span>Usage count badge</span>
</div>
</div>
</div>
)}
{/* SVG Canvas */}
<svg
ref={svgRef}
className={css['TopologyMapView__svg']}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
style={{
cursor: isPanning ? 'grabbing' : 'grab'
}}
>
<TopologyEdgeMarkerDef />
<g transform={`translate(${pan.x}, ${pan.y}) scale(${scale})`}>
{/* Render edges first (behind nodes) */}
{graph.edges.map((edge, i) => (
<TopologyEdge
key={`${edge.from}-${edge.to}-${i}`}
edge={edge}
fromNode={nodeMap.get(edge.from)}
toNode={nodeMap.get(edge.to)}
/>
))}
{/* Render nodes */}
{graph.nodes.map((node) => (
<TopologyNode
key={node.fullName}
node={node}
onClick={(n) => onNodeClick?.(n)}
onMouseEnter={(n) => onNodeHover?.(n)}
onMouseLeave={() => onNodeHover?.(null)}
/>
))}
</g>
</svg>
{/* Stats footer */}
<div className={css['TopologyMapView__footer']}>
📊 {graph.totalNodes} components total | {graph.counts.pages} pages | {graph.counts.shared} shared
{graph.counts.orphans > 0 && ` | ⚠️ ${graph.counts.orphans} orphans`}
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
/**
* TopologyNode Styles
*/
.TopologyNode {
transition: all 0.2s ease;
&:hover {
.TopologyNode__rect {
filter: brightness(1.1);
}
}
}
.TopologyNode__rect {
fill: var(--theme-color-bg-3);
stroke: #4a90d9; // Brighter blue instead of dim border
stroke-width: 2;
transition: all 0.2s ease;
}
// Page nodes
.TopologyNode--page {
.TopologyNode__rect {
fill: var(--theme-color-bg-4);
stroke: var(--theme-color-primary);
stroke-width: 2.5;
filter: drop-shadow(0 0 4px rgba(74, 144, 217, 0.3));
}
}
// Current component highlight
.TopologyNode--current {
.TopologyNode__rect {
stroke: var(--theme-color-primary);
stroke-width: 3;
fill: var(--theme-color-bg-4);
filter: drop-shadow(0 0 12px var(--theme-color-primary));
}
// Distinct hover state for selected nodes
&:hover .TopologyNode__rect {
stroke-width: 4;
filter: drop-shadow(0 0 18px var(--theme-color-primary));
animation: selected-pulse 1.5s ease-in-out infinite;
}
}
@keyframes selected-pulse {
0%,
100% {
filter: drop-shadow(0 0 18px var(--theme-color-primary));
}
50% {
filter: drop-shadow(0 0 24px var(--theme-color-primary));
}
}
// Shared components (used multiple times)
.TopologyNode--shared {
.TopologyNode__rect {
stroke: #f5a623; // Brighter orange/gold for shared
stroke-width: 2;
}
}
// Orphan components (never used)
.TopologyNode--orphan {
opacity: 0.6;
.TopologyNode__rect {
stroke: var(--theme-color-warning);
stroke-dasharray: 4 2;
}
}
.TopologyNode__icon {
fill: var(--theme-color-fg-default);
user-select: none;
pointer-events: none;
}
.TopologyNode__text {
fill: var(--theme-color-fg-default);
font-size: 12px;
font-weight: 500;
user-select: none;
pointer-events: none;
}
.TopologyNode__star {
user-select: none;
pointer-events: none;
filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.8));
}
// Usage count badge
.TopologyNode__badge {
fill: var(--theme-color-accent);
stroke: var(--theme-color-bg-3);
stroke-width: 2;
}
.TopologyNode__badgeText {
fill: var(--theme-color-fg-on-accent);
font-weight: 600;
user-select: none;
pointer-events: none;
}
// Warning indicator for orphans
.TopologyNode__warning {
fill: var(--theme-color-warning);
stroke: var(--theme-color-bg-3);
stroke-width: 2;
}
.TopologyNode__warningText {
fill: var(--theme-color-fg-on-warning);
font-weight: 700;
user-select: none;
pointer-events: none;
}

View File

@@ -0,0 +1,88 @@
/**
* TopologyNode Component
*
* Renders a single component node in the topology map.
*/
import classNames from 'classnames';
import React from 'react';
import { TopologyNode as TopologyNodeType } from '../utils/topologyTypes';
import css from './TopologyNode.module.scss';
export interface TopologyNodeProps {
node: TopologyNodeType;
onClick?: (node: TopologyNodeType) => void;
onMouseEnter?: (node: TopologyNodeType, event: React.MouseEvent) => void;
onMouseLeave?: (node: TopologyNodeType, event: React.MouseEvent) => void;
}
export function TopologyNode({ node, onClick, onMouseEnter, onMouseLeave }: TopologyNodeProps) {
if (node.x === undefined || node.y === undefined || !node.width || !node.height) {
return null;
}
const isOrphan = node.usageCount === 0 && node.depth === 999;
return (
<g
className={classNames(css['TopologyNode'], {
[css['TopologyNode--page']]: node.type === 'page',
[css['TopologyNode--current']]: node.isCurrentComponent,
[css['TopologyNode--shared']]: node.usageCount >= 2,
[css['TopologyNode--orphan']]: isOrphan
})}
transform={`translate(${node.x}, ${node.y})`}
onClick={() => onClick?.(node)}
onMouseEnter={(e) => onMouseEnter?.(node, e)}
onMouseLeave={(e) => onMouseLeave?.(node, e)}
style={{ cursor: 'pointer' }}
>
{/* Background rectangle */}
<rect className={css['TopologyNode__rect']} width={node.width} height={node.height} rx={4} />
{/* Node icon indicator */}
<text className={css['TopologyNode__icon']} x={8} y={20} fontSize={14}>
{node.type === 'page' ? '📄' : '🧩'}
</text>
{/* Component name */}
<text
className={css['TopologyNode__text']}
x={node.width / 2}
y={node.height / 2 + 4}
textAnchor="middle"
fontSize={12}
>
{node.name.length > 15 ? node.name.substring(0, 13) + '...' : node.name}
</text>
{/* Current component indicator */}
{node.isCurrentComponent && (
<text className={css['TopologyNode__star']} x={node.width - 20} y={20} fontSize={16}>
</text>
)}
{/* Usage count badge (for shared components) */}
{node.usageCount >= 2 && (
<g transform={`translate(${node.width - 24}, ${node.height - 20})`}>
<circle className={css['TopologyNode__badge']} cx={12} cy={10} r={10} />
<text className={css['TopologyNode__badgeText']} x={12} y={14} textAnchor="middle" fontSize={10}>
×{node.usageCount}
</text>
</g>
)}
{/* Orphan warning indicator */}
{isOrphan && (
<g transform={`translate(${node.width - 20}, 8)`}>
<circle className={css['TopologyNode__warning']} cx={8} cy={8} r={8} />
<text className={css['TopologyNode__warningText']} x={8} y={12} textAnchor="middle" fontSize={12}>
!
</text>
</g>
)}
</g>
);
}

View File

@@ -0,0 +1,176 @@
/**
* useTopologyGraph Hook
*
* Builds the topology graph data structure from the current project.
* Uses VIEW-000 graph analysis utilities to extract component relationships.
*/
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
import { useEventListener } from '@noodl-hooks/useEventListener';
import { useMemo, useState } from 'react';
import { ComponentModel } from '@noodl-models/componentmodel';
import { ProjectModel } from '@noodl-models/projectmodel';
import { buildComponentDependencyGraph, findComponentUsages, getComponentDepth } from '@noodl-utils/graphAnalysis';
import { TopologyGraph, TopologyNode, TopologyEdge } from '../utils/topologyTypes';
/**
* Determines if a component should be classified as a page.
* Pages typically have 'Page' in their name or are at the root level.
*/
function isPageComponent(component: ComponentModel): boolean {
const name = component.name.toLowerCase();
return (
name.includes('page') ||
name.includes('screen') ||
name === 'app' ||
name === 'root' ||
component.fullName === component.name // Root level component
);
}
/**
* Builds the breadcrumb path from root to the current component.
*/
function buildBreadcrumbPath(currentComponent: ComponentModel | null, project: ProjectModel): string[] {
if (!currentComponent) return [];
const path: string[] = [];
const visited = new Set<string>();
// Start from current and work backwards to find a path to root
let current = currentComponent;
path.unshift(current.fullName);
visited.add(current.fullName);
// Find parent components (components that use the current one)
while (current) {
const usages = findComponentUsages(project, current.fullName);
if (usages.length === 0) {
// No parent found, we're at a root
break;
}
// Pick the first parent (could be multiple paths, we just show one)
const parent = usages[0].usedIn;
if (!parent || visited.has(parent.fullName)) {
// Avoid cycles
break;
}
path.unshift(parent.fullName);
visited.add(parent.fullName);
current = parent;
}
return path;
}
/**
* Hook that builds and returns the topology graph for the current project.
*
* @returns The complete topology graph with nodes, edges, and metadata
*
* @example
* ```tsx
* function MyComponent() {
* const graph = useTopologyGraph();
*
* return (
* <div>
* <p>Total components: {graph.totalNodes}</p>
* <p>Pages: {graph.counts.pages}</p>
* </div>
* );
* }
* ```
*/
export function useTopologyGraph(): TopologyGraph {
const project = ProjectModel.instance;
const [updateTrigger, setUpdateTrigger] = useState(0);
// Get current component from NodeGraphContext
const currentComponent = NodeGraphContextTmp.nodeGraph?.activeComponent || null;
// Rebuild graph when components change
useEventListener(ProjectModel.instance, 'componentAdded', () => {
setUpdateTrigger((prev) => prev + 1);
});
useEventListener(ProjectModel.instance, 'componentRemoved', () => {
setUpdateTrigger((prev) => prev + 1);
});
// Listen to node graph for component switches
useEventListener(NodeGraphContextTmp.nodeGraph, 'activeComponentChanged', () => {
setUpdateTrigger((prev) => prev + 1);
});
const graph = useMemo<TopologyGraph>(() => {
console.log('[TopologyMap] Building topology graph...');
// Use VIEW-000 utility to build the base graph
const dependencyGraph = buildComponentDependencyGraph(project);
// Build nodes with enhanced metadata
const nodes: TopologyNode[] = dependencyGraph.nodes.map((component) => {
const fullName = component.fullName;
const usages = findComponentUsages(project, fullName);
const depth = getComponentDepth(project, fullName);
// Find edges to/from this component
const usedBy = dependencyGraph.edges.filter((edge) => edge.to === fullName).map((edge) => edge.from);
const uses = dependencyGraph.edges.filter((edge) => edge.from === fullName).map((edge) => edge.to);
return {
component,
name: component.name,
fullName: fullName,
type: isPageComponent(component) ? 'page' : 'component',
usageCount: usages.length,
usedBy,
uses,
depth: depth >= 0 ? depth : 999, // Put unreachable components at the bottom
isCurrentComponent: currentComponent?.fullName === fullName
};
});
// Copy edges from dependency graph
const edges: TopologyEdge[] = dependencyGraph.edges.map((edge) => ({
from: edge.from,
to: edge.to,
count: edge.count
}));
// Build breadcrumb path
const currentPath = buildBreadcrumbPath(currentComponent, project);
// Calculate counts
const pages = nodes.filter((n) => n.type === 'page').length;
const components = nodes.filter((n) => n.type === 'component').length;
const shared = nodes.filter((n) => n.usageCount >= 2).length;
const orphans = nodes.filter((n) => n.usageCount === 0 && n.depth === 999).length;
console.log(`[TopologyMap] Built graph: ${nodes.length} nodes, ${edges.length} edges`);
console.log(`[TopologyMap] Stats: ${pages} pages, ${components} components, ${shared} shared, ${orphans} orphans`);
return {
nodes,
edges,
currentPath,
currentComponentName: currentComponent?.fullName || null,
totalNodes: nodes.length,
counts: {
pages,
components,
shared,
orphans
}
};
}, [project, currentComponent, updateTrigger]);
return graph;
}

View File

@@ -0,0 +1,166 @@
/**
* useTopologyLayout Hook
*
* Applies Dagre layout algorithm to position topology graph nodes.
* Returns a positioned graph ready for SVG rendering.
*/
import dagre from 'dagre';
import { useMemo } from 'react';
import { TopologyGraph, TopologyLayoutConfig, PositionedTopologyGraph, TopologyNode } from '../utils/topologyTypes';
/**
* Default layout configuration.
*/
const DEFAULT_LAYOUT_CONFIG: TopologyLayoutConfig = {
rankdir: 'TB', // Top to bottom
ranksep: 100, // Vertical spacing between ranks (increased from 80)
nodesep: 200, // Horizontal spacing between nodes (increased from 50 for better spread)
margin: { x: 50, y: 50 } // More breathing room (increased from 20)
};
/**
* Node dimensions based on type and content.
*/
function getNodeDimensions(node: TopologyNode): { width: number; height: number } {
const baseWidth = 120;
const baseHeight = 60;
// Pages are slightly larger
if (node.type === 'page') {
return { width: baseWidth + 20, height: baseHeight };
}
// Shared components (used multiple times) are slightly wider for badge
if (node.usageCount >= 2) {
return { width: baseWidth + 10, height: baseHeight };
}
return { width: baseWidth, height: baseHeight };
}
/**
* Hook that applies Dagre layout to a topology graph.
*
* @param graph - The topology graph to layout
* @param config - Optional layout configuration
* @returns Positioned topology graph with node coordinates and bounds
*
* @example
* ```tsx
* function MyComponent() {
* const graph = useTopologyGraph();
* const positionedGraph = useTopologyLayout(graph);
*
* return (
* <svg viewBox={`0 0 ${positionedGraph.bounds.width} ${positionedGraph.bounds.height}`}>
* {positionedGraph.nodes.map(node => (
* <rect key={node.fullName} x={node.x} y={node.y} width={node.width} height={node.height} />
* ))}
* </svg>
* );
* }
* ```
*/
export function useTopologyLayout(
graph: TopologyGraph,
config: Partial<TopologyLayoutConfig> = {}
): PositionedTopologyGraph {
const layoutConfig = { ...DEFAULT_LAYOUT_CONFIG, ...config };
const positionedGraph = useMemo<PositionedTopologyGraph>(() => {
console.log('[TopologyLayout] Calculating layout...');
// Create a new directed graph
const g = new dagre.graphlib.Graph();
// Set graph options
g.setGraph({
rankdir: layoutConfig.rankdir,
ranksep: layoutConfig.ranksep,
nodesep: layoutConfig.nodesep,
marginx: layoutConfig.margin.x,
marginy: layoutConfig.margin.y
});
// Default edge label
g.setDefaultEdgeLabel(() => ({}));
// Add nodes with their dimensions
graph.nodes.forEach((node) => {
const dimensions = getNodeDimensions(node);
g.setNode(node.fullName, {
width: dimensions.width,
height: dimensions.height,
...node // Store original node data
});
});
// Add edges
graph.edges.forEach((edge) => {
g.setEdge(edge.from, edge.to);
});
// Run layout algorithm
dagre.layout(g);
// Extract positioned nodes
const positionedNodes: TopologyNode[] = graph.nodes.map((node) => {
const dagreNode = g.node(node.fullName);
// Dagre returns center coordinates, we need top-left
const x = dagreNode.x - dagreNode.width / 2;
const y = dagreNode.y - dagreNode.height / 2;
return {
...node,
x,
y,
width: dagreNode.width,
height: dagreNode.height
};
});
// Calculate bounding box
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
positionedNodes.forEach((node) => {
if (node.x === undefined || node.y === undefined) return;
minX = Math.min(minX, node.x);
minY = Math.min(minY, node.y);
maxX = Math.max(maxX, node.x + (node.width || 0));
maxY = Math.max(maxY, node.y + (node.height || 0));
});
// Add some padding to bounds
const padding = 40;
const bounds = {
x: minX - padding,
y: minY - padding,
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2
};
console.log(`[TopologyLayout] Layout complete: ${bounds.width}x${bounds.height}`);
return {
...graph,
nodes: positionedNodes,
bounds
};
}, [
graph,
layoutConfig.rankdir,
layoutConfig.ranksep,
layoutConfig.nodesep,
layoutConfig.margin.x,
layoutConfig.margin.y
]);
return positionedGraph;
}

View File

@@ -0,0 +1,7 @@
/**
* TopologyMapPanel - Project Topology Map
*
* Exports the main panel component.
*/
export { TopologyMapPanel } from './TopologyMapPanel';

View File

@@ -0,0 +1,103 @@
/**
* Topology Map Types
*
* Type definitions for the Project Topology Map visualization.
*/
import { ComponentModel } from '@noodl-models/componentmodel';
/**
* A node in the topology graph representing a component.
*/
export interface TopologyNode {
/** Component model instance */
component: ComponentModel;
/** Component name (display) */
name: string;
/** Full component path */
fullName: string;
/** Component type classification */
type: 'page' | 'component';
/** Number of times this component is used */
usageCount: number;
/** Component names that use this component */
usedBy: string[];
/** Component names that this component uses */
uses: string[];
/** Nesting depth from root (0 = root, 1 = used by root, etc.) */
depth: number;
/** Whether this is the currently active component */
isCurrentComponent: boolean;
/** X position (set by layout engine) */
x?: number;
/** Y position (set by layout engine) */
y?: number;
/** Node width (set by layout engine) */
width?: number;
/** Node height (set by layout engine) */
height?: number;
}
/**
* An edge in the topology graph representing component usage.
*/
export interface TopologyEdge {
/** Source component fullName */
from: string;
/** Target component fullName */
to: string;
/** Number of instances of this relationship */
count: number;
}
/**
* The complete topology graph structure.
*/
export interface TopologyGraph {
/** All nodes in the graph */
nodes: TopologyNode[];
/** All edges in the graph */
edges: TopologyEdge[];
/** Breadcrumb path from root to current component */
currentPath: string[];
/** The currently active component fullName */
currentComponentName: string | null;
/** Total node count */
totalNodes: number;
/** Count by type */
counts: {
pages: number;
components: number;
shared: number; // Used 2+ times
orphans: number; // Never used
};
}
/**
* Configuration for the topology layout.
*/
export interface TopologyLayoutConfig {
/** Direction of flow */
rankdir: 'TB' | 'LR' | 'BT' | 'RL';
/** Vertical separation between ranks */
ranksep: number;
/** Horizontal separation between nodes */
nodesep: number;
/** Margins around the graph */
margin: { x: number; y: number };
}
/**
* Positioned topology graph ready for rendering.
*/
export interface PositionedTopologyGraph extends TopologyGraph {
/** Nodes with layout positions */
nodes: TopologyNode[];
/** Bounding box of the entire graph */
bounds: {
x: number;
y: number;
width: number;
height: number;
};
}

View File

@@ -0,0 +1,196 @@
/**
* Trigger Chain Debugger Panel Styles
*
* Uses design tokens from UI-STYLING-GUIDE.md
*/
.TriggerChainDebuggerPanel {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-default);
overflow: hidden;
}
/* =================================================================
Header
================================================================= */
.Header {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-2);
}
.Title {
display: flex;
align-items: center;
gap: 8px;
h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--theme-color-fg-default-shy);
}
}
.RecordingIndicator {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background-color: var(--theme-color-bg-3);
border-radius: 4px;
font-size: 13px;
font-weight: 500;
color: var(--theme-color-fg-highlight);
}
.RecordingDot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #ef4444; /* Red for recording indicator */
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* =================================================================
Controls
================================================================= */
.Controls {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-2);
}
.EventCount {
margin-left: auto;
padding: 6px 12px;
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
font-size: 13px;
font-weight: 500;
color: var(--theme-color-fg-default);
span {
color: var(--theme-color-fg-highlight);
}
}
/* =================================================================
Content - Scrollable Area
================================================================= */
.Content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
/* =================================================================
Empty State
================================================================= */
.EmptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 24px;
text-align: center;
color: var(--theme-color-fg-default-shy);
h3 {
margin: 16px 0 8px;
font-size: 16px;
font-weight: 500;
color: var(--theme-color-fg-default);
}
p {
margin: 0;
font-size: 13px;
}
}
/* =================================================================
Recording State
================================================================= */
.RecordingState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 24px;
text-align: center;
color: var(--theme-color-fg-default-shy);
h3 {
margin: 16px 0 8px;
font-size: 16px;
font-weight: 500;
color: var(--theme-color-fg-highlight);
}
p {
margin: 0;
font-size: 13px;
}
}
/* =================================================================
Timeline Container
================================================================= */
.TimelineContainer {
display: flex;
flex-direction: column;
gap: 16px;
}
.Placeholder {
padding: 48px 24px;
text-align: center;
background-color: var(--theme-color-bg-2);
border: 1px dashed var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default-shy);
p {
margin: 0 0 8px;
font-size: 14px;
}
}
.SmallText {
font-size: 12px !important;
font-style: italic;
color: var(--theme-color-fg-default-shy);
}

View File

@@ -0,0 +1,134 @@
/**
* Trigger Chain Debugger Panel
*
* Records and visualizes event trigger chains in the runtime preview.
* Shows a timeline of events, their relationships, and component boundaries.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { triggerChainRecorder } from '../../../utils/triggerChain';
import { ChainStats } from './components/ChainStats';
import { ChainTimeline } from './components/ChainTimeline';
import css from './TriggerChainDebuggerPanel.module.scss';
export function TriggerChainDebuggerPanel() {
const [isRecording, setIsRecording] = useState(false);
const [eventCount, setEventCount] = useState(0);
const [liveEvents, setLiveEvents] = useState(triggerChainRecorder.getEvents());
const handleStartRecording = useCallback(() => {
triggerChainRecorder.startRecording();
setIsRecording(true);
setEventCount(0);
setLiveEvents([]);
}, []);
const handleStopRecording = useCallback(() => {
triggerChainRecorder.stopRecording();
setIsRecording(false);
const state = triggerChainRecorder.getState();
setEventCount(state.events.length);
}, []);
const handleClear = useCallback(() => {
triggerChainRecorder.reset();
setEventCount(0);
}, []);
const hasEvents = eventCount > 0;
// Poll for events while recording (live updates)
useEffect(() => {
if (!isRecording) return;
const interval = setInterval(() => {
const events = triggerChainRecorder.getEvents();
setEventCount(events.length);
setLiveEvents(events);
}, 100); // Poll every 100ms
return () => clearInterval(interval);
}, [isRecording]);
return (
<div className={css['TriggerChainDebuggerPanel']}>
{/* Header */}
<div className={css['Header']}>
<div className={css['Title']}>
<Icon icon={IconName.CloudData} />
<h2>Trigger Chain Debugger</h2>
</div>
{isRecording && (
<div className={css['RecordingIndicator']}>
<span className={css['RecordingDot']} />
<span>Recording...</span>
</div>
)}
</div>
{/* Recording Controls */}
<div className={css['Controls']}>
{!isRecording ? (
<PrimaryButton
label="Start Recording"
onClick={handleStartRecording}
variant={PrimaryButtonVariant.Cta}
icon={IconName.Play}
/>
) : (
<PrimaryButton
label="Stop Recording"
onClick={handleStopRecording}
variant={PrimaryButtonVariant.Danger}
icon={IconName.Close}
/>
)}
{hasEvents && !isRecording && (
<PrimaryButton
label="Clear"
onClick={handleClear}
variant={PrimaryButtonVariant.Muted}
icon={IconName.Trash}
/>
)}
{hasEvents && (
<div className={css['EventCount']}>
<span>{eventCount} events captured</span>
</div>
)}
</div>
{/* Content */}
<div className={css['Content']}>
{!hasEvents && !isRecording && (
<div className={css['EmptyState']}>
<Icon icon={IconName.CloudData} />
<h3>No Events Recorded</h3>
<p>Click &quot;Start Recording&quot; then interact with your preview to capture event chains</p>
</div>
)}
{isRecording && !hasEvents && (
<div className={css['RecordingState']}>
<Icon icon={IconName.CloudData} />
<h3>Recording Active</h3>
<p>Interact with your preview to capture events...</p>
</div>
)}
{hasEvents && (
<div className={css['TimelineContainer']}>
<ChainStats events={liveEvents} isRecording={isRecording} />
<ChainTimeline events={liveEvents} isRecording={isRecording} />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
/**
* ChainStats Component Styles
*
* Uses design tokens from UI-STYLING-GUIDE.md
*/
.ChainStats {
padding: 16px;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
margin-bottom: 16px;
}
/* =================================================================
Stats Title
================================================================= */
.StatsTitle {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
/* =================================================================
Stat Groups
================================================================= */
.StatGroup {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.GroupTitle {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--theme-color-fg-default-shy);
}
/* =================================================================
Stat Items
================================================================= */
.StatItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
background-color: var(--theme-color-bg-3);
border-radius: 3px;
margin-bottom: 4px;
font-size: 12px;
&:last-child {
margin-bottom: 0;
}
}
.StatLabel {
color: var(--theme-color-fg-default);
}
.StatValue {
font-weight: 600;
font-family: monospace;
color: var(--theme-color-fg-highlight);
}
/* =================================================================
Component List
================================================================= */
.ComponentList {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ComponentChip {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 3px;
font-size: 11px;
color: var(--theme-color-fg-default);
transition: all 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-4);
border-color: var(--theme-color-primary);
}
}

View File

@@ -0,0 +1,117 @@
/**
* ChainStats Component
*
* Displays statistics about the trigger chain.
* Shows event counts, component breakdown, and timing info.
*/
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
import React, { useCallback, useMemo } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { ProjectModel } from '../../../../models/projectmodel';
import { buildChainFromEvents, calculateStatistics, TriggerEvent } from '../../../../utils/triggerChain';
import css from './ChainStats.module.scss';
export interface ChainStatsProps {
events: TriggerEvent[];
isRecording?: boolean;
}
export function ChainStats({ events, isRecording }: ChainStatsProps) {
// Build chain and calculate stats
const stats = useMemo(() => {
if (events.length === 0) return null;
const chain = buildChainFromEvents(events);
return calculateStatistics(chain);
}, [events]);
const handleComponentClick = useCallback(
(componentName: string) => {
// Don't navigate while recording
if (isRecording) return;
// Find and navigate to the component
const component = ProjectModel.instance?.getComponentWithName(componentName);
if (component && NodeGraphContextTmp.switchToComponent) {
NodeGraphContextTmp.switchToComponent(component, { pushHistory: true });
}
},
[isRecording]
);
if (!stats) {
return null;
}
return (
<div className={css['ChainStats']}>
<h4 className={css['StatsTitle']}>
<Icon icon={IconName.Setting} />
Chain Statistics
</h4>
{/* Total Events */}
<div className={css['StatGroup']}>
<div className={css['StatItem']}>
<span className={css['StatLabel']}>Total Events</span>
<span className={css['StatValue']}>{stats.totalEvents}</span>
</div>
</div>
{/* Events by Type */}
<div className={css['StatGroup']}>
<h5 className={css['GroupTitle']}>Events by Type</h5>
{Array.from(stats.eventsByType.entries()).map(([type, count]) => (
<div key={type} className={css['StatItem']}>
<span className={css['StatLabel']}>{type}</span>
<span className={css['StatValue']}>{count}</span>
</div>
))}
</div>
{/* Events by Component */}
<div className={css['StatGroup']}>
<h5 className={css['GroupTitle']}>Events by Component</h5>
{Array.from(stats.eventsByComponent.entries()).map(([component, count]) => (
<div key={component} className={css['StatItem']}>
<span className={css['StatLabel']}>{component}</span>
<span className={css['StatValue']}>{count}</span>
</div>
))}
</div>
{/* Timing */}
<div className={css['StatGroup']}>
<h5 className={css['GroupTitle']}>Timing</h5>
<div className={css['StatItem']}>
<span className={css['StatLabel']}>Average Gap</span>
<span className={css['StatValue']}>{stats.averageEventGap.toFixed(2)}ms</span>
</div>
<div className={css['StatItem']}>
<span className={css['StatLabel']}>Longest Gap</span>
<span className={css['StatValue']}>{stats.longestGap.toFixed(2)}ms</span>
</div>
</div>
{/* Components Involved */}
<div className={css['StatGroup']}>
<h5 className={css['GroupTitle']}>Components Involved</h5>
<div className={css['ComponentList']}>
{stats.componentsInvolved.map((component) => (
<div
key={component}
className={css['ComponentChip']}
onClick={() => handleComponentClick(component)}
style={{ cursor: isRecording ? 'default' : 'pointer' }}
>
<Icon icon={IconName.Component} />
<span>{component}</span>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
/**
* ChainTimeline Component Styles
*
* Uses design tokens from UI-STYLING-GUIDE.md
*/
.ChainTimeline {
display: flex;
flex-direction: column;
gap: 16px;
}
/* =================================================================
Chain Header
================================================================= */
.ChainHeader {
padding: 12px;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
}
.ChainInfo {
h3 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
}
}
.ChainMeta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--theme-color-fg-default-shy);
span {
&:nth-child(2) {
color: var(--theme-color-fg-default-shy);
}
}
}
/* =================================================================
Timeline List
================================================================= */
.TimelineList {
display: flex;
flex-direction: column;
}
/* =================================================================
Empty Timeline
================================================================= */
.EmptyTimeline {
padding: 48px 24px;
text-align: center;
background-color: var(--theme-color-bg-2);
border: 1px dashed var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default-shy);
p {
margin: 0;
font-size: 13px;
}
}

View File

@@ -0,0 +1,73 @@
/**
* ChainTimeline Component
*
* Displays a timeline visualization of trigger chain events.
* Builds chains from raw events using buildChainFromEvents().
*/
import React, { useMemo } from 'react';
import { buildChainFromEvents, calculateTiming, TriggerEvent } from '../../../../utils/triggerChain';
import css from './ChainTimeline.module.scss';
import { EventStep } from './EventStep';
export interface ChainTimelineProps {
events: TriggerEvent[];
isRecording?: boolean;
}
export function ChainTimeline({ events, isRecording }: ChainTimelineProps) {
// Build the trigger chain from raw events
const chain = useMemo(() => {
if (events.length === 0) return null;
return buildChainFromEvents(events);
}, [events]);
// Calculate timing for each event
const timing = useMemo(() => {
if (!chain) return [];
return calculateTiming(chain);
}, [chain]);
if (!chain || events.length === 0) {
return (
<div className={css['ChainTimeline']}>
<div className={css['EmptyTimeline']}>
<p>No events to display</p>
</div>
</div>
);
}
return (
<div className={css['ChainTimeline']}>
{/* Chain Header */}
<div className={css['ChainHeader']}>
<div className={css['ChainInfo']}>
<h3>{chain.name}</h3>
<div className={css['ChainMeta']}>
<span>{chain.eventCount} events</span>
<span></span>
<span>{chain.duration.toFixed(2)}ms</span>
</div>
</div>
</div>
{/* Timeline */}
<div className={css['TimelineList']}>
{chain.events.map((event) => {
const eventTiming = timing.find((t) => t.eventId === event.id);
return (
<EventStep
key={event.id}
event={event}
timeSinceStart={eventTiming?.sinceStart || 0}
timeSincePrevious={eventTiming?.sincePrevious || 0}
isRecording={isRecording}
/>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
/**
* EventStep Component Styles
*
* Uses design tokens from UI-STYLING-GUIDE.md
*/
.EventStep {
display: flex;
gap: 12px;
position: relative;
padding-bottom: 16px;
&:last-child {
.TimelineLine {
display: none;
}
}
}
/* =================================================================
Timeline Connector
================================================================= */
.TimelineConnector {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
padding-top: 4px;
}
.TimelineDot {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--theme-color-primary);
border: 2px solid var(--theme-color-bg-1);
flex-shrink: 0;
z-index: 1;
}
.TimelineLine {
width: 2px;
flex: 1;
background-color: var(--theme-color-border-default);
margin-top: 4px;
}
/* =================================================================
Event Card
================================================================= */
.EventCard {
flex: 1;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
padding: 12px;
transition: all 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-3);
border-color: var(--theme-color-primary);
}
}
/* =================================================================
Event Header
================================================================= */
.EventHeader {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.EventIcon {
flex-shrink: 0;
padding: 6px;
background-color: var(--theme-color-bg-3);
border-radius: 4px;
color: var(--theme-color-primary);
}
.EventMeta {
flex: 1;
min-width: 0;
}
.NodeInfo {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.NodeType {
font-size: 13px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
.NodeLabel {
font-size: 12px;
color: var(--theme-color-fg-default-shy);
font-style: italic;
}
.ComponentInfo {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--theme-color-fg-default-shy);
}
.EventType {
flex-shrink: 0;
span {
padding: 4px 8px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-default);
&.type-signal {
background-color: var(--theme-color-primary);
color: white;
}
&.type-value-change {
background-color: #3b82f6;
color: white;
}
&.type-error {
background-color: #ef4444;
color: white;
}
}
}
/* =================================================================
Event Timing
================================================================= */
.EventTiming {
display: flex;
gap: 16px;
padding: 8px;
background-color: var(--theme-color-bg-3);
border-radius: 3px;
margin-bottom: 8px;
}
.TimingItem {
display: flex;
gap: 6px;
font-size: 11px;
}
.TimingLabel {
color: var(--theme-color-fg-default-shy);
}
.TimingValue {
font-weight: 600;
font-family: monospace;
color: var(--theme-color-fg-highlight);
}
/* =================================================================
Port Info
================================================================= */
.EventPort {
display: flex;
gap: 6px;
padding: 6px 8px;
background-color: var(--theme-color-bg-3);
border-radius: 3px;
font-size: 11px;
margin-bottom: 8px;
}
.PortLabel {
color: var(--theme-color-fg-default-shy);
}
.PortName {
font-weight: 600;
font-family: monospace;
color: var(--theme-color-fg-default);
}
/* =================================================================
Data Preview
================================================================= */
.EventData {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
background-color: var(--theme-color-bg-3);
border-radius: 3px;
margin-bottom: 8px;
}
.DataLabel {
font-size: 11px;
font-weight: 600;
color: var(--theme-color-fg-default-shy);
}
.DataValue {
font-size: 11px;
font-family: monospace;
color: var(--theme-color-fg-default);
white-space: pre-wrap;
word-break: break-all;
max-height: 100px;
overflow-y: auto;
padding: 4px;
background-color: var(--theme-color-bg-1);
border-radius: 2px;
}
/* =================================================================
Error Info
================================================================= */
.EventError {
display: flex;
align-items: center;
gap: 6px;
padding: 8px;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid #ef4444;
border-radius: 3px;
color: #ef4444;
font-size: 12px;
}

View File

@@ -0,0 +1,155 @@
/**
* EventStep Component
*
* Displays a single event in the trigger chain timeline.
* Shows node info, timing, and event type.
*/
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
import React, { useCallback } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { ProjectModel } from '../../../../models/projectmodel';
import { TriggerEvent } from '../../../../utils/triggerChain';
import css from './EventStep.module.scss';
export interface EventStepProps {
event: TriggerEvent;
timeSinceStart: number;
timeSincePrevious: number;
isRecording?: boolean;
}
/**
* Get icon for event type
*/
function getEventTypeIcon(type: string): IconName {
switch (type) {
case 'signal':
return IconName.Play;
case 'value-change':
return IconName.Setting;
case 'component-enter':
return IconName.Component;
case 'component-exit':
return IconName.Component;
case 'api-call':
return IconName.CloudData;
case 'api-response':
return IconName.CloudData;
case 'navigation':
return IconName.Navigate;
case 'error':
return IconName.Close;
default:
return IconName.Play;
}
}
/**
* Format duration in milliseconds to readable string
*/
function formatDuration(ms: number): string {
if (ms < 1) return '<1ms';
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
export function EventStep({ event, timeSinceStart, timeSincePrevious, isRecording }: EventStepProps) {
const icon = getEventTypeIcon(event.type);
const handleClick = useCallback(() => {
// Don't navigate while recording
if (isRecording) return;
// Find the component
const component = ProjectModel.instance?.getComponentWithName(event.componentName);
if (!component || !NodeGraphContextTmp.switchToComponent) return;
// Find the node if we have a nodeId
let nodeToSelect;
if (event.nodeId && component.graph) {
nodeToSelect = component.graph.findNodeWithId(event.nodeId);
}
// Navigate to component and select the node (if found)
NodeGraphContextTmp.switchToComponent(component, {
node: nodeToSelect,
pushHistory: true
});
}, [event.componentName, event.nodeId, isRecording]);
return (
<div className={css['EventStep']}>
{/* Timeline Connector */}
<div className={css['TimelineConnector']}>
<div className={css['TimelineDot']} />
<div className={css['TimelineLine']} />
</div>
{/* Event Card */}
<div className={css['EventCard']} onClick={handleClick} style={{ cursor: isRecording ? 'default' : 'pointer' }}>
{/* Header */}
<div className={css['EventHeader']}>
<div className={css['EventIcon']}>
<Icon icon={icon} />
</div>
<div className={css['EventMeta']}>
<div className={css['NodeInfo']}>
<span className={css['NodeType']}>{event.nodeType}</span>
{event.nodeLabel && <span className={css['NodeLabel']}>{event.nodeLabel}</span>}
</div>
<div className={css['ComponentInfo']}>
<Icon icon={IconName.Component} />
<span>{event.componentName}</span>
</div>
</div>
<div className={css['EventType']}>
<span className={css[`type-${event.type}`]}>{event.type}</span>
</div>
</div>
{/* Timing Info */}
<div className={css['EventTiming']}>
<div className={css['TimingItem']}>
<span className={css['TimingLabel']}>Since Start:</span>
<span className={css['TimingValue']}>{formatDuration(timeSinceStart)}</span>
</div>
{timeSincePrevious > 0 && (
<div className={css['TimingItem']}>
<span className={css['TimingLabel']}>Delta:</span>
<span className={css['TimingValue']}>+{formatDuration(timeSincePrevious)}</span>
</div>
)}
</div>
{/* Port Info */}
{event.port && (
<div className={css['EventPort']}>
<span className={css['PortLabel']}>Port:</span>
<span className={css['PortName']}>{event.port}</span>
</div>
)}
{/* Data Preview */}
{event.data !== undefined && (
<div className={css['EventData']}>
<span className={css['DataLabel']}>Data:</span>
<code className={css['DataValue']}>
{typeof event.data === 'object' ? JSON.stringify(event.data, null, 2) : String(event.data)}
</code>
</div>
)}
{/* Error Info */}
{event.error && (
<div className={css['EventError']}>
<Icon icon={IconName.Close} />
<span>{event.error.message}</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
/**
* Trigger Chain Debugger Panel
*
* Panel for recording and visualizing event trigger chains in the runtime.
*
* @module TriggerChainDebuggerPanel
*/
export { TriggerChainDebuggerPanel } from './TriggerChainDebuggerPanel';

View File

@@ -1,46 +1,53 @@
import { ipcRenderer } from 'electron';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { LocalStorageKey } from '@noodl-constants/LocalStorageKey';
import getDocsEndpoint from '@noodl-utils/getDocsEndpoint';
import { NewsModal } from './views/NewsModal';
import PopupLayer from './views/popuplayer';
/**
* Display latest whats-new-post if the user hasn't seen one after it was last published
* @returns
*/
export async function whatsnewRender() {
const newEditorVersionAvailable = JSON.parse(localStorage.getItem(LocalStorageKey.hasNewEditorVersionAvailable));
// if user runs an older version the changelog will be irrelevant
if (newEditorVersionAvailable) return;
const latestChangelogPost = await fetch(`${getDocsEndpoint()}/whats-new/feed.json`)
.then((data) => data.json())
.then((json) => json.items[0]);
const lastSeenChangelogDate = new Date(
JSON.parse(localStorage.getItem(LocalStorageKey.lastSeenChangelogDate))
).getTime();
const latestChangelogDate = new Date(latestChangelogPost.date_modified).getTime();
if (lastSeenChangelogDate >= latestChangelogDate) return;
ipcRenderer.send('viewer-hide');
const modalContainer = document.createElement('div');
modalContainer.classList.add('popup-layer-react-modal');
PopupLayer.instance.el.find('.popup-layer-modal').before(modalContainer);
createRoot(modalContainer).render(
React.createElement(NewsModal, {
content: latestChangelogPost.content_html,
onFinished: () => ipcRenderer.send('viewer-show')
})
);
localStorage.setItem(LocalStorageKey.lastSeenChangelogDate, latestChangelogDate.toString());
}
import { ipcRenderer } from 'electron';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { LocalStorageKey } from '@noodl-constants/LocalStorageKey';
import getDocsEndpoint from '@noodl-utils/getDocsEndpoint';
import { NewsModal } from './views/NewsModal';
import PopupLayer from './views/popuplayer';
/**
* Display latest whats-new-post if the user hasn't seen one after it was last published
* @returns
*/
export async function whatsnewRender() {
const newEditorVersionAvailable = JSON.parse(localStorage.getItem(LocalStorageKey.hasNewEditorVersionAvailable));
// if user runs an older version the changelog will be irrelevant
if (newEditorVersionAvailable) return;
const latestChangelogPost = await fetch(`${getDocsEndpoint()}/whats-new/feed.json`)
.then((data) => data.json())
.then((json) => json.items[0]);
const lastSeenChangelogDate = new Date(
JSON.parse(localStorage.getItem(LocalStorageKey.lastSeenChangelogDate))
).getTime();
const latestChangelogDate = new Date(latestChangelogPost.date_modified).getTime();
if (lastSeenChangelogDate >= latestChangelogDate) return;
ipcRenderer.send('viewer-hide');
const modalContainer = document.createElement('div');
modalContainer.classList.add('popup-layer-react-modal');
PopupLayer.instance.el.find('.popup-layer-modal').before(modalContainer);
// Create root once and properly unmount when finished
const modalRoot = createRoot(modalContainer);
modalRoot.render(
React.createElement(NewsModal, {
content: latestChangelogPost.content_html,
onFinished: () => {
ipcRenderer.send('viewer-show');
// Properly cleanup React root and DOM element
modalRoot.unmount();
modalContainer.remove();
}
})
);
localStorage.setItem(LocalStorageKey.lastSeenChangelogDate, latestChangelogDate.toString());
}

View File

@@ -12,6 +12,11 @@ module.exports = merge(shared, {
// Use faster sourcemap for development - 'eval-cheap-module-source-map' is much faster
// than 'eval-source-map' while still providing decent debugging experience
devtool: 'eval-cheap-module-source-map',
// CRITICAL FIX: Disable ALL webpack caching in development
// This ensures code changes are always picked up without requiring `npm run clean:all`
cache: false,
externals: getExternalModules({
production: false
}),
@@ -31,6 +36,10 @@ module.exports = merge(shared, {
hot: true,
host: 'localhost', // Default: '0.0.0.0' that is causing issues on some OS / net interfaces
port: 8080,
// Disable server-side caching
headers: {
'Cache-Control': 'no-store'
},
onListening(devServer) {
// Wait for webpack compilation to finish before starting Electron
// This prevents the black screen issue where Electron opens before
@@ -42,10 +51,14 @@ module.exports = merge(shared, {
console.error('Webpack compilation has errors - not starting Electron');
return;
}
electronStarted = true;
console.log('\n✓ Webpack compilation complete - launching Electron...\n');
// Build timestamp canary for cache verification
console.log(`🔥 BUILD TIMESTAMP: ${new Date().toISOString()}`);
console.log(' (Check console for this timestamp to verify fresh code is running)\n');
child_process
.spawn('npm', ['run', 'start:_dev'], {
shell: true,