mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 07:12:54 +01:00
Added three new experimental views
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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! 🚀
|
||||
@@ -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
|
||||
};
|
||||
@@ -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()');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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()');
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }[];
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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={
|
||||
<>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Component X-Ray Panel
|
||||
*
|
||||
* Exports the Component X-Ray panel for sidebar registration
|
||||
*/
|
||||
|
||||
export { ComponentXRayPanel } from './ComponentXRayPanel';
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* TopologyMapPanel - Project Topology Map
|
||||
*
|
||||
* Exports the main panel component.
|
||||
*/
|
||||
|
||||
export { TopologyMapPanel } from './TopologyMapPanel';
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 "Start Recording" 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user