Tried to add data lineage view, implementation failed and requires rethink

This commit is contained in:
Richard Osborne
2026-01-04 22:31:21 +01:00
parent bb9f4dfcc8
commit d144166f79
47 changed files with 6423 additions and 205 deletions

View File

@@ -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();

View File

@@ -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',

View File

@@ -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';

View File

@@ -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);
}

View File

@@ -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({

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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);
}
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,8 @@
/**
* Data Lineage Panel
*
* Exports for the Data Lineage visualization panel
*/
export { DataLineagePanel } from './DataLineagePanel';
export type { DataLineagePanelProps } from './DataLineagePanel';

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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);
}
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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);
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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
};
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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(' • ');
}

View File

@@ -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
};
}

View File

@@ -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
};
}

View File

@@ -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;
}

View File

@@ -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';
}

View File

@@ -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)
};
}

View File

@@ -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';
}
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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) {