mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 07:12:54 +01:00
Tried to add data lineage view, implementation failed and requires rethink
This commit is contained in:
@@ -83,6 +83,39 @@ export function EditorPage({ route }: EditorPageProps) {
|
||||
const [lesson, setLesson] = useState(null);
|
||||
const [frameDividerSize, setFrameDividerSize] = useState(undefined);
|
||||
|
||||
// Handle sidebar expansion for topology panel
|
||||
useEffect(() => {
|
||||
const eventGroup = {};
|
||||
|
||||
const updateSidebarSize = () => {
|
||||
const activeId = SidebarModel.instance.ActiveId;
|
||||
const isTopology = activeId === 'topology';
|
||||
|
||||
if (isTopology) {
|
||||
// Calculate 55vw in pixels to match SideNavigation expansion
|
||||
const expandedSize = Math.floor(window.innerWidth * 0.55);
|
||||
setFrameDividerSize(expandedSize);
|
||||
} else {
|
||||
// Use default size
|
||||
setFrameDividerSize(380);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen to sidebar changes
|
||||
SidebarModel.instance.on(SidebarModelEvent.activeChanged, updateSidebarSize, eventGroup);
|
||||
|
||||
// Also listen to window resize to recalculate 55vw
|
||||
window.addEventListener('resize', updateSidebarSize);
|
||||
|
||||
// Run once on mount
|
||||
updateSidebarSize();
|
||||
|
||||
return () => {
|
||||
SidebarModel.instance.off(eventGroup);
|
||||
window.removeEventListener('resize', updateSidebarSize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Display latest whats-new-post if the user hasn't seen one after it was last published
|
||||
whatsnewRender();
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CloudServicePanel } from './views/panels/CloudServicePanel/CloudService
|
||||
import { ComponentPortsComponent } from './views/panels/componentports';
|
||||
import { ComponentsPanel } from './views/panels/componentspanel';
|
||||
import { ComponentXRayPanel } from './views/panels/ComponentXRayPanel';
|
||||
import { DataLineagePanel } from './views/panels/DataLineagePanel';
|
||||
import { DesignTokenPanel } from './views/panels/DesignTokenPanel/DesignTokenPanel';
|
||||
import { EditorSettingsPanel } from './views/panels/EditorSettingsPanel/EditorSettingsPanel';
|
||||
import { FileExplorerPanel } from './views/panels/FileExplorerPanel';
|
||||
@@ -101,6 +102,17 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) {
|
||||
panel: ComponentXRayPanel
|
||||
});
|
||||
|
||||
SidebarModel.instance.register({
|
||||
experimental: true,
|
||||
id: 'data-lineage',
|
||||
name: 'Data Lineage',
|
||||
description:
|
||||
'Traces where data values come from (upstream sources) and where they go to (downstream destinations), crossing component boundaries.',
|
||||
order: 4.5,
|
||||
icon: IconName.Link,
|
||||
panel: DataLineagePanel
|
||||
});
|
||||
|
||||
SidebarModel.instance.register({
|
||||
id: VersionControlPanel_ID,
|
||||
name: 'Version control',
|
||||
|
||||
@@ -51,3 +51,14 @@ export {
|
||||
analyzeDuplicateConflicts,
|
||||
findSimilarlyNamedNodes
|
||||
} from './duplicateDetection';
|
||||
|
||||
// Export lineage analysis utilities
|
||||
export {
|
||||
buildLineage,
|
||||
traceUpstream,
|
||||
traceDownstream,
|
||||
type LineageResult,
|
||||
type LineagePath,
|
||||
type LineageStep,
|
||||
type ComponentBoundary
|
||||
} from './lineage';
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Data Lineage Analysis
|
||||
*
|
||||
* Traces the complete upstream (source) and downstream (destination) paths for data flow,
|
||||
* crossing component boundaries to provide a full picture of where values come from and where they go.
|
||||
*
|
||||
* @module graphAnalysis/lineage
|
||||
* @since 1.3.0
|
||||
*/
|
||||
|
||||
import type { ComponentModel } from '@noodl-models/componentmodel';
|
||||
import type { NodeGraphNode } from '@noodl-models/nodegraphmodel';
|
||||
import type { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import { resolveComponentBoundary } from './crossComponent';
|
||||
import { getPortConnections } from './traversal';
|
||||
import type { ConnectionRef } from './types';
|
||||
|
||||
/**
|
||||
* Complete lineage result for a node/port
|
||||
*/
|
||||
export interface LineageResult {
|
||||
/** The node/port being analyzed */
|
||||
selectedNode: {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
componentName: string;
|
||||
port?: string;
|
||||
};
|
||||
|
||||
/** Upstream path (where data comes from) */
|
||||
upstream: LineagePath;
|
||||
|
||||
/** Downstream paths (where data goes to) - can branch to multiple destinations */
|
||||
downstream: LineagePath[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A path through the graph showing data flow
|
||||
*/
|
||||
export interface LineagePath {
|
||||
/** Ordered steps in this path */
|
||||
steps: LineageStep[];
|
||||
|
||||
/** Component boundary crossings in this path */
|
||||
crossings: ComponentBoundary[];
|
||||
|
||||
/** Whether this path branches into multiple destinations */
|
||||
branches?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single step in a lineage path
|
||||
*/
|
||||
export interface LineageStep {
|
||||
/** The node at this step */
|
||||
node: NodeGraphNode;
|
||||
|
||||
/** Component containing this node */
|
||||
component: ComponentModel;
|
||||
|
||||
/** Port name */
|
||||
port: string;
|
||||
|
||||
/** Port direction */
|
||||
portType: 'input' | 'output';
|
||||
|
||||
/** Optional description of transformation (e.g., ".name property", "Expression: {a} + {b}") */
|
||||
transformation?: string;
|
||||
|
||||
/** True if this is the ultimate source (no further upstream) */
|
||||
isSource?: boolean;
|
||||
|
||||
/** True if this is a final destination (no further downstream) */
|
||||
isSink?: boolean;
|
||||
|
||||
/** Connection leading to/from this step */
|
||||
connection?: ConnectionRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about crossing a component boundary
|
||||
*/
|
||||
export interface ComponentBoundary {
|
||||
/** Component we're leaving */
|
||||
from: ComponentModel;
|
||||
|
||||
/** Component we're entering */
|
||||
to: ComponentModel;
|
||||
|
||||
/** Port name at the boundary */
|
||||
viaPort: string;
|
||||
|
||||
/** Direction of crossing */
|
||||
direction: 'into' | 'outof';
|
||||
|
||||
/** Index in the steps array where this crossing occurs */
|
||||
stepIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete lineage for a node, tracing both upstream and downstream paths.
|
||||
*
|
||||
* @param project - Project containing all components
|
||||
* @param component - Component containing the starting node
|
||||
* @param nodeId - ID of the node to trace
|
||||
* @param port - Optional specific port to trace (if omitted, traces all connections)
|
||||
* @returns Complete lineage result with upstream and downstream paths
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const lineage = buildLineage(project, component, textNodeId, 'text');
|
||||
* console.log('Source:', lineage.upstream.steps[0].node.label);
|
||||
* console.log('Destinations:', lineage.downstream.map(p => p.steps[p.steps.length - 1].node.label));
|
||||
* ```
|
||||
*/
|
||||
export function buildLineage(
|
||||
project: ProjectModel,
|
||||
component: ComponentModel,
|
||||
nodeId: string,
|
||||
port?: string
|
||||
): LineageResult | null {
|
||||
const node = component.graph.nodeMap.get(nodeId);
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedNode = {
|
||||
id: node.id,
|
||||
label: node.label || node.typename,
|
||||
type: node.typename,
|
||||
componentName: component.name,
|
||||
port
|
||||
};
|
||||
|
||||
// Trace upstream (find sources)
|
||||
const upstream = traceUpstream(project, component, node, port);
|
||||
|
||||
// Trace downstream (find all destinations)
|
||||
const downstream = traceDownstream(project, component, node, port);
|
||||
|
||||
return {
|
||||
selectedNode,
|
||||
upstream,
|
||||
downstream
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace upstream to find the source(s) of data.
|
||||
* Follows connections backwards through the graph and across component boundaries.
|
||||
*/
|
||||
/**
|
||||
* Primary data ports by node type - only trace these ports for focused results
|
||||
*/
|
||||
const PRIMARY_DATA_PORTS: Record<string, string[]> = {
|
||||
Variable: ['value'],
|
||||
Variable2: ['value'],
|
||||
String: ['value'],
|
||||
Number: ['value'],
|
||||
Boolean: ['value'],
|
||||
Object: ['value'],
|
||||
Array: ['value'],
|
||||
Text: ['text'],
|
||||
Image: ['src'],
|
||||
Component: [], // Skip visual components entirely for primary tracing
|
||||
Group: [] // Skip layout components
|
||||
};
|
||||
|
||||
export function traceUpstream(
|
||||
project: ProjectModel,
|
||||
component: ComponentModel,
|
||||
startNode: NodeGraphNode,
|
||||
startPort?: string,
|
||||
visited: Set<string> = new Set(),
|
||||
depth: number = 0
|
||||
): LineagePath {
|
||||
const MAX_DEPTH = 5; // Limit depth to prevent noise
|
||||
const steps: LineageStep[] = [];
|
||||
const crossings: ComponentBoundary[] = [];
|
||||
|
||||
if (depth >= MAX_DEPTH) {
|
||||
return { steps, crossings };
|
||||
}
|
||||
|
||||
// Create unique key for cycle detection
|
||||
const nodeKey = `${component.fullName}:${startNode.id}:${startPort || '*'}`;
|
||||
if (visited.has(nodeKey)) {
|
||||
return { steps, crossings }; // Cycle detected
|
||||
}
|
||||
visited.add(nodeKey);
|
||||
|
||||
// Determine which inputs to trace
|
||||
const portsToTrace = startPort ? [startPort] : getInputPorts(startNode);
|
||||
|
||||
for (const portName of portsToTrace) {
|
||||
const connections = getPortConnections(component, startNode.id, portName, 'input');
|
||||
|
||||
if (connections.length === 0) {
|
||||
// No connections - skip unconnected ports unless this is a Component Input boundary
|
||||
if (startNode.typename === 'Component Inputs') {
|
||||
// Cross into parent component
|
||||
const external = resolveComponentBoundary(project, component, startNode.id, portName);
|
||||
|
||||
if (external.length > 0) {
|
||||
// Found connection in parent - continue tracing there
|
||||
const ext = external[0]; // Take first parent connection
|
||||
const parentComponent = project.getComponentWithName(ext.parentNodeId.split('.')[0]);
|
||||
|
||||
if (parentComponent) {
|
||||
const parentNode = parentComponent.graph.nodeMap.get(ext.parentNodeId);
|
||||
|
||||
if (parentNode) {
|
||||
crossings.push({
|
||||
from: component,
|
||||
to: parentComponent,
|
||||
viaPort: portName,
|
||||
direction: 'into',
|
||||
stepIndex: steps.length
|
||||
});
|
||||
|
||||
// Recursively trace in parent
|
||||
const parentPath = traceUpstream(
|
||||
project,
|
||||
parentComponent,
|
||||
parentNode,
|
||||
ext.parentPort,
|
||||
visited,
|
||||
depth + 1
|
||||
);
|
||||
|
||||
steps.push(...parentPath.steps);
|
||||
crossings.push(...parentPath.crossings);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// True source node - no further upstream
|
||||
steps.push({
|
||||
node: startNode,
|
||||
component,
|
||||
port: portName,
|
||||
portType: 'input',
|
||||
isSource: true
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Follow connections upstream
|
||||
for (const conn of connections) {
|
||||
const sourceNode = component.graph.nodeMap.get(conn.fromNodeId);
|
||||
if (!sourceNode) continue;
|
||||
|
||||
// Add this step
|
||||
steps.push({
|
||||
node: sourceNode,
|
||||
component,
|
||||
port: conn.fromPort,
|
||||
portType: 'output',
|
||||
transformation: describeTransformation(sourceNode, conn.fromPort),
|
||||
connection: conn
|
||||
});
|
||||
|
||||
// Check if source is a source-type node
|
||||
if (isSourceNode(sourceNode)) {
|
||||
steps[steps.length - 1].isSource = true;
|
||||
continue; // Don't trace further
|
||||
}
|
||||
|
||||
// Check for component boundary
|
||||
if (sourceNode.typename === 'Component Outputs') {
|
||||
// This comes from a child component
|
||||
// For now, mark as boundary - full child traversal can be added later
|
||||
steps[steps.length - 1].transformation = `From child component output: ${conn.fromPort}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recursively trace further upstream
|
||||
const upstreamPath = traceUpstream(project, component, sourceNode, undefined, visited, depth + 1);
|
||||
steps.push(...upstreamPath.steps);
|
||||
crossings.push(...upstreamPath.crossings);
|
||||
}
|
||||
}
|
||||
|
||||
return { steps, crossings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace downstream to find all destinations of data.
|
||||
* Follows connections forward through the graph and across component boundaries.
|
||||
*/
|
||||
export function traceDownstream(
|
||||
project: ProjectModel,
|
||||
component: ComponentModel,
|
||||
startNode: NodeGraphNode,
|
||||
startPort?: string,
|
||||
visited: Set<string> = new Set(),
|
||||
depth: number = 0
|
||||
): LineagePath[] {
|
||||
const MAX_DEPTH = 50;
|
||||
const paths: LineagePath[] = [];
|
||||
|
||||
if (depth >= MAX_DEPTH) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
const nodeKey = `${component.fullName}:${startNode.id}:${startPort || '*'}`;
|
||||
if (visited.has(nodeKey)) {
|
||||
return paths;
|
||||
}
|
||||
visited.add(nodeKey);
|
||||
|
||||
// Determine which outputs to trace
|
||||
const portsToTrace = startPort ? [startPort] : getOutputPorts(startNode);
|
||||
|
||||
for (const portName of portsToTrace) {
|
||||
const connections = getPortConnections(component, startNode.id, portName, 'output');
|
||||
|
||||
if (connections.length === 0) {
|
||||
// No connections - check if this is a component boundary
|
||||
if (startNode.typename === 'Component Outputs') {
|
||||
// Cross out to parent component
|
||||
const external = resolveComponentBoundary(project, component, startNode.id, portName);
|
||||
|
||||
if (external.length > 0) {
|
||||
const ext = external[0];
|
||||
// Create path showing we crossed the boundary
|
||||
paths.push({
|
||||
steps: [
|
||||
{
|
||||
node: startNode,
|
||||
component,
|
||||
port: portName,
|
||||
portType: 'output',
|
||||
transformation: `Exported to parent as: ${portName}`
|
||||
}
|
||||
],
|
||||
crossings: [
|
||||
{
|
||||
from: component,
|
||||
to: component, // Would need to resolve parent component here
|
||||
viaPort: portName,
|
||||
direction: 'outof',
|
||||
stepIndex: 0
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// True sink - no further downstream
|
||||
paths.push({
|
||||
steps: [
|
||||
{
|
||||
node: startNode,
|
||||
component,
|
||||
port: portName,
|
||||
portType: 'output',
|
||||
isSink: true
|
||||
}
|
||||
],
|
||||
crossings: []
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Follow each connection downstream (can branch to multiple destinations)
|
||||
for (const conn of connections) {
|
||||
const destNode = component.graph.nodeMap.get(conn.toNodeId);
|
||||
if (!destNode) continue;
|
||||
|
||||
const pathSteps: LineageStep[] = [
|
||||
{
|
||||
node: destNode,
|
||||
component,
|
||||
port: conn.toPort,
|
||||
portType: 'input',
|
||||
transformation: describeTransformation(destNode, conn.toPort),
|
||||
connection: conn
|
||||
}
|
||||
];
|
||||
|
||||
// Check if destination is a sink
|
||||
if (isSinkNode(destNode)) {
|
||||
pathSteps[0].isSink = true;
|
||||
paths.push({ steps: pathSteps, crossings: [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for component boundary
|
||||
if (destNode.typename === 'Component Inputs') {
|
||||
// This goes into a child component
|
||||
pathSteps[0].transformation = `Into child component input: ${conn.toPort}`;
|
||||
paths.push({ steps: pathSteps, crossings: [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recursively trace further downstream
|
||||
const downstreamPaths = traceDownstream(project, component, destNode, undefined, visited, depth + 1);
|
||||
|
||||
if (downstreamPaths.length === 0) {
|
||||
// Dead end
|
||||
paths.push({ steps: pathSteps, crossings: [] });
|
||||
} else {
|
||||
// Merge this step with downstream paths
|
||||
for (const downPath of downstreamPaths) {
|
||||
paths.push({
|
||||
steps: [...pathSteps, ...downPath.steps],
|
||||
crossings: downPath.crossings,
|
||||
branches: downstreamPaths.length > 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe what transformation a node performs (if any)
|
||||
*/
|
||||
function describeTransformation(node: NodeGraphNode, port: string): string | undefined {
|
||||
switch (node.typename) {
|
||||
case 'Expression':
|
||||
return `Expression: ${node.parameters.expression || '...'}`;
|
||||
|
||||
case 'String Format':
|
||||
return `Format: ${node.parameters.format || '...'}`;
|
||||
|
||||
case 'Object':
|
||||
// Check if accessing a property
|
||||
if (port && port !== 'object') {
|
||||
return `.${port} property`;
|
||||
}
|
||||
return 'Object value';
|
||||
|
||||
case 'Array':
|
||||
if (port === 'length') return 'Array length';
|
||||
if (port.startsWith('item-')) return `Array item [${port.substring(5)}]`;
|
||||
return undefined;
|
||||
|
||||
case 'Variable':
|
||||
return `Variable: ${node.parameters.name || node.label || 'unnamed'}`;
|
||||
|
||||
case 'Function':
|
||||
return `Function: ${node.parameters.name || 'unnamed'}`;
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a data source (no further upstream to trace)
|
||||
*/
|
||||
function isSourceNode(node: NodeGraphNode): boolean {
|
||||
const sourceTypes = [
|
||||
'REST',
|
||||
'Cloud Function',
|
||||
'Function',
|
||||
'String',
|
||||
'Number',
|
||||
'Boolean',
|
||||
'Color',
|
||||
'Page Inputs',
|
||||
'Receive Event'
|
||||
];
|
||||
|
||||
return sourceTypes.includes(node.typename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a data sink (no further downstream to trace)
|
||||
*/
|
||||
function isSinkNode(node: NodeGraphNode): boolean {
|
||||
const sinkTypes = [
|
||||
'REST', // Also a sink when sending
|
||||
'Cloud Function',
|
||||
'Function',
|
||||
'Send Event',
|
||||
'Navigate',
|
||||
'Set Variable',
|
||||
'Page Router'
|
||||
];
|
||||
|
||||
return sinkTypes.includes(node.typename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ports to skip when tracing lineage (signals, metadata, internal state)
|
||||
*/
|
||||
const SKIP_PORTS = new Set([
|
||||
// Signal ports (events, not data)
|
||||
'changed',
|
||||
'fetched',
|
||||
'onSave',
|
||||
'onLoad',
|
||||
'onClick',
|
||||
'onHover',
|
||||
'onPress',
|
||||
'onFocus',
|
||||
'onBlur',
|
||||
|
||||
// Metadata ports (not actual data flow)
|
||||
'name',
|
||||
'id',
|
||||
'savedValue',
|
||||
'isLoading',
|
||||
'error',
|
||||
|
||||
// Internal state
|
||||
'__state',
|
||||
'__internal'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a port should be included in lineage tracing
|
||||
*/
|
||||
function shouldTracePort(port: { name: string; type?: string }): boolean {
|
||||
// Skip signal ports
|
||||
if (port.type === 'signal') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip known metadata/event ports
|
||||
if (SKIP_PORTS.has(port.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip ports starting with underscore (internal)
|
||||
if (port.name.startsWith('_')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all input port names for a node (filtered for data ports only)
|
||||
*/
|
||||
function getInputPorts(node: NodeGraphNode): string[] {
|
||||
const ports = node.getPorts('input');
|
||||
return ports.filter(shouldTracePort).map((p) => p.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all output port names for a node (filtered for data ports only)
|
||||
*/
|
||||
function getOutputPorts(node: NodeGraphNode): string[] {
|
||||
// Check if this node type has primary data ports defined
|
||||
const primaryPorts = PRIMARY_DATA_PORTS[node.typename];
|
||||
if (primaryPorts !== undefined) {
|
||||
return primaryPorts; // Only trace primary ports for this type
|
||||
}
|
||||
|
||||
// Default: trace all filtered ports
|
||||
const ports = node.getPorts('output');
|
||||
return ports.filter(shouldTracePort).map((p) => p.name);
|
||||
}
|
||||
@@ -2485,7 +2485,59 @@ export class NodeGraphEditor extends View {
|
||||
PopupLayer.instance.hidePopup();
|
||||
evt.consumed = true;
|
||||
|
||||
// Check if we're right-clicking on a node (selected or not)
|
||||
let nodeUnderCursor: NodeGraphEditorNode = null;
|
||||
|
||||
// First check if clicking on already selected nodes
|
||||
if (this.isPointInsideNodes(scaledPos, this.selector.nodes)) {
|
||||
nodeUnderCursor = this.selector.nodes.find((node) => {
|
||||
const nodeRect = {
|
||||
x: node.global.x,
|
||||
y: node.global.y,
|
||||
width: node.nodeSize.width,
|
||||
height: node.nodeSize.height
|
||||
};
|
||||
return (
|
||||
scaledPos.x >= nodeRect.x &&
|
||||
scaledPos.x <= nodeRect.x + nodeRect.width &&
|
||||
scaledPos.y >= nodeRect.y &&
|
||||
scaledPos.y <= nodeRect.y + nodeRect.height
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// If not on a selected node, check all nodes
|
||||
if (!nodeUnderCursor) {
|
||||
this.forEachNode((node) => {
|
||||
const nodeRect = {
|
||||
x: node.global.x,
|
||||
y: node.global.y,
|
||||
width: node.nodeSize.width,
|
||||
height: node.nodeSize.height
|
||||
};
|
||||
if (
|
||||
scaledPos.x >= nodeRect.x &&
|
||||
scaledPos.x <= nodeRect.x + nodeRect.width &&
|
||||
scaledPos.y >= nodeRect.y &&
|
||||
scaledPos.y <= nodeRect.y + nodeRect.height
|
||||
) {
|
||||
nodeUnderCursor = node;
|
||||
return true; // Stop iteration
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (nodeUnderCursor) {
|
||||
// Select the node if it isn't already selected
|
||||
if (!this.selector.isActive(nodeUnderCursor)) {
|
||||
this.clearSelection();
|
||||
this.commentLayer?.clearSelection();
|
||||
nodeUnderCursor.selected = true;
|
||||
this.selector.select([nodeUnderCursor]);
|
||||
this.repaint();
|
||||
}
|
||||
|
||||
// Show context menu
|
||||
this.openRightClickMenu();
|
||||
} else if (
|
||||
CreateNewNodePanel.shouldShow({
|
||||
@@ -2508,14 +2560,8 @@ export class NodeGraphEditor extends View {
|
||||
isBackgroundDimmed: true,
|
||||
onClose: () => this.createNewNodePanel.dispose()
|
||||
});
|
||||
} else {
|
||||
PopupLayer.instance.showTooltip({
|
||||
x: evt.pageX,
|
||||
y: evt.pageY,
|
||||
position: 'bottom',
|
||||
content: 'This node type cannot have children.'
|
||||
});
|
||||
}
|
||||
// If clicking empty space with no valid actions, do nothing (no broken tooltip)
|
||||
}
|
||||
this.rightClickPos = undefined;
|
||||
}
|
||||
@@ -2577,6 +2623,27 @@ export class NodeGraphEditor extends View {
|
||||
|
||||
items.push('divider');
|
||||
|
||||
// Data Lineage - DISABLED: Not production ready, requires more work
|
||||
// TODO: Re-enable when lineage filtering and event handling are fixed
|
||||
// items.push({
|
||||
// label: 'Show Data Lineage',
|
||||
// icon: IconName.Link,
|
||||
// onClick: () => {
|
||||
// const selectedNode = this.selector.nodes[0];
|
||||
// if (selectedNode) {
|
||||
// EventDispatcher.instance.emit('DataLineage.ShowForNode', {
|
||||
// nodeId: selectedNode.model.id,
|
||||
// componentName: this.activeComponent?.fullName
|
||||
// });
|
||||
// SidebarModel.instance.switch('data-lineage');
|
||||
// }
|
||||
// },
|
||||
// isDisabled: selectedNodes.length !== 1,
|
||||
// tooltip: selectedNodes.length !== 1 ? 'Select a single node to trace its data lineage' : undefined,
|
||||
// tooltipShowAfterMs: 300
|
||||
// });
|
||||
// items.push('divider');
|
||||
|
||||
if (
|
||||
selectedNodes.length === 1 &&
|
||||
CreateNewNodePanel.shouldShow({
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// Data Lineage Panel Styles
|
||||
|
||||
.DataLineagePanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: var(--theme-spacing-2);
|
||||
overflow-y: auto;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
}
|
||||
|
||||
.EmptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
|
||||
&-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--theme-spacing-3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--theme-spacing-2);
|
||||
}
|
||||
|
||||
&-description {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: var(--theme-spacing-2);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
margin-bottom: var(--theme-spacing-3);
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theme-spacing-2);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-2);
|
||||
}
|
||||
}
|
||||
|
||||
.Button {
|
||||
padding: var(--theme-spacing-1) var(--theme-spacing-3);
|
||||
background-color: var(--theme-color-primary);
|
||||
color: var(--theme-color-bg-1);
|
||||
border: none;
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.SelectedNode {
|
||||
padding: var(--theme-spacing-2);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
margin-bottom: var(--theme-spacing-3);
|
||||
|
||||
&-label {
|
||||
font-size: 15px;
|
||||
margin-bottom: var(--theme-spacing-1);
|
||||
}
|
||||
|
||||
&-meta {
|
||||
font-size: 13px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
margin-bottom: var(--theme-spacing-1);
|
||||
}
|
||||
|
||||
&-component {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.Section {
|
||||
margin-bottom: var(--theme-spacing-3);
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theme-spacing-2);
|
||||
padding: var(--theme-spacing-2);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
&-toggle {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theme-spacing-1);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&-content {
|
||||
margin-top: var(--theme-spacing-2);
|
||||
padding-left: var(--theme-spacing-2);
|
||||
}
|
||||
}
|
||||
|
||||
.EmptyPath {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--theme-spacing-4) var(--theme-spacing-2);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
|
||||
&-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: var(--theme-spacing-2);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: 14px;
|
||||
margin-bottom: var(--theme-spacing-1);
|
||||
}
|
||||
|
||||
&-hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.DownstreamPath {
|
||||
margin-bottom: var(--theme-spacing-2);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.PathBranch {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-primary);
|
||||
margin-bottom: var(--theme-spacing-1);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Data Lineage Panel
|
||||
*
|
||||
* Shows the complete upstream (source) and downstream (destination) paths for data flow
|
||||
* through the node graph, including cross-component traversal.
|
||||
*/
|
||||
|
||||
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import { HighlightManager } from '../../../services/HighlightManager';
|
||||
import type { LineageResult } from '../../../utils/graphAnalysis';
|
||||
import { LineagePath } from './components/LineagePath';
|
||||
import { PathSummary } from './components/PathSummary';
|
||||
import css from './DataLineagePanel.module.scss';
|
||||
import { useDataLineage } from './hooks/useDataLineage';
|
||||
|
||||
export interface DataLineagePanelProps {
|
||||
/** Optional: Pre-selected node to trace */
|
||||
selectedNodeId?: string;
|
||||
|
||||
/** Optional: Specific port to trace */
|
||||
selectedPort?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataLineagePanel component
|
||||
*
|
||||
* Displays data lineage information for a selected node, showing where
|
||||
* data comes from (upstream) and where it goes to (downstream).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DataLineagePanel selectedNodeId="node-123" />
|
||||
* ```
|
||||
*/
|
||||
export function DataLineagePanel({ selectedNodeId, selectedPort }: DataLineagePanelProps) {
|
||||
const [activeHighlightHandle, setActiveHighlightHandle] = useState<{ dismiss: () => void } | null>(null);
|
||||
const [lineageData, setLineageData] = useState<LineageResult | null>(null);
|
||||
const [isUpstreamExpanded, setIsUpstreamExpanded] = useState(true);
|
||||
const [isDownstreamExpanded, setIsDownstreamExpanded] = useState(true);
|
||||
|
||||
// Get lineage data using custom hook
|
||||
const lineage = useDataLineage(selectedNodeId, selectedPort);
|
||||
|
||||
// Update lineage data when it changes
|
||||
useEffect(() => {
|
||||
setLineageData(lineage);
|
||||
}, [lineage]);
|
||||
|
||||
// Cleanup highlights on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (activeHighlightHandle) {
|
||||
activeHighlightHandle.dismiss();
|
||||
}
|
||||
};
|
||||
}, [activeHighlightHandle]);
|
||||
|
||||
/**
|
||||
* Highlight the full lineage path on the canvas
|
||||
*/
|
||||
const handleHighlightPath = () => {
|
||||
if (!lineageData) return;
|
||||
|
||||
// Dismiss any existing highlight
|
||||
if (activeHighlightHandle) {
|
||||
activeHighlightHandle.dismiss();
|
||||
}
|
||||
|
||||
// Collect all nodes in the lineage
|
||||
const nodeIds = new Set<string>();
|
||||
const connections: Array<{
|
||||
fromNodeId: string;
|
||||
fromPort: string;
|
||||
toNodeId: string;
|
||||
toPort: string;
|
||||
}> = [];
|
||||
|
||||
// Add upstream nodes
|
||||
lineageData.upstream.steps.forEach((step) => {
|
||||
nodeIds.add(step.node.id);
|
||||
if (step.connection) {
|
||||
connections.push(step.connection);
|
||||
}
|
||||
});
|
||||
|
||||
// Add downstream nodes
|
||||
lineageData.downstream.forEach((path) => {
|
||||
path.steps.forEach((step) => {
|
||||
nodeIds.add(step.node.id);
|
||||
if (step.connection) {
|
||||
connections.push(step.connection);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add the selected node itself
|
||||
nodeIds.add(lineageData.selectedNode.id);
|
||||
|
||||
// Create highlight
|
||||
const handle = HighlightManager.instance.highlightPath(
|
||||
{
|
||||
nodes: Array.from(nodeIds),
|
||||
connections,
|
||||
crossesComponents: lineageData.upstream.crossings.length > 0
|
||||
},
|
||||
{
|
||||
channel: 'lineage',
|
||||
style: 'glow',
|
||||
persistent: true,
|
||||
label: `Lineage: ${lineageData.selectedNode.label}`
|
||||
}
|
||||
);
|
||||
|
||||
setActiveHighlightHandle(handle);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dismiss the lineage highlight
|
||||
*/
|
||||
const handleDismiss = () => {
|
||||
if (activeHighlightHandle) {
|
||||
activeHighlightHandle.dismiss();
|
||||
setActiveHighlightHandle(null);
|
||||
}
|
||||
setLineageData(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to a specific node on the canvas
|
||||
*/
|
||||
const handleNavigateToNode = (componentName: string, _nodeId: string) => {
|
||||
// Switch to the component if needed
|
||||
const component = ProjectModel.instance.getComponentWithName(componentName);
|
||||
if (component && NodeGraphContextTmp.switchToComponent) {
|
||||
NodeGraphContextTmp.switchToComponent(component, { pushHistory: true });
|
||||
}
|
||||
|
||||
// The canvas will auto-center on the node due to selection
|
||||
// TODO: Add explicit pan-to-node and selection once that API is available
|
||||
};
|
||||
|
||||
// Empty state
|
||||
if (!lineageData) {
|
||||
return (
|
||||
<div className={css['DataLineagePanel']}>
|
||||
<div className={css['EmptyState']}>
|
||||
<div className={css['EmptyState-icon']}>🔗</div>
|
||||
<div className={css['EmptyState-title']}>No Node Selected</div>
|
||||
<div className={css['EmptyState-description']}>Select a node on the canvas to trace its data lineage</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasUpstream = lineageData.upstream.steps.length > 0;
|
||||
const hasDownstream = lineageData.downstream.length > 0 && lineageData.downstream.some((p) => p.steps.length > 0);
|
||||
|
||||
return (
|
||||
<div className={css['DataLineagePanel']}>
|
||||
{/* Header */}
|
||||
<div className={css['Header']}>
|
||||
<div className={css['Header-title']}>
|
||||
<span className={css['Header-icon']}>🔗</span>
|
||||
<span className={css['Header-label']}>Data Lineage</span>
|
||||
</div>
|
||||
<div className={css['Header-actions']}>
|
||||
{activeHighlightHandle ? (
|
||||
<button className={css['Button']} onClick={handleDismiss} title="Clear highlighting">
|
||||
Clear
|
||||
</button>
|
||||
) : (
|
||||
<button className={css['Button']} onClick={handleHighlightPath} title="Highlight path on canvas">
|
||||
Highlight
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Node Info */}
|
||||
<div className={css['SelectedNode']}>
|
||||
<div className={css['SelectedNode-label']}>
|
||||
<strong>{lineageData.selectedNode.label}</strong>
|
||||
</div>
|
||||
<div className={css['SelectedNode-meta']}>
|
||||
{lineageData.selectedNode.type}
|
||||
{lineageData.selectedNode.port && ` → ${lineageData.selectedNode.port}`}
|
||||
</div>
|
||||
<div className={css['SelectedNode-component']}>{lineageData.selectedNode.componentName}</div>
|
||||
</div>
|
||||
|
||||
{/* Path Summary */}
|
||||
{(hasUpstream || hasDownstream) && (
|
||||
<PathSummary
|
||||
upstream={lineageData.upstream}
|
||||
downstream={lineageData.downstream}
|
||||
selectedNodeLabel={lineageData.selectedNode.label}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upstream Section */}
|
||||
<div className={css['Section']}>
|
||||
<div className={css['Section-header']} onClick={() => setIsUpstreamExpanded(!isUpstreamExpanded)}>
|
||||
<span className={css['Section-toggle']}>{isUpstreamExpanded ? '▼' : '▶'}</span>
|
||||
<span className={css['Section-title']}>
|
||||
<span className={css['Section-icon']}>▲</span> UPSTREAM
|
||||
</span>
|
||||
<span className={css['Section-subtitle']}>Where does this value come from?</span>
|
||||
</div>
|
||||
|
||||
{isUpstreamExpanded && (
|
||||
<div className={css['Section-content']}>
|
||||
{hasUpstream ? (
|
||||
<LineagePath path={lineageData.upstream} direction="upstream" onNavigateToNode={handleNavigateToNode} />
|
||||
) : (
|
||||
<div className={css['EmptyPath']}>
|
||||
<div className={css['EmptyPath-icon']}>⊗</div>
|
||||
<div className={css['EmptyPath-text']}>No upstream connections</div>
|
||||
<div className={css['EmptyPath-hint']}>This is a source node</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Downstream Section */}
|
||||
<div className={css['Section']}>
|
||||
<div className={css['Section-header']} onClick={() => setIsDownstreamExpanded(!isDownstreamExpanded)}>
|
||||
<span className={css['Section-toggle']}>{isDownstreamExpanded ? '▼' : '▶'}</span>
|
||||
<span className={css['Section-title']}>
|
||||
<span className={css['Section-icon']}>▼</span> DOWNSTREAM
|
||||
</span>
|
||||
<span className={css['Section-subtitle']}>Where does this value go?</span>
|
||||
</div>
|
||||
|
||||
{isDownstreamExpanded && (
|
||||
<div className={css['Section-content']}>
|
||||
{hasDownstream ? (
|
||||
<>
|
||||
{lineageData.downstream.map((path, index) => (
|
||||
<div key={index} className={css['DownstreamPath']}>
|
||||
{lineageData.downstream.length > 1 && <div className={css['PathBranch']}>Branch {index + 1}</div>}
|
||||
<LineagePath path={path} direction="downstream" onNavigateToNode={handleNavigateToNode} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className={css['EmptyPath']}>
|
||||
<div className={css['EmptyPath-icon']}>⊗</div>
|
||||
<div className={css['EmptyPath-text']}>No downstream connections</div>
|
||||
<div className={css['EmptyPath-hint']}>This is a sink node</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// LineagePath Component Styles
|
||||
|
||||
.LineagePath {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-1);
|
||||
}
|
||||
|
||||
.Step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-1);
|
||||
|
||||
&-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theme-spacing-2);
|
||||
padding: var(--theme-spacing-2);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
&-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&-port {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&-transformation {
|
||||
padding: var(--theme-spacing-1) var(--theme-spacing-2);
|
||||
margin-left: var(--theme-spacing-4);
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&-badge {
|
||||
display: inline-block;
|
||||
padding: 2px var(--theme-spacing-2);
|
||||
margin-left: var(--theme-spacing-4);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background-color: var(--theme-color-primary);
|
||||
color: var(--theme-color-bg-1);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&-arrow {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.Crossings {
|
||||
margin-top: var(--theme-spacing-2);
|
||||
padding: var(--theme-spacing-2);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
border-left: 3px solid var(--theme-color-primary);
|
||||
|
||||
&-label {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* LineagePath Component
|
||||
* Displays a single lineage path (upstream or downstream)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { LineagePath as LineagePathType } from '../../../../utils/graphAnalysis';
|
||||
import css from './LineagePath.module.scss';
|
||||
|
||||
export interface LineagePathProps {
|
||||
path: LineagePathType;
|
||||
direction: 'upstream' | 'downstream';
|
||||
onNavigateToNode: (componentName: string, nodeId: string) => void;
|
||||
}
|
||||
|
||||
export function LineagePath({ path, direction, onNavigateToNode }: LineagePathProps) {
|
||||
if (!path || path.steps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css['LineagePath']} data-direction={direction}>
|
||||
{path.steps.map((step, index) => (
|
||||
<div key={`${step.node.id}-${index}`} className={css['Step']}>
|
||||
<div
|
||||
className={css['Step-node']}
|
||||
onClick={() => onNavigateToNode(step.component.name, step.node.id)}
|
||||
title={`Click to navigate to ${step.node.label || step.node.typename}`}
|
||||
>
|
||||
<span className={css['Step-icon']}>🔵</span>
|
||||
<span className={css['Step-label']}>{step.node.label || step.node.typename}</span>
|
||||
{step.port && <span className={css['Step-port']}>→ {step.port}</span>}
|
||||
</div>
|
||||
|
||||
{step.transformation && <div className={css['Step-transformation']}>{step.transformation}</div>}
|
||||
|
||||
{step.isSource && <div className={css['Step-badge']}>SOURCE</div>}
|
||||
{step.isSink && <div className={css['Step-badge']}>SINK</div>}
|
||||
|
||||
{index < path.steps.length - 1 && <div className={css['Step-arrow']}>↓</div>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{path.crossings && path.crossings.length > 0 && (
|
||||
<div className={css['Crossings']}>
|
||||
<div className={css['Crossings-label']}>Component boundaries crossed: {path.crossings.length}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// PathSummary Component Styles
|
||||
|
||||
.PathSummary {
|
||||
padding: var(--theme-spacing-2);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
margin-bottom: var(--theme-spacing-3);
|
||||
border-left: 3px solid var(--theme-color-primary);
|
||||
|
||||
&-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
margin-bottom: var(--theme-spacing-2);
|
||||
}
|
||||
|
||||
&-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-1);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.Path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theme-spacing-2);
|
||||
flex-wrap: wrap;
|
||||
|
||||
&-segment {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&-arrow {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&-selected {
|
||||
padding: var(--theme-spacing-1) var(--theme-spacing-2);
|
||||
background-color: var(--theme-color-primary);
|
||||
color: var(--theme-color-bg-1);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-branch {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-style: italic;
|
||||
margin-left: var(--theme-spacing-1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* PathSummary Component
|
||||
* Shows a compact summary of the lineage path
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { LineagePath } from '../../../../utils/graphAnalysis';
|
||||
import css from './PathSummary.module.scss';
|
||||
|
||||
export interface PathSummaryProps {
|
||||
upstream: LineagePath;
|
||||
downstream: LineagePath[];
|
||||
selectedNodeLabel: string;
|
||||
}
|
||||
|
||||
export function PathSummary({ upstream, downstream, selectedNodeLabel }: PathSummaryProps) {
|
||||
// Build compact path summary
|
||||
const upstreamSummary = upstream.steps.map((s) => s.node.label || s.node.typename).join(' → ');
|
||||
const downstreamSummaries = downstream.map((path) =>
|
||||
path.steps.map((s) => s.node.label || s.node.typename).join(' → ')
|
||||
);
|
||||
|
||||
const hasUpstream = upstream.steps.length > 0;
|
||||
const hasDownstream = downstream.length > 0 && downstream.some((p) => p.steps.length > 0);
|
||||
|
||||
if (!hasUpstream && !hasDownstream) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css['PathSummary']}>
|
||||
<div className={css['PathSummary-title']}>Path Summary</div>
|
||||
<div className={css['PathSummary-content']}>
|
||||
{hasUpstream && (
|
||||
<div className={css['Path']}>
|
||||
<span className={css['Path-segment']}>{upstreamSummary}</span>
|
||||
<span className={css['Path-arrow']}>→</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={css['Path-selected']}>
|
||||
<strong>{selectedNodeLabel}</strong>
|
||||
</div>
|
||||
|
||||
{hasDownstream && (
|
||||
<>
|
||||
{downstreamSummaries.map((summary, index) => (
|
||||
<div key={index} className={css['Path']}>
|
||||
<span className={css['Path-arrow']}>→</span>
|
||||
<span className={css['Path-segment']}>{summary}</span>
|
||||
{downstream.length > 1 && <span className={css['Path-branch']}>Branch {index + 1}</span>}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Hook for calculating data lineage for a selected node
|
||||
*/
|
||||
|
||||
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import { EventDispatcher } from '../../../../../../shared/utils/EventDispatcher';
|
||||
import { buildLineage, type LineageResult } from '../../../../utils/graphAnalysis';
|
||||
|
||||
/**
|
||||
* Custom hook to calculate and return lineage data for a selected node
|
||||
*
|
||||
* @param selectedNodeId - ID of the node to trace (optional - if not provided, uses canvas selection)
|
||||
* @param selectedPort - Specific port to trace (optional)
|
||||
* @returns Lineage result or null if no node selected
|
||||
*/
|
||||
export function useDataLineage(selectedNodeId?: string, selectedPort?: string): LineageResult | null {
|
||||
const [lineage, setLineage] = useState<LineageResult | null>(null);
|
||||
const [currentSelectedNodeId, setCurrentSelectedNodeId] = useState<string | undefined>(selectedNodeId);
|
||||
|
||||
console.log(
|
||||
'🔗 [DataLineage] Hook render - currentSelectedNodeId:',
|
||||
currentSelectedNodeId,
|
||||
'selectedNodeId prop:',
|
||||
selectedNodeId
|
||||
);
|
||||
|
||||
// Check current selection on mount (catches selection that happened before panel opened)
|
||||
useEffect(() => {
|
||||
console.log('🔗 [DataLineage] Mount check useEffect running');
|
||||
// Only if we're not using a fixed selectedNodeId prop
|
||||
if (!selectedNodeId) {
|
||||
console.log('🔗 [DataLineage] No prop provided, checking graph selection...');
|
||||
console.log('🔗 [DataLineage] NodeGraphContextTmp.nodeGraph:', NodeGraphContextTmp.nodeGraph);
|
||||
const selection = NodeGraphContextTmp.nodeGraph?.getSelectedNodes?.();
|
||||
console.log('🔗 [DataLineage] getSelectedNodes() returned:', selection);
|
||||
if (selection && selection.length === 1) {
|
||||
console.log('🔗 [DataLineage] ✅ Setting currentSelectedNodeId to:', selection[0].id);
|
||||
setCurrentSelectedNodeId(selection[0].id);
|
||||
} else {
|
||||
console.log('🔗 [DataLineage] ❌ No valid single selection on mount');
|
||||
}
|
||||
} else {
|
||||
console.log('🔗 [DataLineage] Using prop selectedNodeId:', selectedNodeId);
|
||||
}
|
||||
}, []); // Empty deps = run once on mount
|
||||
|
||||
// Listen to context menu "Show Data Lineage" requests
|
||||
useEventListener(
|
||||
EventDispatcher.instance,
|
||||
'DataLineage.ShowForNode',
|
||||
(data: { nodeId: string; componentName?: string }) => {
|
||||
console.log('📍 [DataLineage] Context menu event - Show lineage for node:', data.nodeId);
|
||||
setCurrentSelectedNodeId(data.nodeId);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Listen to selection changes on the canvas
|
||||
useEventListener(
|
||||
NodeGraphContextTmp.nodeGraph,
|
||||
'selectionChanged',
|
||||
(selection: { nodeIds: string[] }) => {
|
||||
console.log('🔗 [DataLineage] selectionChanged event fired:', selection);
|
||||
// Only update if we're not using a fixed selectedNodeId prop
|
||||
if (!selectedNodeId) {
|
||||
if (selection.nodeIds.length === 1) {
|
||||
console.log('🔗 [DataLineage] ✅ Event: Setting selection to:', selection.nodeIds[0]);
|
||||
setCurrentSelectedNodeId(selection.nodeIds[0]);
|
||||
} else {
|
||||
console.log('🔗 [DataLineage] ❌ Event: Clearing selection (count:', selection.nodeIds.length, ')');
|
||||
setCurrentSelectedNodeId(undefined);
|
||||
}
|
||||
} else {
|
||||
console.log('🔗 [DataLineage] Event: Ignoring (using prop)');
|
||||
}
|
||||
},
|
||||
[selectedNodeId]
|
||||
);
|
||||
|
||||
// Recalculate lineage when selection or component changes
|
||||
useEffect(() => {
|
||||
console.log('🔗 [DataLineage] Lineage calc useEffect running');
|
||||
const component = NodeGraphContextTmp.nodeGraph?.activeComponent;
|
||||
console.log('🔗 [DataLineage] Active component:', component?.name);
|
||||
|
||||
if (!component) {
|
||||
console.log('🔗 [DataLineage] ❌ No active component');
|
||||
setLineage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use either the prop or the current selection
|
||||
const nodeId = selectedNodeId || currentSelectedNodeId;
|
||||
console.log('🔗 [DataLineage] Node ID to trace:', nodeId);
|
||||
|
||||
if (!nodeId) {
|
||||
console.log('🔗 [DataLineage] ❌ No node ID to trace');
|
||||
setLineage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate lineage
|
||||
try {
|
||||
console.log('🔗 [DataLineage] 🚀 Calling buildLineage...');
|
||||
const result = buildLineage(ProjectModel.instance, component, nodeId, selectedPort);
|
||||
console.log('🔗 [DataLineage] ✅ Lineage built successfully:', result);
|
||||
setLineage(result);
|
||||
} catch (error) {
|
||||
console.error('🔗 [DataLineage] ❌ Error building lineage:', error);
|
||||
setLineage(null);
|
||||
}
|
||||
}, [selectedNodeId, currentSelectedNodeId, selectedPort]);
|
||||
|
||||
return lineage;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Data Lineage Panel
|
||||
*
|
||||
* Exports for the Data Lineage visualization panel
|
||||
*/
|
||||
|
||||
export { DataLineagePanel } from './DataLineagePanel';
|
||||
export type { DataLineagePanelProps } from './DataLineagePanel';
|
||||
@@ -24,6 +24,29 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.TopologyMapPanel__backButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
color: var(--theme-color-fg-default);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-color-bg-4);
|
||||
border-color: var(--theme-color-primary);
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--theme-color-bg-5);
|
||||
}
|
||||
}
|
||||
|
||||
.TopologyMapPanel__icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -57,6 +80,25 @@
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.TopologyMapPanel__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.TopologyMapPanel__orphanCount {
|
||||
color: var(--theme-color-warning) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Legend
|
||||
.TopologyMapPanel__legend {
|
||||
display: flex;
|
||||
|
||||
@@ -5,138 +5,111 @@
|
||||
* 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 React, { useState, useCallback } 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 { useFolderGraph } from './hooks/useFolderGraph';
|
||||
import { useFolderLayout } from './hooks/useFolderLayout';
|
||||
import css from './TopologyMapPanel.module.scss';
|
||||
import { TopologyNode } from './utils/topologyTypes';
|
||||
import { FolderNode, TopologyViewState } from './utils/topologyTypes';
|
||||
|
||||
export function TopologyMapPanel() {
|
||||
const [hoveredNode, setHoveredNode] = useState<TopologyNode | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [selectedFolder, setSelectedFolder] = useState<FolderNode | null>(null);
|
||||
const [isLegendOpen, setIsLegendOpen] = useState(false);
|
||||
const [viewState, setViewState] = useState<TopologyViewState>({
|
||||
mode: 'overview',
|
||||
expandedFolderId: null,
|
||||
selectedComponentId: null
|
||||
});
|
||||
|
||||
// Build the graph data
|
||||
const graph = useTopologyGraph();
|
||||
// Build the folder graph
|
||||
const folderGraph = useFolderGraph();
|
||||
|
||||
// Apply layout algorithm
|
||||
const positionedGraph = useTopologyLayout(graph);
|
||||
// Apply tiered layout
|
||||
const positionedGraph = useFolderLayout(folderGraph);
|
||||
|
||||
// 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 folder click - select for details (Phase 4)
|
||||
const handleFolderClick = useCallback((folder: FolderNode) => {
|
||||
console.log('[TopologyMapPanel] Selected folder:', folder.name);
|
||||
setSelectedFolder(folder);
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Handle folder double-click - drill down (Phase 3)
|
||||
const handleFolderDoubleClick = useCallback((folder: FolderNode) => {
|
||||
console.log('[TopologyMapPanel] Drilling into folder:', folder.name);
|
||||
setViewState({
|
||||
mode: 'expanded',
|
||||
expandedFolderId: folder.id,
|
||||
selectedComponentId: 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);
|
||||
// Handle back to overview
|
||||
const handleBackToOverview = useCallback(() => {
|
||||
console.log('[TopologyMapPanel] Returning to overview');
|
||||
setViewState({
|
||||
mode: 'overview',
|
||||
expandedFolderId: null,
|
||||
selectedComponentId: null
|
||||
});
|
||||
setSelectedFolder(null);
|
||||
}, []);
|
||||
|
||||
// Get expanded folder details
|
||||
const expandedFolder =
|
||||
viewState.mode === 'expanded' ? positionedGraph.folders.find((f) => f.id === viewState.expandedFolderId) : null;
|
||||
|
||||
return (
|
||||
<div className={css['TopologyMapPanel']}>
|
||||
{/* Header with breadcrumbs */}
|
||||
{/* Header */}
|
||||
<div className={css['TopologyMapPanel__header']}>
|
||||
<div className={css['TopologyMapPanel__title']}>
|
||||
{viewState.mode === 'expanded' && (
|
||||
<button
|
||||
className={css['TopologyMapPanel__backButton']}
|
||||
onClick={handleBackToOverview}
|
||||
title="Back to overview"
|
||||
>
|
||||
<Icon icon={IconName.ArrowLeft} />
|
||||
</button>
|
||||
)}
|
||||
<Icon icon={IconName.Navigate} />
|
||||
<h2 className={css['TopologyMapPanel__titleText']}>Project Topology</h2>
|
||||
<h2 className={css['TopologyMapPanel__titleText']}>
|
||||
{viewState.mode === 'overview' ? 'Project Topology' : expandedFolder?.name || 'Folder Contents'}
|
||||
</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}
|
||||
{/* Stats display */}
|
||||
<div className={css['TopologyMapPanel__stats']}>
|
||||
{viewState.mode === 'overview' ? (
|
||||
<>
|
||||
<span>
|
||||
{positionedGraph.totalFolders} folders • {positionedGraph.totalComponents} components
|
||||
</span>
|
||||
{positionedGraph.orphanComponents.length > 0 && (
|
||||
<span className={css['TopologyMapPanel__orphanCount']}>
|
||||
{positionedGraph.orphanComponents.length} orphans
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>{expandedFolder?.componentCount || 0} components in this folder</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main visualization with legend inside */}
|
||||
{/* Main visualization */}
|
||||
<TopologyMapView
|
||||
graph={positionedGraph}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeHover={handleNodeHover}
|
||||
viewState={viewState}
|
||||
selectedFolderId={selectedFolder?.id || null}
|
||||
onFolderClick={handleFolderClick}
|
||||
onFolderDoubleClick={handleFolderDoubleClick}
|
||||
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,205 @@
|
||||
/**
|
||||
* ComponentNode Styles
|
||||
*
|
||||
* Card-style component nodes with colors inherited from parent folder type.
|
||||
*/
|
||||
|
||||
.ComponentNode {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
.ComponentNode__border {
|
||||
opacity: 1;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.ComponentNode__background {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ComponentNode__background {
|
||||
fill: var(--theme-color-bg-4);
|
||||
opacity: 0.9;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ComponentNode__border {
|
||||
fill: none;
|
||||
stroke: var(--theme-color-border-default);
|
||||
stroke-width: 2;
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ComponentNode__header {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ComponentNode__iconBackground {
|
||||
fill: var(--theme-color-bg-5);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ComponentNode__iconText {
|
||||
fill: var(--theme-color-fg-default);
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ComponentNode__name {
|
||||
fill: var(--theme-color-fg-default);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ComponentNode__stats {
|
||||
fill: var(--theme-color-fg-default-shy);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ComponentNode__nodeList {
|
||||
fill: var(--theme-color-fg-default);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Folder Type Color Inheritance (matching parent folder colors)
|
||||
// ============================================================================
|
||||
|
||||
// Page folder components (blue)
|
||||
.ComponentNode--page {
|
||||
.ComponentNode__background {
|
||||
fill: #1e3a8a;
|
||||
}
|
||||
.ComponentNode__border {
|
||||
stroke: #3b82f6;
|
||||
}
|
||||
.ComponentNode__iconBackground {
|
||||
fill: #3b82f6;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Feature folder components (purple)
|
||||
.ComponentNode--feature {
|
||||
.ComponentNode__background {
|
||||
fill: #581c87;
|
||||
}
|
||||
.ComponentNode__border {
|
||||
stroke: #a855f7;
|
||||
}
|
||||
.ComponentNode__iconBackground {
|
||||
fill: #a855f7;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Integration folder components (green)
|
||||
.ComponentNode--integration {
|
||||
.ComponentNode__background {
|
||||
fill: #064e3b;
|
||||
}
|
||||
.ComponentNode__border {
|
||||
stroke: #10b981;
|
||||
}
|
||||
.ComponentNode__iconBackground {
|
||||
fill: #10b981;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// UI folder components (cyan)
|
||||
.ComponentNode--ui {
|
||||
.ComponentNode__background {
|
||||
fill: #164e63;
|
||||
}
|
||||
.ComponentNode__border {
|
||||
stroke: #06b6d4;
|
||||
}
|
||||
.ComponentNode__iconBackground {
|
||||
fill: #06b6d4;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility folder components (gray)
|
||||
.ComponentNode--utility {
|
||||
.ComponentNode__background {
|
||||
fill: #374151;
|
||||
}
|
||||
.ComponentNode__border {
|
||||
stroke: #6b7280;
|
||||
}
|
||||
.ComponentNode__iconBackground {
|
||||
fill: #6b7280;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Orphan folder components (yellow)
|
||||
.ComponentNode--orphan {
|
||||
.ComponentNode__background {
|
||||
fill: #422006;
|
||||
}
|
||||
.ComponentNode__border {
|
||||
stroke: #ca8a04;
|
||||
}
|
||||
.ComponentNode__iconBackground {
|
||||
fill: #ca8a04;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// App Component Special Styling (Gold/Amber border)
|
||||
// ============================================================================
|
||||
|
||||
.ComponentNode--app {
|
||||
.ComponentNode__border {
|
||||
stroke: #fbbf24; // Amber/gold color
|
||||
stroke-width: 3;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover .ComponentNode__border {
|
||||
stroke: #f59e0b; // Brighter amber on hover
|
||||
filter: drop-shadow(0 0 8px #fbbf24);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Selection State
|
||||
// ============================================================================
|
||||
|
||||
.ComponentNode--selected {
|
||||
.ComponentNode__border {
|
||||
stroke-width: 3;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.ComponentNode--page .ComponentNode__border {
|
||||
filter: drop-shadow(0 0 8px #3b82f6);
|
||||
}
|
||||
&.ComponentNode--feature .ComponentNode__border {
|
||||
filter: drop-shadow(0 0 8px #a855f7);
|
||||
}
|
||||
&.ComponentNode--integration .ComponentNode__border {
|
||||
filter: drop-shadow(0 0 8px #10b981);
|
||||
}
|
||||
&.ComponentNode--ui .ComponentNode__border {
|
||||
filter: drop-shadow(0 0 8px #06b6d4);
|
||||
}
|
||||
&.ComponentNode--utility .ComponentNode__border {
|
||||
filter: drop-shadow(0 0 8px #6b7280);
|
||||
}
|
||||
&.ComponentNode--orphan .ComponentNode__border {
|
||||
filter: drop-shadow(0 0 8px #ca8a04);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* ComponentNode Component
|
||||
*
|
||||
* Renders a single component node in the expanded folder view.
|
||||
* Styled as a card with color inherited from parent folder type.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
|
||||
import { Icon, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import { formatStatsShort, getComponentQuickStats } from '../utils/componentStats';
|
||||
import { getComponentIcon } from '../utils/folderColors';
|
||||
import { FolderType } from '../utils/topologyTypes';
|
||||
import css from './ComponentNode.module.scss';
|
||||
|
||||
export interface ComponentNodeProps {
|
||||
component: ComponentModel;
|
||||
folderType: FolderType;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
onClick?: (component: ComponentModel) => void;
|
||||
isSelected?: boolean;
|
||||
isAppComponent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits text into lines for multi-line display.
|
||||
* Tries to break at sensible points (/, ., -) when possible.
|
||||
*/
|
||||
function splitTextForDisplay(text: string, maxCharsPerLine: number = 20): string[] {
|
||||
if (text.length <= maxCharsPerLine) return [text];
|
||||
|
||||
const lines: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > maxCharsPerLine) {
|
||||
// Try to find a good break point
|
||||
let breakIndex = maxCharsPerLine;
|
||||
const slashIndex = remaining.lastIndexOf('/', maxCharsPerLine);
|
||||
const dotIndex = remaining.lastIndexOf('.', maxCharsPerLine);
|
||||
const dashIndex = remaining.lastIndexOf('-', maxCharsPerLine);
|
||||
|
||||
// Use the best break point found
|
||||
if (slashIndex > 0 && slashIndex > maxCharsPerLine - 10) {
|
||||
breakIndex = slashIndex + 1; // Include the slash in the current line
|
||||
} else if (dotIndex > 0 && dotIndex > maxCharsPerLine - 10) {
|
||||
breakIndex = dotIndex + 1;
|
||||
} else if (dashIndex > 0 && dashIndex > maxCharsPerLine - 10) {
|
||||
breakIndex = dashIndex + 1;
|
||||
}
|
||||
|
||||
lines.push(remaining.slice(0, breakIndex));
|
||||
remaining = remaining.slice(breakIndex);
|
||||
}
|
||||
|
||||
if (remaining) {
|
||||
lines.push(remaining);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the dynamic height needed for a component card based on text length.
|
||||
*/
|
||||
export function calculateComponentNodeHeight(componentName: string, baseHeight: number = 80): number {
|
||||
const lines = splitTextForDisplay(componentName);
|
||||
const extraLines = Math.max(0, lines.length - 1);
|
||||
const lineHeight = 14;
|
||||
return baseHeight + extraLines * lineHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a card-style component node with inherited folder colors
|
||||
*/
|
||||
export function ComponentNode({
|
||||
component,
|
||||
folderType,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
onClick,
|
||||
isSelected = false,
|
||||
isAppComponent = false
|
||||
}: ComponentNodeProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(component);
|
||||
};
|
||||
|
||||
const typeClass = css[`ComponentNode--${folderType}`] || '';
|
||||
const selectedClass = isSelected ? css['ComponentNode--selected'] : '';
|
||||
const appClass = isAppComponent ? css['ComponentNode--app'] : '';
|
||||
const nameLines = splitTextForDisplay(component.name);
|
||||
|
||||
// Calculate stats
|
||||
const stats = getComponentQuickStats(component);
|
||||
const statsText = formatStatsShort(stats);
|
||||
|
||||
// Get node list
|
||||
const nodeNames: string[] = [];
|
||||
component.graph.forEachNode((node) => {
|
||||
// Get the last part of the node type (e.g., "Group" from "noodl.visual.Group")
|
||||
const typeName = node.typename || node.type?.split('.').pop() || '';
|
||||
if (typeName) {
|
||||
nodeNames.push(typeName);
|
||||
}
|
||||
});
|
||||
const sortedNodeNames = [...new Set(nodeNames)].sort(); // Unique and sorted
|
||||
const displayNodeList = sortedNodeNames.slice(0, 5).join(', ');
|
||||
const remainingCount = sortedNodeNames.length - 5;
|
||||
|
||||
// Calculate layout
|
||||
const iconSize = 16;
|
||||
const padding = 12;
|
||||
const headerHeight = 36;
|
||||
const footerY = y + height - 40;
|
||||
const nodeListY = y + height - 22;
|
||||
const nameStartY = y + headerHeight / 2 + 2;
|
||||
|
||||
return (
|
||||
<g
|
||||
className={`${css['ComponentNode']} ${typeClass} ${selectedClass} ${appClass}`}
|
||||
onClick={handleClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{/* Card background */}
|
||||
<rect className={css['ComponentNode__background']} x={x} y={y} width={width} height={height} rx={8} />
|
||||
|
||||
{/* Card border */}
|
||||
<rect className={css['ComponentNode__border']} x={x} y={y} width={width} height={height} rx={8} />
|
||||
|
||||
{/* Header section with icon */}
|
||||
<g className={css['ComponentNode__header']}>
|
||||
{/* SVG Icon via foreignObject */}
|
||||
<foreignObject x={x + padding} y={y + (headerHeight - iconSize) / 2} width={iconSize} height={iconSize}>
|
||||
<Icon icon={getComponentIcon(component)} size={IconSize.Small} />
|
||||
</foreignObject>
|
||||
</g>
|
||||
|
||||
{/* Component name (multi-line) */}
|
||||
<text
|
||||
className={css['ComponentNode__name']}
|
||||
x={x + padding + iconSize + 8}
|
||||
y={nameStartY}
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
>
|
||||
{nameLines.map((line, index) => (
|
||||
<tspan key={index} x={x + padding + iconSize + 8} dy={index === 0 ? 0 : 14}>
|
||||
{line}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
|
||||
{/* X-Ray stats (footer) */}
|
||||
<text
|
||||
className={css['ComponentNode__stats']}
|
||||
x={x + width / 2}
|
||||
y={footerY}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="10"
|
||||
>
|
||||
{statsText}
|
||||
</text>
|
||||
|
||||
{/* Node list */}
|
||||
{sortedNodeNames.length > 0 && (
|
||||
<text className={css['ComponentNode__nodeList']} x={x + padding} y={nodeListY} fontSize="9">
|
||||
{displayNodeList}
|
||||
{remainingCount > 0 && ` +${remainingCount}`}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* FolderEdge Styles
|
||||
*
|
||||
* Styling for folder-to-folder connections.
|
||||
*/
|
||||
|
||||
.FolderEdge {
|
||||
stroke: #4b5563;
|
||||
fill: none;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* FolderEdge Component
|
||||
*
|
||||
* Renders a connection between two folders with:
|
||||
* - Gradient coloring (source folder color → target folder color)
|
||||
* - Variable thickness based on traffic
|
||||
* - Opacity based on connection strength
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { getFolderColor } from '../utils/folderColors';
|
||||
import { FolderConnection, FolderNode } from '../utils/topologyTypes';
|
||||
import css from './FolderEdge.module.scss';
|
||||
|
||||
interface FolderEdgeProps {
|
||||
connection: FolderConnection;
|
||||
fromFolder: FolderNode;
|
||||
toFolder: FolderNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates stroke width based on connection count.
|
||||
* Range: 1-4px
|
||||
*/
|
||||
function getStrokeWidth(count: number): number {
|
||||
if (count >= 30) return 4;
|
||||
if (count >= 20) return 3;
|
||||
if (count >= 10) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates opacity based on connection count.
|
||||
* Range: 0.3-0.7
|
||||
*/
|
||||
function getOpacity(count: number): number {
|
||||
const baseOpacity = 0.3;
|
||||
const maxOpacity = 0.7;
|
||||
const normalized = Math.min(count / 50, 1); // Normalize to 0-1
|
||||
return baseOpacity + normalized * (maxOpacity - baseOpacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a connection line between two folder nodes with gradient coloring.
|
||||
*/
|
||||
export function FolderEdge({ connection, fromFolder, toFolder }: FolderEdgeProps) {
|
||||
if (
|
||||
!fromFolder.x ||
|
||||
!fromFolder.y ||
|
||||
!fromFolder.width ||
|
||||
!fromFolder.height ||
|
||||
!toFolder.x ||
|
||||
!toFolder.y ||
|
||||
!toFolder.width ||
|
||||
!toFolder.height
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate connection points (center of each node)
|
||||
const x1 = fromFolder.x + fromFolder.width / 2;
|
||||
const y1 = fromFolder.y + fromFolder.height;
|
||||
const x2 = toFolder.x + toFolder.width / 2;
|
||||
const y2 = toFolder.y;
|
||||
|
||||
const strokeWidth = getStrokeWidth(connection.count);
|
||||
const opacity = getOpacity(connection.count);
|
||||
|
||||
// Get colors for gradient (source folder → target folder)
|
||||
const fromColor = getFolderColor(fromFolder.type);
|
||||
const toColor = getFolderColor(toFolder.type);
|
||||
|
||||
// Create unique gradient ID based on folder IDs
|
||||
const gradientId = `folder-edge-${fromFolder.id}-${toFolder.id}`;
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* Define gradient */}
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={fromColor} stopOpacity={opacity} />
|
||||
<stop offset="100%" stopColor={toColor} stopOpacity={opacity} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Render line with gradient */}
|
||||
<line
|
||||
className={css['FolderEdge']}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke={`url(#${gradientId})`}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* FolderNode Styles
|
||||
*
|
||||
* Color-coded styling for folder nodes based on type.
|
||||
*/
|
||||
|
||||
.FolderNode {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
.FolderNode__border {
|
||||
opacity: 1;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.FolderNode__background {
|
||||
filter: brightness(1.1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.FolderNode__background {
|
||||
fill: var(--theme-color-bg-4);
|
||||
opacity: 0.9;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.FolderNode__border {
|
||||
fill: none;
|
||||
stroke: var(--theme-color-border-default);
|
||||
stroke-width: 2;
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.FolderNode__path {
|
||||
fill: var(--theme-color-fg-default);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.FolderNode__nameWrapper {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.FolderNode__componentList {
|
||||
fill: var(--theme-color-fg-default);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.FolderNode__componentListWrapper {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
opacity: 0.8;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.FolderNode__connections {
|
||||
fill: var(--theme-color-fg-default);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.FolderNode__count {
|
||||
fill: var(--theme-color-fg-default-shy);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Folder Type Colors (from spec)
|
||||
// ============================================================================
|
||||
|
||||
// Page folders (blue)
|
||||
.FolderNode--page {
|
||||
.FolderNode__background {
|
||||
fill: #1e3a8a;
|
||||
}
|
||||
.FolderNode__border {
|
||||
stroke: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
// Feature folders (purple)
|
||||
.FolderNode--feature {
|
||||
.FolderNode__background {
|
||||
fill: #581c87;
|
||||
}
|
||||
.FolderNode__border {
|
||||
stroke: #a855f7;
|
||||
}
|
||||
}
|
||||
|
||||
// Integration folders (green)
|
||||
.FolderNode--integration {
|
||||
.FolderNode__background {
|
||||
fill: #064e3b;
|
||||
}
|
||||
.FolderNode__border {
|
||||
stroke: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
// UI folders (cyan)
|
||||
.FolderNode--ui {
|
||||
.FolderNode__background {
|
||||
fill: #164e63;
|
||||
}
|
||||
.FolderNode__border {
|
||||
stroke: #06b6d4;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility folders (gray)
|
||||
.FolderNode--utility {
|
||||
.FolderNode__background {
|
||||
fill: #374151;
|
||||
}
|
||||
.FolderNode__border {
|
||||
stroke: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
// Orphan folders (yellow with dashed border)
|
||||
.FolderNode--orphan {
|
||||
.FolderNode__background {
|
||||
fill: #422006;
|
||||
}
|
||||
.FolderNode__border {
|
||||
stroke: #ca8a04;
|
||||
stroke-dasharray: 4;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Selection State
|
||||
// ============================================================================
|
||||
|
||||
.FolderNode--selected {
|
||||
.FolderNode__border {
|
||||
stroke-width: 3;
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 10px currentColor);
|
||||
}
|
||||
|
||||
&.FolderNode--page .FolderNode__border {
|
||||
filter: drop-shadow(0 0 10px #3b82f6);
|
||||
}
|
||||
&.FolderNode--feature .FolderNode__border {
|
||||
filter: drop-shadow(0 0 10px #a855f7);
|
||||
}
|
||||
&.FolderNode--integration .FolderNode__border {
|
||||
filter: drop-shadow(0 0 10px #10b981);
|
||||
}
|
||||
&.FolderNode--ui .FolderNode__border {
|
||||
filter: drop-shadow(0 0 10px #06b6d4);
|
||||
}
|
||||
&.FolderNode--utility .FolderNode__border {
|
||||
filter: drop-shadow(0 0 10px #6b7280);
|
||||
}
|
||||
&.FolderNode--orphan .FolderNode__border {
|
||||
filter: drop-shadow(0 0 10px #ca8a04);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* FolderNode Component
|
||||
*
|
||||
* Renders a folder node in the topology map with color-coded styling.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Icon, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import { calculateFolderSectionPositions } from '../utils/folderCardHeight';
|
||||
import { getFolderIcon } from '../utils/folderColors';
|
||||
import { FolderNode as FolderNodeType } from '../utils/topologyTypes';
|
||||
import css from './FolderNode.module.scss';
|
||||
|
||||
interface FolderNodeProps {
|
||||
folder: FolderNodeType;
|
||||
isSelected?: boolean;
|
||||
onClick?: (folder: FolderNodeType) => void;
|
||||
onDoubleClick?: (folder: FolderNodeType) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a folder node with appropriate styling based on folder type.
|
||||
*/
|
||||
export function FolderNode({ folder, isSelected = false, onClick, onDoubleClick }: FolderNodeProps) {
|
||||
if (!folder.x || !folder.y || !folder.width || !folder.height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(folder);
|
||||
};
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDoubleClick?.(folder);
|
||||
};
|
||||
|
||||
const typeClassName = css[`FolderNode--${folder.type}`] || '';
|
||||
const selectedClassName = isSelected ? css['FolderNode--selected'] : '';
|
||||
|
||||
// Calculate dynamic positions for each section
|
||||
const positions = calculateFolderSectionPositions(folder);
|
||||
|
||||
return (
|
||||
<g
|
||||
className={`${css['FolderNode']} ${typeClassName} ${selectedClassName}`}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{/* Background rectangle */}
|
||||
<rect
|
||||
className={css['FolderNode__background']}
|
||||
x={folder.x}
|
||||
y={folder.y}
|
||||
width={folder.width}
|
||||
height={folder.height}
|
||||
rx={8}
|
||||
/>
|
||||
|
||||
{/* Border rectangle */}
|
||||
<rect
|
||||
className={css['FolderNode__border']}
|
||||
x={folder.x}
|
||||
y={folder.y}
|
||||
width={folder.width}
|
||||
height={folder.height}
|
||||
rx={8}
|
||||
/>
|
||||
|
||||
{/* Icon (SVG embedded via foreignObject) */}
|
||||
<foreignObject x={folder.x + 12} y={positions.iconY} width={20} height={20}>
|
||||
<Icon icon={getFolderIcon(folder.type)} size={IconSize.Default} />
|
||||
</foreignObject>
|
||||
|
||||
{/* Folder name - wrapped in foreignObject for proper text wrapping */}
|
||||
<foreignObject x={folder.x + 38} y={positions.titleY} width={folder.width - 50} height={positions.titleHeight}>
|
||||
<div className={css['FolderNode__nameWrapper']}>{folder.name}</div>
|
||||
</foreignObject>
|
||||
|
||||
{/* Component names preview - wrapped in foreignObject for text wrapping */}
|
||||
{folder.componentNames.length > 0 && (
|
||||
<foreignObject x={folder.x + 12} y={positions.componentListY} width={folder.width - 24} height={30}>
|
||||
<div className={css['FolderNode__componentListWrapper']}>
|
||||
{folder.componentNames.slice(0, 3).join(', ')}
|
||||
{folder.componentNames.length > 3 && `, +${folder.componentNames.length - 3} more`}
|
||||
</div>
|
||||
</foreignObject>
|
||||
)}
|
||||
|
||||
{/* Connection stats */}
|
||||
<text className={css['FolderNode__connections']} x={folder.x + 12} y={positions.statsY} fontSize="11">
|
||||
{folder.connectionCount.incoming} in • {folder.connectionCount.outgoing} out
|
||||
</text>
|
||||
|
||||
{/* Component count */}
|
||||
<text
|
||||
className={css['FolderNode__count']}
|
||||
x={folder.x + folder.width / 2}
|
||||
y={positions.countY}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{folder.componentCount} component{folder.componentCount !== 1 ? 's' : ''}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,36 @@
|
||||
/**
|
||||
* TopologyMapView Component
|
||||
*
|
||||
* Main SVG visualization container for the topology map.
|
||||
* Handles rendering nodes, edges, pan/zoom, and user interaction.
|
||||
* Main SVG visualization container for the folder-based topology map.
|
||||
* Handles rendering folder 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 { PositionedFolderGraph, FolderNode as FolderNodeType, TopologyViewState } from '../utils/topologyTypes';
|
||||
import { ComponentNode, calculateComponentNodeHeight } from './ComponentNode';
|
||||
import { FolderEdge } from './FolderEdge';
|
||||
import { FolderNode } from './FolderNode';
|
||||
import css from './TopologyMapView.module.scss';
|
||||
import { TopologyNode } from './TopologyNode';
|
||||
|
||||
export interface TopologyMapViewProps {
|
||||
graph: PositionedTopologyGraph;
|
||||
onNodeClick?: (node: TopologyNodeType) => void;
|
||||
onNodeHover?: (node: TopologyNodeType | null) => void;
|
||||
graph: PositionedFolderGraph;
|
||||
viewState: TopologyViewState;
|
||||
selectedFolderId: string | null;
|
||||
onFolderClick?: (folder: FolderNodeType) => void;
|
||||
onFolderDoubleClick?: (folder: FolderNodeType) => void;
|
||||
isLegendOpen?: boolean;
|
||||
onLegendToggle?: () => void;
|
||||
}
|
||||
|
||||
export function TopologyMapView({
|
||||
graph,
|
||||
onNodeClick,
|
||||
onNodeHover,
|
||||
viewState,
|
||||
selectedFolderId,
|
||||
onFolderClick,
|
||||
onFolderDoubleClick,
|
||||
isLegendOpen,
|
||||
onLegendToggle
|
||||
}: TopologyMapViewProps) {
|
||||
@@ -35,7 +40,7 @@ export function TopologyMapView({
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
// Handle mouse wheel for zoom (zoom to cursor position)
|
||||
// Handle mouse wheel for zoom
|
||||
const handleWheel = (e: React.WheelEvent<SVGSVGElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -53,7 +58,6 @@ export function TopologyMapView({
|
||||
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,
|
||||
@@ -67,7 +71,6 @@ export function TopologyMapView({
|
||||
// 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 });
|
||||
}
|
||||
@@ -114,12 +117,105 @@ export function TopologyMapView({
|
||||
setPan(newPan);
|
||||
};
|
||||
|
||||
// Node lookup map for edges
|
||||
const nodeMap = new Map<string, TopologyNodeType>();
|
||||
graph.nodes.forEach((node) => {
|
||||
nodeMap.set(node.fullName, node);
|
||||
// Folder lookup map for edges
|
||||
const folderMap = new Map<string, FolderNodeType>();
|
||||
graph.folders.forEach((folder) => {
|
||||
folderMap.set(folder.id, folder);
|
||||
});
|
||||
|
||||
// Get expanded folder if in expanded mode
|
||||
const expandedFolder =
|
||||
viewState.mode === 'expanded' ? graph.folders.find((f) => f.id === viewState.expandedFolderId) : null;
|
||||
|
||||
// Render expanded view (component-level)
|
||||
if (viewState.mode === 'expanded' && expandedFolder) {
|
||||
const components = expandedFolder.components;
|
||||
const componentsPerRow = 3;
|
||||
const nodeWidth = 180;
|
||||
const gap = 40;
|
||||
|
||||
// Calculate component positions with dynamic heights
|
||||
const componentLayouts = components.map((component, i) => {
|
||||
const dynamicHeight = calculateComponentNodeHeight(component.name);
|
||||
const row = Math.floor(i / componentsPerRow);
|
||||
const col = i % componentsPerRow;
|
||||
|
||||
// Calculate Y position based on previous rows' max heights
|
||||
let y = 50;
|
||||
for (let r = 0; r < row; r++) {
|
||||
const rowStart = r * componentsPerRow;
|
||||
const rowEnd = Math.min(rowStart + componentsPerRow, components.length);
|
||||
const rowComponents = components.slice(rowStart, rowEnd);
|
||||
const maxRowHeight = Math.max(...rowComponents.map((c) => calculateComponentNodeHeight(c.name)));
|
||||
y += maxRowHeight + gap;
|
||||
}
|
||||
|
||||
const x = 50 + col * (nodeWidth + gap);
|
||||
|
||||
return { component, x, y, height: dynamicHeight };
|
||||
});
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* SVG Canvas for expanded view */}
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className={css['TopologyMapView__svg']}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
cursor: isPanning ? 'grabbing' : 'grab'
|
||||
}}
|
||||
>
|
||||
<g transform={`translate(${pan.x}, ${pan.y}) scale(${scale})`}>
|
||||
{/* Render components in a grid with dynamic heights */}
|
||||
{componentLayouts.map((layout) => (
|
||||
<ComponentNode
|
||||
key={layout.component.fullName}
|
||||
component={layout.component}
|
||||
folderType={expandedFolder.type}
|
||||
x={layout.x}
|
||||
y={layout.y}
|
||||
width={nodeWidth}
|
||||
height={layout.height}
|
||||
isSelected={viewState.selectedComponentId === layout.component.fullName}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Stats footer */}
|
||||
<div className={css['TopologyMapView__footer']}>📦 {components.length} components in this folder</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render overview (folder-level) - default view
|
||||
return (
|
||||
<div className={css['TopologyMapView']}>
|
||||
{/* Controls */}
|
||||
@@ -165,33 +261,35 @@ export function TopologyMapView({
|
||||
</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>
|
||||
<span className={css['TopologyMapView__legendColor']} style={{ borderColor: '#3b82f6' }}></span>
|
||||
<span>📄 Pages (entry points)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span className={css['TopologyMapView__legendColor']} style={{ borderColor: '#a855f7' }}></span>
|
||||
<span>📝 Features (domain logic)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span className={css['TopologyMapView__legendColor']} style={{ borderColor: '#10b981' }}></span>
|
||||
<span>🔗 Integrations (external services)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span className={css['TopologyMapView__legendColor']} style={{ borderColor: '#06b6d4' }}></span>
|
||||
<span>🎨 UI (shared components)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span className={css['TopologyMapView__legendColor']} style={{ borderColor: '#6b7280' }}></span>
|
||||
<span>⚙️ Utilities (foundation)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span
|
||||
className={css['TopologyMapView__legendColor']}
|
||||
style={{ borderColor: 'var(--theme-color-primary)', borderWidth: '2.5px' }}
|
||||
style={{ borderColor: '#ca8a04', borderStyle: 'dashed' }}
|
||||
></span>
|
||||
<span>Page Component (blue border + shadow)</span>
|
||||
<span>⚠️ Orphans (unused)</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 className={css['TopologyMapView__legendDivider']} />
|
||||
<div className={css['TopologyMapView__legendHint']}>
|
||||
<strong>Tip:</strong> Double-click a folder to expand and see its components
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,36 +308,79 @@ export function TopologyMapView({
|
||||
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)}
|
||||
{graph.connections.map((connection, i) => {
|
||||
const fromFolder = folderMap.get(connection.from);
|
||||
const toFolder = folderMap.get(connection.to);
|
||||
if (!fromFolder || !toFolder) return null;
|
||||
|
||||
return (
|
||||
<FolderEdge
|
||||
key={`${connection.from}-${connection.to}-${i}`}
|
||||
connection={connection}
|
||||
fromFolder={fromFolder}
|
||||
toFolder={toFolder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Render top-level components (pages) */}
|
||||
{graph.topLevelComponents.map((topLevel) => (
|
||||
<ComponentNode
|
||||
key={topLevel.component.fullName}
|
||||
component={topLevel.component}
|
||||
folderType="page"
|
||||
x={topLevel.x!}
|
||||
y={topLevel.y!}
|
||||
width={topLevel.width!}
|
||||
height={topLevel.height!}
|
||||
isSelected={viewState.selectedComponentId === topLevel.component.fullName}
|
||||
isAppComponent={topLevel.isAppComponent}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Render nodes */}
|
||||
{graph.nodes.map((node) => (
|
||||
<TopologyNode
|
||||
key={node.fullName}
|
||||
node={node}
|
||||
onClick={(n) => onNodeClick?.(n)}
|
||||
onMouseEnter={(n) => onNodeHover?.(n)}
|
||||
onMouseLeave={() => onNodeHover?.(null)}
|
||||
{/* Render folder nodes */}
|
||||
{graph.folders.map((folder) => (
|
||||
<FolderNode
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
isSelected={folder.id === selectedFolderId}
|
||||
onClick={onFolderClick}
|
||||
onDoubleClick={onFolderDoubleClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Render orphan indicator if there are orphans */}
|
||||
{graph.orphanComponents.length > 0 && (
|
||||
<g>
|
||||
<rect
|
||||
x={50}
|
||||
y={700}
|
||||
width={140}
|
||||
height={50}
|
||||
rx={8}
|
||||
fill="#422006"
|
||||
stroke="#ca8a04"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4"
|
||||
opacity={0.6}
|
||||
/>
|
||||
<text x={120} y={720} textAnchor="middle" fill="#fcd34d" fontSize="13" fontWeight="600">
|
||||
⚠️ Orphans
|
||||
</text>
|
||||
<text x={120} y={738} textAnchor="middle" fill="#ca8a04" fontSize="11">
|
||||
{graph.orphanComponents.length} unused
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</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`}
|
||||
📊 {graph.totalFolders} folders • {graph.totalComponents} components
|
||||
{graph.orphanComponents.length > 0 && ` • ⚠️ ${graph.orphanComponents.length} orphans`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* useDraggable Hook
|
||||
*
|
||||
* Provides drag-and-drop functionality for SVG elements with snap-to-grid.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { snapPositionToGrid } from '../utils/snapToGrid';
|
||||
|
||||
export interface DraggableState {
|
||||
isDragging: boolean;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
}
|
||||
|
||||
export interface UseDraggableOptions {
|
||||
initialX: number;
|
||||
initialY: number;
|
||||
onDragEnd?: (x: number, y: number) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseDraggableResult {
|
||||
isDragging: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
handleMouseDown: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for making SVG elements draggable with snap-to-grid.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns Drag state and handlers
|
||||
*/
|
||||
export function useDraggable({
|
||||
initialX,
|
||||
initialY,
|
||||
onDragEnd,
|
||||
enabled = true
|
||||
}: UseDraggableOptions): UseDraggableResult {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [currentX, setCurrentX] = useState(initialX);
|
||||
const [currentY, setCurrentY] = useState(initialY);
|
||||
|
||||
const dragStartRef = useRef<{ x: number; y: number; mouseX: number; mouseY: number } | null>(null);
|
||||
|
||||
// Update position when initial position changes (from layout)
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setCurrentX(initialX);
|
||||
setCurrentY(initialY);
|
||||
}
|
||||
}, [initialX, initialY, isDragging]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!enabled) return;
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = {
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY
|
||||
};
|
||||
},
|
||||
[enabled, currentX, currentY]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging || !dragStartRef.current) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
|
||||
const dx = e.clientX - dragStartRef.current.mouseX;
|
||||
const dy = e.clientY - dragStartRef.current.mouseY;
|
||||
|
||||
setCurrentX(dragStartRef.current.x + dx);
|
||||
setCurrentY(dragStartRef.current.y + dy);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!dragStartRef.current) return;
|
||||
|
||||
// Snap to grid
|
||||
const snapped = snapPositionToGrid(currentX, currentY);
|
||||
|
||||
setCurrentX(snapped.x);
|
||||
setCurrentY(snapped.y);
|
||||
setIsDragging(false);
|
||||
|
||||
// Notify parent
|
||||
onDragEnd?.(snapped.x, snapped.y);
|
||||
|
||||
dragStartRef.current = null;
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, currentX, currentY, onDragEnd]);
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
handleMouseDown
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* useFolderGraph Hook
|
||||
*
|
||||
* Builds the folder-level topology graph from the current project.
|
||||
* This replaces useTopologyGraph for the folder-first architecture.
|
||||
*/
|
||||
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import { buildFolderGraph } from '../utils/folderAggregation';
|
||||
import { FolderGraph } from '../utils/topologyTypes';
|
||||
|
||||
/**
|
||||
* Hook that builds and returns the folder graph for the current project.
|
||||
*
|
||||
* @returns The complete folder graph with folders, connections, and metadata
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const folderGraph = useFolderGraph();
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <p>Total folders: {folderGraph.totalFolders}</p>
|
||||
* <p>Total components: {folderGraph.totalComponents}</p>
|
||||
* <p>Orphans: {folderGraph.orphanComponents.length}</p>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useFolderGraph(): FolderGraph {
|
||||
const project = ProjectModel.instance;
|
||||
const [updateTrigger, setUpdateTrigger] = useState(0);
|
||||
|
||||
// Rebuild graph when components change
|
||||
useEventListener(ProjectModel.instance, 'componentAdded', () => {
|
||||
setUpdateTrigger((prev) => prev + 1);
|
||||
});
|
||||
|
||||
useEventListener(ProjectModel.instance, 'componentRemoved', () => {
|
||||
setUpdateTrigger((prev) => prev + 1);
|
||||
});
|
||||
|
||||
useEventListener(ProjectModel.instance, 'componentRenamed', () => {
|
||||
setUpdateTrigger((prev) => prev + 1);
|
||||
});
|
||||
|
||||
const folderGraph = useMemo<FolderGraph>(() => {
|
||||
console.log('[useFolderGraph] Building folder graph...');
|
||||
const graph = buildFolderGraph(project);
|
||||
|
||||
// Log summary
|
||||
console.log(`[useFolderGraph] Summary:`);
|
||||
console.log(` - ${graph.totalFolders} folders`);
|
||||
console.log(` - ${graph.totalComponents} components`);
|
||||
console.log(` - ${graph.connections.length} folder connections`);
|
||||
console.log(` - ${graph.orphanComponents.length} orphans`);
|
||||
|
||||
// Log folder breakdown by type
|
||||
const typeBreakdown = graph.folders.reduce((acc, folder) => {
|
||||
acc[folder.type] = (acc[folder.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
console.log(`[useFolderGraph] Folder types:`, typeBreakdown);
|
||||
|
||||
return graph;
|
||||
}, [project, updateTrigger]);
|
||||
|
||||
return folderGraph;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* useFolderLayout Hook
|
||||
*
|
||||
* Applies tiered layout algorithm to folder nodes.
|
||||
* Replaces dagre auto-layout with semantic positioning.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { calculateFolderHeight } from '../utils/folderCardHeight';
|
||||
import { getTierYPosition } from '../utils/tierAssignment';
|
||||
import { FolderGraph, FolderNode, PositionedFolderGraph } from '../utils/topologyTypes';
|
||||
|
||||
/**
|
||||
* Layout configuration
|
||||
*/
|
||||
const LAYOUT_CONFIG = {
|
||||
NODE_WIDTH: 140,
|
||||
NODE_HEIGHT: 110,
|
||||
COMPONENT_WIDTH: 140,
|
||||
COMPONENT_HEIGHT: 110,
|
||||
HORIZONTAL_SPACING: 60,
|
||||
TIER_MARGIN: 150,
|
||||
ORPHAN_X: 50,
|
||||
ORPHAN_Y: 700
|
||||
};
|
||||
|
||||
/**
|
||||
* Positions folders in horizontal tiers based on their semantic tier.
|
||||
*
|
||||
* @param folderGraph The folder graph to layout
|
||||
* @returns Positioned folder graph with x,y coordinates
|
||||
*/
|
||||
function layoutFolderGraph(folderGraph: FolderGraph): PositionedFolderGraph {
|
||||
// Position top-level components on tier 0
|
||||
const tier0Y = getTierYPosition(0);
|
||||
let currentX = LAYOUT_CONFIG.TIER_MARGIN;
|
||||
|
||||
// Position top-level components first (they go on tier 0)
|
||||
folderGraph.topLevelComponents.forEach((topLevel) => {
|
||||
topLevel.x = currentX;
|
||||
topLevel.y = tier0Y;
|
||||
topLevel.width = LAYOUT_CONFIG.COMPONENT_WIDTH;
|
||||
topLevel.height = LAYOUT_CONFIG.COMPONENT_HEIGHT;
|
||||
currentX += LAYOUT_CONFIG.COMPONENT_WIDTH + LAYOUT_CONFIG.HORIZONTAL_SPACING;
|
||||
});
|
||||
|
||||
// Group folders by tier
|
||||
const foldersByTier = new Map<number, FolderNode[]>();
|
||||
for (const folder of folderGraph.folders) {
|
||||
if (!foldersByTier.has(folder.tier)) {
|
||||
foldersByTier.set(folder.tier, []);
|
||||
}
|
||||
foldersByTier.get(folder.tier)!.push(folder);
|
||||
}
|
||||
|
||||
// Position folders within each tier
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
// Track bounds from top-level components
|
||||
if (folderGraph.topLevelComponents.length > 0) {
|
||||
minX = LAYOUT_CONFIG.TIER_MARGIN;
|
||||
maxX = currentX - LAYOUT_CONFIG.HORIZONTAL_SPACING + LAYOUT_CONFIG.COMPONENT_WIDTH;
|
||||
minY = tier0Y;
|
||||
maxY = tier0Y + LAYOUT_CONFIG.COMPONENT_HEIGHT;
|
||||
}
|
||||
|
||||
for (const [tier, folders] of foldersByTier.entries()) {
|
||||
const y = getTierYPosition(tier);
|
||||
|
||||
// Start position for this tier
|
||||
// If tier 0, continue after top-level components
|
||||
const startX = tier === 0 ? currentX : LAYOUT_CONFIG.TIER_MARGIN;
|
||||
|
||||
folders.forEach((folder, index) => {
|
||||
folder.x = startX + index * (LAYOUT_CONFIG.NODE_WIDTH + LAYOUT_CONFIG.HORIZONTAL_SPACING);
|
||||
folder.y = y;
|
||||
folder.width = LAYOUT_CONFIG.NODE_WIDTH;
|
||||
// Calculate dynamic height based on content
|
||||
folder.height = calculateFolderHeight(folder);
|
||||
|
||||
// Track bounds
|
||||
minX = Math.min(minX, folder.x);
|
||||
maxX = Math.max(maxX, folder.x + folder.width);
|
||||
minY = Math.min(minY, folder.y);
|
||||
maxY = Math.max(maxY, folder.y + folder.height);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle orphans separately (bottom-left corner)
|
||||
if (folderGraph.orphanComponents.length > 0) {
|
||||
minX = Math.min(minX, LAYOUT_CONFIG.ORPHAN_X);
|
||||
maxX = Math.max(maxX, LAYOUT_CONFIG.ORPHAN_X + LAYOUT_CONFIG.NODE_WIDTH);
|
||||
minY = Math.min(minY, LAYOUT_CONFIG.ORPHAN_Y);
|
||||
maxY = Math.max(maxY, LAYOUT_CONFIG.ORPHAN_Y + LAYOUT_CONFIG.NODE_HEIGHT);
|
||||
}
|
||||
|
||||
// Add padding to bounds
|
||||
const padding = 50;
|
||||
const bounds = {
|
||||
x: minX - padding,
|
||||
y: minY - padding,
|
||||
width: maxX - minX + padding * 2,
|
||||
height: maxY - minY + padding * 2
|
||||
};
|
||||
|
||||
return {
|
||||
...folderGraph,
|
||||
folders: folderGraph.folders,
|
||||
bounds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that applies layout to a folder graph.
|
||||
*
|
||||
* @param folderGraph The folder graph to layout
|
||||
* @returns Positioned folder graph with coordinates
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const folderGraph = useFolderGraph();
|
||||
* const positionedGraph = useFolderLayout(folderGraph);
|
||||
*
|
||||
* return (
|
||||
* <svg viewBox={`0 0 ${positionedGraph.bounds.width} ${positionedGraph.bounds.height}`}>
|
||||
* {positionedGraph.folders.map(folder => (
|
||||
* <rect key={folder.id} x={folder.x} y={folder.y} width={folder.width} height={folder.height} />
|
||||
* ))}
|
||||
* </svg>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useFolderLayout(folderGraph: FolderGraph): PositionedFolderGraph {
|
||||
const positionedGraph = useMemo(() => {
|
||||
console.log('[useFolderLayout] Applying tiered layout...');
|
||||
const positioned = layoutFolderGraph(folderGraph);
|
||||
|
||||
console.log(`[useFolderLayout] Bounds: ${positioned.bounds.width}x${positioned.bounds.height}`);
|
||||
console.log(`[useFolderLayout] Positioned ${positioned.folders.length} folders`);
|
||||
|
||||
return positioned;
|
||||
}, [folderGraph]);
|
||||
|
||||
return positionedGraph;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Component Stats Utility
|
||||
*
|
||||
* Lightweight extraction of component statistics for display in Topology Map.
|
||||
* Optimized for performance - doesn't compute full X-Ray data.
|
||||
*/
|
||||
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
|
||||
|
||||
/**
|
||||
* Quick stats for a component
|
||||
*/
|
||||
export interface ComponentQuickStats {
|
||||
nodeCount: number;
|
||||
subcomponentCount: number;
|
||||
hasRestCalls: boolean;
|
||||
hasEvents: boolean;
|
||||
hasState: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract lightweight stats from a component.
|
||||
* Used for quick display in topology cards.
|
||||
*/
|
||||
export function getComponentQuickStats(component: ComponentModel): ComponentQuickStats {
|
||||
let nodeCount = 0;
|
||||
let subcomponentCount = 0;
|
||||
let hasRestCalls = false;
|
||||
let hasEvents = false;
|
||||
let hasState = false;
|
||||
|
||||
component.graph.forEachNode((node: NodeGraphNode) => {
|
||||
nodeCount++;
|
||||
|
||||
// Check for subcomponents
|
||||
if (node.type instanceof ComponentModel) {
|
||||
subcomponentCount++;
|
||||
}
|
||||
|
||||
// Check for REST calls
|
||||
if (node.typename === 'REST' || node.typename === 'REST2') {
|
||||
hasRestCalls = true;
|
||||
}
|
||||
|
||||
// Check for events
|
||||
if (node.typename === 'Send Event' || node.typename === 'Receive Event') {
|
||||
hasEvents = true;
|
||||
}
|
||||
|
||||
// Check for state
|
||||
if (
|
||||
node.typename === 'Variable' ||
|
||||
node.typename === 'Variable2' ||
|
||||
node.typename === 'Object' ||
|
||||
node.typename === 'States'
|
||||
) {
|
||||
hasState = true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
nodeCount,
|
||||
subcomponentCount,
|
||||
hasRestCalls,
|
||||
hasEvents,
|
||||
hasState
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format stats as a short display string.
|
||||
* Example: "24 nodes • 3 sub • REST • Events"
|
||||
*/
|
||||
export function formatStatsShort(stats: ComponentQuickStats): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`${stats.nodeCount} nodes`);
|
||||
|
||||
if (stats.subcomponentCount > 0) {
|
||||
parts.push(`${stats.subcomponentCount} sub`);
|
||||
}
|
||||
|
||||
if (stats.hasRestCalls) {
|
||||
parts.push('REST');
|
||||
}
|
||||
|
||||
if (stats.hasEvents) {
|
||||
parts.push('Events');
|
||||
}
|
||||
|
||||
if (stats.hasState) {
|
||||
parts.push('State');
|
||||
}
|
||||
|
||||
return parts.join(' • ');
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Folder Aggregation Utilities
|
||||
*
|
||||
* Functions to group components into folders and build folder-level connections.
|
||||
*/
|
||||
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
import { buildComponentDependencyGraph } from '@noodl-utils/graphAnalysis';
|
||||
|
||||
import { detectFolderType } from './folderTypeDetection';
|
||||
import { assignFolderTier } from './tierAssignment';
|
||||
import { FolderNode, FolderConnection, FolderGraph, TopLevelComponent } from './topologyTypes';
|
||||
|
||||
/**
|
||||
* Extracts the folder path from a component's full name.
|
||||
*
|
||||
* Examples:
|
||||
* - "/#Directus/Query" → "/#Directus"
|
||||
* - "/App" → "/" (root)
|
||||
* - "MyComponent" → "/" (root)
|
||||
*
|
||||
* @param componentFullName The full component path
|
||||
* @returns The folder path
|
||||
*/
|
||||
export function extractFolderPath(componentFullName: string): string {
|
||||
// Handle root-level components
|
||||
if (!componentFullName.includes('/')) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
// Find the last slash
|
||||
const lastSlashIndex = componentFullName.lastIndexOf('/');
|
||||
|
||||
if (lastSlashIndex === 0) {
|
||||
// Component is at root like "/App"
|
||||
return '/';
|
||||
}
|
||||
|
||||
// Return everything up to (and including) the last folder separator
|
||||
return componentFullName.substring(0, lastSlashIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a display name from a folder path.
|
||||
*
|
||||
* Examples:
|
||||
* - "/#Directus" → "Directus"
|
||||
* - "/#Directus Prefab/Components/Admin" → "Directus Prefab/Components/Admin"
|
||||
* - "/" → "Pages"
|
||||
*
|
||||
* @param folderPath The folder path
|
||||
* @returns A human-readable folder name (full breadcrumb path)
|
||||
*/
|
||||
export function getFolderDisplayName(folderPath: string): string {
|
||||
if (folderPath === '/') {
|
||||
return 'Pages';
|
||||
}
|
||||
|
||||
// Remove leading slash and any # prefix, return full path
|
||||
const cleaned = folderPath.replace(/^\/+/, '').replace(/^#/, '');
|
||||
|
||||
return cleaned || 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups components by their folder path.
|
||||
*
|
||||
* @param components Array of component models
|
||||
* @returns Map of folder path to components in that folder
|
||||
*/
|
||||
export function groupComponentsByFolder(components: ComponentModel[]): Map<string, ComponentModel[]> {
|
||||
const folderMap = new Map<string, ComponentModel[]>();
|
||||
|
||||
for (const component of components) {
|
||||
const folderPath = extractFolderPath(component.fullName);
|
||||
|
||||
if (!folderMap.has(folderPath)) {
|
||||
folderMap.set(folderPath, []);
|
||||
}
|
||||
|
||||
folderMap.get(folderPath)!.push(component);
|
||||
}
|
||||
|
||||
return folderMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds folder-level connections by aggregating component-to-component relationships.
|
||||
*
|
||||
* @param project The project model
|
||||
* @param folders Array of folder nodes
|
||||
* @returns Array of folder connections
|
||||
*/
|
||||
export function buildFolderConnections(project: ProjectModel, folders: FolderNode[]): FolderConnection[] {
|
||||
// Build component-level dependency graph
|
||||
const componentGraph = buildComponentDependencyGraph(project);
|
||||
|
||||
// Create a map from component fullName to folder id
|
||||
const componentToFolder = new Map<string, string>();
|
||||
for (const folder of folders) {
|
||||
for (const component of folder.components) {
|
||||
componentToFolder.set(component.fullName, folder.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate edges by folder-to-folder relationships
|
||||
const folderConnectionMap = new Map<string, FolderConnection>();
|
||||
|
||||
for (const edge of componentGraph.edges) {
|
||||
const fromFolder = componentToFolder.get(edge.from);
|
||||
const toFolder = componentToFolder.get(edge.to);
|
||||
|
||||
// Skip if either component isn't in a folder or if it's same-folder connection
|
||||
if (!fromFolder || !toFolder || fromFolder === toFolder) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create connection key
|
||||
const connectionKey = `${fromFolder}→${toFolder}`;
|
||||
|
||||
if (!folderConnectionMap.has(connectionKey)) {
|
||||
folderConnectionMap.set(connectionKey, {
|
||||
from: fromFolder,
|
||||
to: toFolder,
|
||||
count: 0,
|
||||
componentPairs: []
|
||||
});
|
||||
}
|
||||
|
||||
const connection = folderConnectionMap.get(connectionKey)!;
|
||||
connection.count += edge.count;
|
||||
connection.componentPairs.push({
|
||||
from: edge.from,
|
||||
to: edge.to
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(folderConnectionMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a component is a page component that should be displayed at the top level.
|
||||
*
|
||||
* @param component The component to check
|
||||
* @returns True if this is a page component
|
||||
*/
|
||||
export function isPageComponent(component: ComponentModel): boolean {
|
||||
// Must be at root level (path starts with "/" and has no subdirectories)
|
||||
const isRootLevel = component.fullName.startsWith('/') && component.fullName.lastIndexOf('/') === 0;
|
||||
if (!isRootLevel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = component.name.toLowerCase();
|
||||
|
||||
// App component is always a page
|
||||
if (name === 'app') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if name contains "page"
|
||||
if (name.includes('page')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if component contains Page Router nodes (indicating it's a page)
|
||||
let hasPageRouter = false;
|
||||
component.graph?.forEachNode((node) => {
|
||||
if (node.type?.fullName === 'Page Router' || node.type?.name === 'Page Router') {
|
||||
hasPageRouter = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasPageRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies orphaned components (not used by anything and at max depth).
|
||||
*
|
||||
* @param project The project model
|
||||
* @param allComponents All components in the project
|
||||
* @returns Array of orphaned components
|
||||
*/
|
||||
export function identifyOrphanComponents(project: ProjectModel, allComponents: ComponentModel[]): ComponentModel[] {
|
||||
const componentGraph = buildComponentDependencyGraph(project);
|
||||
|
||||
// Find components with no incoming edges
|
||||
const usedComponents = new Set(componentGraph.edges.map((edge) => edge.to));
|
||||
|
||||
return allComponents.filter((component) => {
|
||||
return !usedComponents.has(component.fullName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the complete folder graph from a project.
|
||||
* This is the main entry point for folder aggregation.
|
||||
*
|
||||
* @param project The project model
|
||||
* @returns Complete folder graph
|
||||
*/
|
||||
export function buildFolderGraph(project: ProjectModel): FolderGraph {
|
||||
console.log('[FolderAggregation] Building folder graph...');
|
||||
|
||||
// Get all components
|
||||
const allComponents = project.getComponents();
|
||||
console.log(`[FolderAggregation] Total components: ${allComponents.length}`);
|
||||
|
||||
// Identify orphans first
|
||||
const orphans = identifyOrphanComponents(project, allComponents);
|
||||
console.log(`[FolderAggregation] Orphaned components: ${orphans.length}`);
|
||||
|
||||
// Filter out orphans from folder grouping
|
||||
const activeComponents = allComponents.filter((c) => !orphans.includes(c));
|
||||
|
||||
// Group components by folder
|
||||
const folderMap = groupComponentsByFolder(activeComponents);
|
||||
console.log(`[FolderAggregation] Unique folders: ${folderMap.size}`);
|
||||
|
||||
// Extract page components from root folder
|
||||
const topLevelComponents: TopLevelComponent[] = [];
|
||||
const rootComponents = folderMap.get('/') || [];
|
||||
const pageComponents = rootComponents.filter((c) => isPageComponent(c));
|
||||
const nonPageRootComponents = rootComponents.filter((c) => !isPageComponent(c));
|
||||
|
||||
console.log(`[FolderAggregation] Page components: ${pageComponents.length}`);
|
||||
|
||||
// Create TopLevelComponent objects for page components
|
||||
for (const pageComp of pageComponents) {
|
||||
topLevelComponents.push({
|
||||
component: pageComp,
|
||||
isAppComponent: pageComp.name.toLowerCase() === 'app'
|
||||
});
|
||||
}
|
||||
|
||||
// Update folderMap to only include non-page root components
|
||||
if (nonPageRootComponents.length > 0) {
|
||||
folderMap.set('/', nonPageRootComponents);
|
||||
} else {
|
||||
folderMap.delete('/'); // Remove root folder if all components are pages
|
||||
}
|
||||
|
||||
// Build folder nodes
|
||||
const folders: FolderNode[] = [];
|
||||
let folderIdCounter = 0;
|
||||
|
||||
for (const [folderPath, components] of folderMap.entries()) {
|
||||
const folderId = `folder-${folderIdCounter++}`;
|
||||
const displayName = getFolderDisplayName(folderPath);
|
||||
const folderType = detectFolderType(folderPath, components);
|
||||
|
||||
const folderNode: FolderNode = {
|
||||
id: folderId,
|
||||
name: displayName,
|
||||
path: folderPath,
|
||||
type: folderType,
|
||||
componentCount: components.length,
|
||||
components: components,
|
||||
componentNames: components.slice(0, 5).map((c) => c.name), // First 5 for preview
|
||||
connectionCount: {
|
||||
incoming: 0, // Will be calculated after connections are built
|
||||
outgoing: 0
|
||||
},
|
||||
tier: 0 // Will be assigned later
|
||||
};
|
||||
|
||||
folders.push(folderNode);
|
||||
}
|
||||
|
||||
// Build folder-to-folder connections
|
||||
const connections = buildFolderConnections(project, folders);
|
||||
|
||||
// Calculate connection counts for each folder
|
||||
for (const connection of connections) {
|
||||
const sourceFolder = folders.find((f) => f.id === connection.from);
|
||||
const targetFolder = folders.find((f) => f.id === connection.to);
|
||||
|
||||
if (sourceFolder) {
|
||||
sourceFolder.connectionCount.outgoing += connection.count;
|
||||
}
|
||||
if (targetFolder) {
|
||||
targetFolder.connectionCount.incoming += connection.count;
|
||||
}
|
||||
}
|
||||
console.log(`[FolderAggregation] Folder connections: ${connections.length}`);
|
||||
|
||||
// Assign tiers for semantic layout
|
||||
for (const folder of folders) {
|
||||
folder.tier = assignFolderTier(folder, folders, connections);
|
||||
}
|
||||
|
||||
// Log tier distribution
|
||||
const tierCounts = folders.reduce((acc, f) => {
|
||||
acc[f.tier] = (acc[f.tier] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<number, number>);
|
||||
console.log('[FolderAggregation] Tier distribution:', tierCounts);
|
||||
|
||||
return {
|
||||
folders,
|
||||
topLevelComponents,
|
||||
connections,
|
||||
orphanComponents: orphans,
|
||||
totalFolders: folders.length,
|
||||
totalComponents: allComponents.length
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Folder Card Height Calculation
|
||||
*
|
||||
* Utilities to calculate dynamic heights for folder cards based on content.
|
||||
*/
|
||||
|
||||
import { FolderNode } from './topologyTypes';
|
||||
|
||||
/**
|
||||
* Configuration for height calculations
|
||||
*/
|
||||
const HEIGHT_CONFIG = {
|
||||
HEADER_HEIGHT: 40, // Icon + title area base
|
||||
LINE_HEIGHT: 18, // Height per text line
|
||||
COMPONENT_LIST_HEIGHT: 30, // Height when component list is shown
|
||||
FOOTER_HEIGHT: 50, // Stats + count area
|
||||
MIN_HEIGHT: 110 // Minimum card height
|
||||
};
|
||||
|
||||
/**
|
||||
* Estimates the number of lines a text will wrap to given a max width.
|
||||
*
|
||||
* @param text The text to measure
|
||||
* @param maxCharsPerLine Rough estimate of characters per line (default: 15)
|
||||
* @returns Estimated number of lines
|
||||
*/
|
||||
export function estimateTextLines(text: string, maxCharsPerLine: number = 15): number {
|
||||
if (!text) return 1;
|
||||
return Math.max(1, Math.ceil(text.length / maxCharsPerLine));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the dynamic height needed for a folder card.
|
||||
*
|
||||
* @param folder The folder node
|
||||
* @returns The calculated height in pixels
|
||||
*/
|
||||
export function calculateFolderHeight(folder: FolderNode): number {
|
||||
// Calculate title height (max 2 lines with ellipsis)
|
||||
const titleLines = Math.min(2, estimateTextLines(folder.name, 15));
|
||||
const titleHeight = titleLines * HEIGHT_CONFIG.LINE_HEIGHT;
|
||||
|
||||
// Component list height (if present)
|
||||
const componentListHeight = folder.componentNames.length > 0 ? HEIGHT_CONFIG.COMPONENT_LIST_HEIGHT : 0;
|
||||
|
||||
// Total height
|
||||
const totalHeight = HEIGHT_CONFIG.HEADER_HEIGHT + titleHeight + componentListHeight + HEIGHT_CONFIG.FOOTER_HEIGHT;
|
||||
|
||||
// Ensure minimum height
|
||||
return Math.max(HEIGHT_CONFIG.MIN_HEIGHT, totalHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates Y positions for each section of the folder card.
|
||||
*
|
||||
* @param folder The folder node with x, y, width, height set
|
||||
* @returns Object with Y positions for each section
|
||||
*/
|
||||
export function calculateFolderSectionPositions(folder: FolderNode) {
|
||||
const titleLines = Math.min(2, estimateTextLines(folder.name, 15));
|
||||
const titleHeight = titleLines * HEIGHT_CONFIG.LINE_HEIGHT;
|
||||
|
||||
return {
|
||||
iconY: folder.y! + 16,
|
||||
titleY: folder.y! + 14,
|
||||
titleHeight: titleHeight + 10, // Add gap after title
|
||||
componentListY: folder.y! + HEIGHT_CONFIG.HEADER_HEIGHT + titleHeight + 10,
|
||||
statsY: folder.y! + folder.height! - 50,
|
||||
countY: folder.y! + folder.height! - 15
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Folder Color Mappings
|
||||
*
|
||||
* Defines colors for folder types used in:
|
||||
* - Gradient edge coloring (source to target)
|
||||
* - Folder node styling accents
|
||||
*/
|
||||
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
|
||||
import { IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import { FolderType } from './topologyTypes';
|
||||
|
||||
/**
|
||||
* Maps folder types to their visual color.
|
||||
* Used for gradient edges and visual accents.
|
||||
*/
|
||||
export function getFolderColor(type: FolderType): string {
|
||||
switch (type) {
|
||||
case 'page':
|
||||
return '#4CAF50'; // Green - entry points
|
||||
case 'integration':
|
||||
return '#FF9800'; // Orange - external connections
|
||||
case 'ui':
|
||||
return '#2196F3'; // Blue - visual components
|
||||
case 'utility':
|
||||
return '#9C27B0'; // Purple - helper functions
|
||||
case 'feature':
|
||||
return '#FFC107'; // Amber - business logic
|
||||
case 'orphan':
|
||||
return '#F44336'; // Red - unused/isolated
|
||||
default:
|
||||
return '#757575'; // Grey - unknown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps folder types to their icon.
|
||||
* Used instead of emojis for professional appearance.
|
||||
*/
|
||||
export function getFolderIcon(type: FolderType): IconName {
|
||||
switch (type) {
|
||||
case 'page':
|
||||
return IconName.PageRouter;
|
||||
case 'integration':
|
||||
return IconName.RestApi;
|
||||
case 'ui':
|
||||
return IconName.UI;
|
||||
case 'utility':
|
||||
return IconName.Sliders;
|
||||
case 'feature':
|
||||
return IconName.ComponentWithChildren;
|
||||
case 'orphan':
|
||||
return IconName.WarningCircle;
|
||||
default:
|
||||
return IconName.FolderClosed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets icon for a component based on its type.
|
||||
* Uses runtime property checks to handle optional ComponentModel properties.
|
||||
*/
|
||||
export function getComponentIcon(component: ComponentModel): IconName {
|
||||
// Use runtime checks since ComponentModel properties may vary
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const comp = component as any;
|
||||
|
||||
if (comp.isPage === true) {
|
||||
return IconName.PageRouter;
|
||||
} else if (comp.isCloudFunction === true) {
|
||||
return IconName.CloudFunction;
|
||||
}
|
||||
return IconName.Component;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Folder Type Detection
|
||||
*
|
||||
* Logic to classify folders into semantic types based on path and component characteristics.
|
||||
*/
|
||||
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
|
||||
import { FolderType } from './topologyTypes';
|
||||
|
||||
/**
|
||||
* Determines if a component is a page based on naming conventions.
|
||||
*
|
||||
* @param component Component to check
|
||||
* @returns True if component appears to be a page
|
||||
*/
|
||||
function isPageComponent(component: ComponentModel): boolean {
|
||||
const name = component.name.toLowerCase();
|
||||
const fullName = component.fullName.toLowerCase();
|
||||
|
||||
return (
|
||||
name.includes('page') ||
|
||||
name.includes('screen') ||
|
||||
name.includes('route') ||
|
||||
name === 'app' ||
|
||||
name === 'root' ||
|
||||
fullName.startsWith('/app') ||
|
||||
fullName.startsWith('/page')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the type of a folder based on its path and components.
|
||||
*
|
||||
* Classification rules:
|
||||
* - **page**: Root-level or contains page/screen components
|
||||
* - **integration**: Starts with # and matches known service patterns (Directus, Supabase, etc.)
|
||||
* - **ui**: Starts with # and matches UI patterns (#UI, #Components, #Design)
|
||||
* - **utility**: Starts with # and matches utility patterns (#Global, #Utils, #Shared, #Helpers)
|
||||
* - **feature**: Starts with # and represents a feature domain (#Forms, #Auth, etc.)
|
||||
* - **orphan**: Special case for components without connections (handled separately)
|
||||
*
|
||||
* @param folderPath The folder path
|
||||
* @param components Components in the folder
|
||||
* @returns The detected folder type
|
||||
*/
|
||||
export function detectFolderType(folderPath: string, components: ComponentModel[]): FolderType {
|
||||
const normalizedPath = folderPath.toLowerCase();
|
||||
|
||||
// Root folder (/) - treat as pages
|
||||
if (folderPath === '/') {
|
||||
return 'page';
|
||||
}
|
||||
|
||||
// Check if folder contains page components
|
||||
const hasPageComponents = components.some((c) => isPageComponent(c));
|
||||
if (hasPageComponents) {
|
||||
return 'page';
|
||||
}
|
||||
|
||||
// Extract folder name (remove leading /, #, etc.)
|
||||
const folderName = folderPath.replace(/^\/+/, '').replace(/^#/, '').toLowerCase();
|
||||
|
||||
// Integration patterns - external services
|
||||
const integrationPatterns = [
|
||||
'directus',
|
||||
'supabase',
|
||||
'firebase',
|
||||
'airtable',
|
||||
'stripe',
|
||||
'auth0',
|
||||
'swapcard',
|
||||
'api',
|
||||
'rest',
|
||||
'graphql',
|
||||
'backend'
|
||||
];
|
||||
|
||||
if (integrationPatterns.some((pattern) => folderName.includes(pattern))) {
|
||||
return 'integration';
|
||||
}
|
||||
|
||||
// UI patterns - visual components
|
||||
const uiPatterns = ['ui', 'component', 'design', 'layout', 'widget', 'button', 'card', 'modal', 'dialog'];
|
||||
|
||||
if (uiPatterns.some((pattern) => folderName.includes(pattern))) {
|
||||
return 'ui';
|
||||
}
|
||||
|
||||
// Utility patterns - foundational utilities
|
||||
const utilityPatterns = ['global', 'util', 'helper', 'shared', 'common', 'core', 'lib', 'tool', 'function'];
|
||||
|
||||
if (utilityPatterns.some((pattern) => folderName.includes(pattern))) {
|
||||
return 'utility';
|
||||
}
|
||||
|
||||
// Feature patterns - domain-specific features
|
||||
const featurePatterns = ['form', 'auth', 'user', 'profile', 'dashboard', 'admin', 'setting', 'search', 'filter'];
|
||||
|
||||
if (featurePatterns.some((pattern) => folderName.includes(pattern))) {
|
||||
return 'feature';
|
||||
}
|
||||
|
||||
// Default to feature for any other # prefixed folder
|
||||
if (normalizedPath.startsWith('/#')) {
|
||||
return 'feature';
|
||||
}
|
||||
|
||||
// Fallback to feature type
|
||||
return 'feature';
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Snap-to-Grid Utility
|
||||
*
|
||||
* Snaps coordinates to a grid for clean alignment of draggable elements.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Snaps a coordinate to the nearest grid point.
|
||||
*
|
||||
* @param value - The coordinate value to snap
|
||||
* @param gridSize - The size of the grid (default: 20px)
|
||||
* @returns The snapped coordinate
|
||||
*/
|
||||
export function snapToGrid(value: number, gridSize: number = 20): number {
|
||||
return Math.round(value / gridSize) * gridSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaps an x,y position to the nearest grid point.
|
||||
*
|
||||
* @param x - The x coordinate
|
||||
* @param y - The y coordinate
|
||||
* @param gridSize - The size of the grid (default: 20px)
|
||||
* @returns Object with snapped x and y coordinates
|
||||
*/
|
||||
export function snapPositionToGrid(x: number, y: number, gridSize: number = 20): { x: number; y: number } {
|
||||
return {
|
||||
x: snapToGrid(x, gridSize),
|
||||
y: snapToGrid(y, gridSize)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Tier Assignment
|
||||
*
|
||||
* Logic to assign semantic tiers to folders for hierarchical layout.
|
||||
* Tiers represent vertical positioning in the topology view.
|
||||
*/
|
||||
|
||||
import { FolderNode, FolderConnection } from './topologyTypes';
|
||||
|
||||
/**
|
||||
* Assigns a semantic tier to a folder for hierarchical layout.
|
||||
*
|
||||
* Tier system:
|
||||
* - **Tier 0**: Pages (entry points, top of hierarchy)
|
||||
* - **Tier 1**: Features used directly by pages
|
||||
* - **Tier 2**: Shared libraries (integrations, UI components)
|
||||
* - **Tier 3**: Utilities (foundation, bottom of hierarchy)
|
||||
* - **Tier -1**: Orphans (separate, not in main flow)
|
||||
*
|
||||
* @param folder The folder to assign a tier to
|
||||
* @param allFolders All folders in the graph
|
||||
* @param connections All folder connections
|
||||
* @returns The assigned tier (0-3, or -1 for orphans)
|
||||
*/
|
||||
export function assignFolderTier(
|
||||
folder: FolderNode,
|
||||
allFolders: FolderNode[],
|
||||
connections: FolderConnection[]
|
||||
): number {
|
||||
// Orphans get special tier
|
||||
if (folder.type === 'orphan') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Pages always at top
|
||||
if (folder.type === 'page') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Utilities always at bottom
|
||||
if (folder.type === 'utility') {
|
||||
return 3;
|
||||
}
|
||||
|
||||
// Check what uses this folder
|
||||
const usedByConnections = connections.filter((c) => c.to === folder.id);
|
||||
|
||||
// Find the types of folders that use this one
|
||||
const usedByTypes = new Set<string>();
|
||||
for (const conn of usedByConnections) {
|
||||
const sourceFolder = allFolders.find((f) => f.id === conn.from);
|
||||
if (sourceFolder) {
|
||||
usedByTypes.add(sourceFolder.type);
|
||||
}
|
||||
}
|
||||
|
||||
// If used by pages, place in tier 1 (features layer)
|
||||
if (usedByTypes.has('page')) {
|
||||
// Features used by pages go in tier 1
|
||||
if (folder.type === 'feature') {
|
||||
return 1;
|
||||
}
|
||||
// Integrations/UI used by pages go in tier 2
|
||||
return 2;
|
||||
}
|
||||
|
||||
// If used by tier 1 folders, place in tier 2
|
||||
const usedByTier1 = usedByConnections.some((conn) => {
|
||||
const sourceFolder = allFolders.find((f) => f.id === conn.from);
|
||||
return sourceFolder && sourceFolder.type === 'feature';
|
||||
});
|
||||
|
||||
if (usedByTier1) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Default tier based on type
|
||||
switch (folder.type) {
|
||||
case 'integration':
|
||||
return 2; // Shared layer
|
||||
case 'ui':
|
||||
return 2; // Shared layer
|
||||
case 'feature':
|
||||
return 1; // Feature layer
|
||||
default:
|
||||
return 2; // Default to shared layer
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Y position for a given tier.
|
||||
*
|
||||
* @param tier The tier number
|
||||
* @returns Y coordinate in the layout space
|
||||
*/
|
||||
export function getTierYPosition(tier: number): number {
|
||||
switch (tier) {
|
||||
case -1:
|
||||
return 600; // Orphans at bottom
|
||||
case 0:
|
||||
return 100; // Pages at top
|
||||
case 1:
|
||||
return 250; // Features
|
||||
case 2:
|
||||
return 400; // Shared (integrations, UI)
|
||||
case 3:
|
||||
return 550; // Utilities
|
||||
default:
|
||||
return 400; // Fallback to middle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a display label for a tier.
|
||||
*
|
||||
* @param tier The tier number
|
||||
* @returns Human-readable tier label
|
||||
*/
|
||||
export function getTierLabel(tier: number): string {
|
||||
switch (tier) {
|
||||
case -1:
|
||||
return 'Orphaned';
|
||||
case 0:
|
||||
return 'Pages';
|
||||
case 1:
|
||||
return 'Features';
|
||||
case 2:
|
||||
return 'Shared';
|
||||
case 3:
|
||||
return 'Utilities';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Topology Map Persistence
|
||||
*
|
||||
* Handles saving and loading custom positions and sticky notes from project metadata.
|
||||
* Stored in project.json under "topologyMap" key.
|
||||
*/
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
import { UndoActionGroup, UndoQueue } from '@noodl-models/undo-queue-model';
|
||||
|
||||
import { CustomPosition, StickyNote, TopologyMapMetadata } from './topologyTypes';
|
||||
|
||||
const METADATA_KEY = 'topologyMap';
|
||||
|
||||
/**
|
||||
* Get topology map metadata from project.
|
||||
* Returns default empty metadata if not found.
|
||||
*/
|
||||
export function getTopologyMapMetadata(project: ProjectModel): TopologyMapMetadata {
|
||||
const metadata = project.getMetaData(METADATA_KEY);
|
||||
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return {
|
||||
version: 1,
|
||||
customPositions: {},
|
||||
stickyNotes: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
version: (metadata as TopologyMapMetadata).version || 1,
|
||||
customPositions: (metadata as TopologyMapMetadata).customPositions || {},
|
||||
stickyNotes: (metadata as TopologyMapMetadata).stickyNotes || []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save topology map metadata to project.
|
||||
* Uses undo queue for proper undo/redo support.
|
||||
*/
|
||||
export function saveTopologyMapMetadata(project: ProjectModel, metadata: TopologyMapMetadata): void {
|
||||
// Capture previous state before modification
|
||||
const previousMetadata = getTopologyMapMetadata(project);
|
||||
|
||||
// Use the correct UndoQueue pattern (see UNDO-QUEUE-PATTERNS.md)
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: 'Update Topology Map',
|
||||
do: () => {
|
||||
project.setMetaData(METADATA_KEY, metadata);
|
||||
},
|
||||
undo: () => {
|
||||
project.setMetaData(METADATA_KEY, previousMetadata);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update custom position for a folder or component.
|
||||
*/
|
||||
export function updateCustomPosition(project: ProjectModel, nodeId: string, position: CustomPosition): void {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
|
||||
metadata.customPositions[nodeId] = position;
|
||||
|
||||
saveTopologyMapMetadata(project, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom position for a node (if exists).
|
||||
*/
|
||||
export function getCustomPosition(project: ProjectModel, nodeId: string): CustomPosition | undefined {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
return metadata.customPositions[nodeId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear custom position for a node.
|
||||
*/
|
||||
export function clearCustomPosition(project: ProjectModel, nodeId: string): void {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
|
||||
delete metadata.customPositions[nodeId];
|
||||
|
||||
saveTopologyMapMetadata(project, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new sticky note.
|
||||
*/
|
||||
export function addStickyNote(project: ProjectModel, note: StickyNote): void {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
|
||||
metadata.stickyNotes.push(note);
|
||||
|
||||
saveTopologyMapMetadata(project, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing sticky note.
|
||||
*/
|
||||
export function updateStickyNote(project: ProjectModel, noteId: string, updates: Partial<StickyNote>): void {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
|
||||
const noteIndex = metadata.stickyNotes.findIndex((n) => n.id === noteId);
|
||||
|
||||
if (noteIndex !== -1) {
|
||||
metadata.stickyNotes[noteIndex] = {
|
||||
...metadata.stickyNotes[noteIndex],
|
||||
...updates
|
||||
};
|
||||
|
||||
saveTopologyMapMetadata(project, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a sticky note.
|
||||
*/
|
||||
export function deleteStickyNote(project: ProjectModel, noteId: string): void {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
|
||||
metadata.stickyNotes = metadata.stickyNotes.filter((n) => n.id !== noteId);
|
||||
|
||||
saveTopologyMapMetadata(project, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sticky notes.
|
||||
*/
|
||||
export function getStickyNotes(project: ProjectModel): StickyNote[] {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
return metadata.stickyNotes;
|
||||
}
|
||||
@@ -39,15 +39,43 @@ export interface TopologyNode {
|
||||
}
|
||||
|
||||
/**
|
||||
* An edge in the topology graph representing component usage.
|
||||
* Positioned folder graph with layout coordinates
|
||||
*/
|
||||
export interface PositionedFolderGraph extends FolderGraph {
|
||||
bounds: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* View mode for topology map
|
||||
*/
|
||||
export type TopologyViewMode = 'overview' | 'expanded';
|
||||
|
||||
/**
|
||||
* View state for topology map
|
||||
*/
|
||||
export interface TopologyViewState {
|
||||
mode: TopologyViewMode;
|
||||
expandedFolderId: string | null;
|
||||
selectedComponentId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection between two topology nodes (components).
|
||||
*/
|
||||
export interface TopologyEdge {
|
||||
/** Source component fullName */
|
||||
/** Source component full name */
|
||||
from: string;
|
||||
/** Target component fullName */
|
||||
/** Target component full name */
|
||||
to: string;
|
||||
/** Number of instances of this relationship */
|
||||
count: number;
|
||||
/** Connection type (e.g., 'children', 'component') */
|
||||
type?: string;
|
||||
/** Number of connections between these components (for aggregated edges) */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,3 +129,158 @@ export interface PositionedTopologyGraph extends TopologyGraph {
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder type classification for semantic grouping.
|
||||
*/
|
||||
export type FolderType = 'page' | 'feature' | 'integration' | 'ui' | 'utility' | 'orphan';
|
||||
|
||||
/**
|
||||
* A folder node representing a group of components.
|
||||
* This is the primary unit in the folder-first topology view.
|
||||
*/
|
||||
export interface FolderNode {
|
||||
/** Unique folder identifier */
|
||||
id: string;
|
||||
/** Display name (e.g., "Directus", "Forms") */
|
||||
name: string;
|
||||
/** Full folder path (e.g., "/#Directus") */
|
||||
path: string;
|
||||
/** Folder type classification */
|
||||
type: FolderType;
|
||||
/** Number of components in this folder */
|
||||
componentCount: number;
|
||||
/** Component models in this folder */
|
||||
components: ComponentModel[];
|
||||
/** Component names for preview (first few names) */
|
||||
componentNames: string[];
|
||||
/** Connection statistics */
|
||||
connectionCount: {
|
||||
incoming: number;
|
||||
outgoing: number;
|
||||
};
|
||||
/** Semantic tier for layout (0=pages, 1=features, 2=shared, 3=utilities, -1=orphans) */
|
||||
tier: number;
|
||||
/** X position (set by layout engine or custom position) */
|
||||
x?: number;
|
||||
/** Y position (set by layout engine or custom position) */
|
||||
y?: number;
|
||||
/** Node width (set by layout engine) */
|
||||
width?: number;
|
||||
/** Node height (set by layout engine) */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A top-level component to display as an individual card (e.g., pages).
|
||||
*/
|
||||
export interface TopLevelComponent {
|
||||
/** The component model */
|
||||
component: ComponentModel;
|
||||
/** Whether this is the App component (for special styling) */
|
||||
isAppComponent: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection between two folders, aggregating component-level connections.
|
||||
*/
|
||||
export interface FolderConnection {
|
||||
/** Source folder id */
|
||||
from: string;
|
||||
/** Target folder id */
|
||||
to: string;
|
||||
/** Number of component-to-component connections between these folders */
|
||||
count: number;
|
||||
/** Individual component pairs that create this connection */
|
||||
componentPairs: Array<{ from: string; to: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The complete folder-level topology graph.
|
||||
*/
|
||||
export interface FolderGraph {
|
||||
/** All folder nodes */
|
||||
folders: FolderNode[];
|
||||
/** Top-level components to display as individual cards (e.g., pages) */
|
||||
topLevelComponents: TopLevelComponent[];
|
||||
/** All folder-to-folder connections */
|
||||
connections: FolderConnection[];
|
||||
/** Components that don't belong to any folder or are unused */
|
||||
orphanComponents: ComponentModel[];
|
||||
/** Total folder count */
|
||||
totalFolders: number;
|
||||
/** Total component count across all folders */
|
||||
totalComponents: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positioned folder graph ready for rendering.
|
||||
*/
|
||||
export interface PositionedFolderGraph extends FolderGraph {
|
||||
/** Folders with layout positions */
|
||||
folders: FolderNode[];
|
||||
/** Bounding box of the entire graph */
|
||||
bounds: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky note color options (fixed palette)
|
||||
*/
|
||||
export type StickyNoteColor = 'yellow' | 'blue' | 'pink' | 'green';
|
||||
|
||||
/**
|
||||
* A sticky note annotation on the topology map
|
||||
*/
|
||||
export interface StickyNote {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** X position */
|
||||
x: number;
|
||||
/** Y position */
|
||||
y: number;
|
||||
/** Note width */
|
||||
width: number;
|
||||
/** Note height */
|
||||
height: number;
|
||||
/** Note text content */
|
||||
text: string;
|
||||
/** Note color */
|
||||
color: StickyNoteColor;
|
||||
/** Creation timestamp */
|
||||
createdAt: number;
|
||||
/** Last update timestamp */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom position override for a node
|
||||
*/
|
||||
export interface CustomPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Topology map metadata stored in project.json
|
||||
*/
|
||||
export interface TopologyMapMetadata {
|
||||
/** Schema version for migrations */
|
||||
version: number;
|
||||
/** Custom positions by node ID */
|
||||
customPositions: Record<string, CustomPosition>;
|
||||
/** Sticky notes */
|
||||
stickyNotes: StickyNote[];
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function PropertyEditor(props: PropertyEditorProps) {
|
||||
return function () {
|
||||
SidebarModel.instance.off(group);
|
||||
};
|
||||
}, []);
|
||||
}, [props.model]); // FIX: Update when model changes!
|
||||
|
||||
const aiAssistant = props.model?.metadata?.AiAssistant;
|
||||
if (aiAssistant) {
|
||||
|
||||
Reference in New Issue
Block a user