mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 14:52:55 +01:00
Tried to add data lineage view, implementation failed and requires rethink
This commit is contained in:
440
.clinerules
440
.clinerules
@@ -1080,3 +1080,443 @@ After creating a node:
|
||||
| Signal-based node | `noodl-runtime/src/nodes/std-library/timer.js` (in viewer-react) |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 15. Task Sizing & Context Management
|
||||
|
||||
### 15.1 Understanding Your Limits
|
||||
|
||||
You (Cline) are running on Claude API with hard limits:
|
||||
|
||||
- **Context window**: ~200K tokens (~150K words)
|
||||
- **Output limit**: ~8K tokens per response
|
||||
- **When you exceed these**: You get an API error and must retry
|
||||
|
||||
**CRITICAL**: If you hit an API error about context length or output limit, DO NOT retry the same approach. You must split the task.
|
||||
|
||||
### 15.2 Recognizing Tasks That Are Too Large
|
||||
|
||||
Before starting implementation, estimate task size:
|
||||
|
||||
#### Signs a task will exceed limits:
|
||||
|
||||
```
|
||||
❌ TOO LARGE - Will hit API limits:
|
||||
- Modifying 10+ files in one go
|
||||
- Reading entire large files multiple times
|
||||
- Converting 50+ Storybook stories
|
||||
- Refactoring a whole subsystem at once
|
||||
- Adding features across runtime + editor + viewer
|
||||
|
||||
✅ MANAGEABLE - Can complete in context:
|
||||
- Modifying 1-3 related files
|
||||
- Adding a single feature to one package
|
||||
- Converting 5-10 Storybook stories
|
||||
- Fixing a specific bug in one area
|
||||
- Writing focused tests for one module
|
||||
```
|
||||
|
||||
#### Quick size estimation:
|
||||
|
||||
| Task Scope | Estimated Files | Context Safety | Action |
|
||||
| -------------- | --------------- | -------------- | ------------------------- |
|
||||
| Bug fix | 1-3 files | ✅ Safe | Proceed |
|
||||
| Small feature | 3-5 files | ✅ Safe | Proceed |
|
||||
| Medium feature | 5-10 files | ⚠️ Monitor | Watch context carefully |
|
||||
| Large feature | 10-20 files | ❌ Risky | Split into subtasks first |
|
||||
| Refactoring | 20+ files | ❌ Too large | Must split |
|
||||
|
||||
### 15.3 When You Get an API Error
|
||||
|
||||
If you receive an error like:
|
||||
|
||||
- "Request too large"
|
||||
- "Context length exceeded"
|
||||
- "Maximum token limit exceeded"
|
||||
- Any message about being over limits
|
||||
|
||||
**DO NOT** retry the same task at the same scope.
|
||||
|
||||
**IMMEDIATELY** follow this protocol:
|
||||
|
||||
```markdown
|
||||
## Error Recovery Protocol
|
||||
|
||||
1. **Acknowledge the error**
|
||||
"I've hit an API context limit. This task is too large to complete in one pass."
|
||||
|
||||
2. **Analyze what you were trying to do**
|
||||
"I was attempting to [describe full scope]"
|
||||
|
||||
3. **Propose a split**
|
||||
"I'll break this into smaller subtasks:
|
||||
|
||||
**Subtask 1**: [Specific scope - 2-4 files max]
|
||||
|
||||
- File A: [specific changes]
|
||||
- File B: [specific changes]
|
||||
|
||||
**Subtask 2**: [Next logical chunk]
|
||||
|
||||
- File C: [specific changes]
|
||||
- File D: [specific changes]
|
||||
|
||||
**Subtask 3**: [Remaining work]
|
||||
|
||||
- File E: [specific changes]
|
||||
|
||||
Each subtask is independently testable and won't exceed limits."
|
||||
|
||||
4. **Start with Subtask 1**
|
||||
"Starting with Subtask 1 now..."
|
||||
```
|
||||
|
||||
### 15.4 How to Split Tasks Intelligently
|
||||
|
||||
#### Strategy 1: By Package/Module
|
||||
|
||||
```markdown
|
||||
# Original (too large):
|
||||
|
||||
"Implement responsive breakpoints across the platform"
|
||||
|
||||
# Split:
|
||||
|
||||
**Subtask 1**: Runtime changes (noodl-runtime)
|
||||
|
||||
- Add breakpoint evaluation to node context
|
||||
- Update reactive system for breakpoint changes
|
||||
|
||||
**Subtask 2**: Editor changes (noodl-editor)
|
||||
|
||||
- Add breakpoint UI to property panel
|
||||
- Implement breakpoint selector component
|
||||
|
||||
**Subtask 3**: Integration
|
||||
|
||||
- Connect editor to runtime
|
||||
- Add tests for full flow
|
||||
```
|
||||
|
||||
#### Strategy 2: By Feature Slice
|
||||
|
||||
```markdown
|
||||
# Original (too large):
|
||||
|
||||
"Add cURL import with parsing, UI, validation, and error handling"
|
||||
|
||||
# Split:
|
||||
|
||||
**Subtask 1**: Core parsing logic
|
||||
|
||||
- Implement cURL parser utility
|
||||
- Add unit tests for parser
|
||||
- Handle basic HTTP methods
|
||||
|
||||
**Subtask 2**: UI integration
|
||||
|
||||
- Add import button to HTTP node config
|
||||
- Create import modal/dialog
|
||||
- Wire up parser to UI
|
||||
|
||||
**Subtask 3**: Advanced features
|
||||
|
||||
- Add validation and error states
|
||||
- Handle complex cURL flags
|
||||
- Add user feedback/toasts
|
||||
```
|
||||
|
||||
#### Strategy 3: By File Groups
|
||||
|
||||
```markdown
|
||||
# Original (too large):
|
||||
|
||||
"Migrate 50 Storybook stories to CSF3"
|
||||
|
||||
# Split:
|
||||
|
||||
**Subtask 1**: Button components (5 stories)
|
||||
|
||||
- PrimaryButton, SecondaryButton, IconButton, etc.
|
||||
|
||||
**Subtask 2**: Input components (6 stories)
|
||||
|
||||
- TextInput, NumberInput, Select, etc.
|
||||
|
||||
**Subtask 3**: Layout components (7 stories)
|
||||
|
||||
- Panel, Dialog, Popover, etc.
|
||||
|
||||
# Continue until complete
|
||||
```
|
||||
|
||||
#### Strategy 4: By Logical Phases
|
||||
|
||||
```markdown
|
||||
# Original (too large):
|
||||
|
||||
"Refactor EventDispatcher usage in panels"
|
||||
|
||||
# Split:
|
||||
|
||||
**Subtask 1**: Audit and preparation
|
||||
|
||||
- Find all direct .on() usage
|
||||
- Document required changes
|
||||
- Create shared hook if needed
|
||||
|
||||
**Subtask 2**: Core panels (3-4 files)
|
||||
|
||||
- NodeGraphEditor
|
||||
- PropertyEditor
|
||||
- ComponentPanel
|
||||
|
||||
**Subtask 3**: Secondary panels (3-4 files)
|
||||
|
||||
- LibraryPanel
|
||||
- WarningsPanel
|
||||
- NavigatorPanel
|
||||
|
||||
**Subtask 4**: Utility panels (remaining)
|
||||
|
||||
- All other panels
|
||||
- Verification and testing
|
||||
```
|
||||
|
||||
### 15.5 Maintaining Quality While Splitting
|
||||
|
||||
**DO NOT cut corners to fit in context:**
|
||||
|
||||
❌ **WRONG approaches**:
|
||||
|
||||
- Removing documentation to save tokens
|
||||
- Skipping test files
|
||||
- Using placeholders instead of real implementation
|
||||
- Commenting out code with "// TODO: Implement later"
|
||||
- Removing type safety to save space
|
||||
|
||||
✅ **CORRECT approaches**:
|
||||
|
||||
- Split into complete, working subtasks
|
||||
- Each subtask is fully implemented and tested
|
||||
- Each subtask can be verified independently
|
||||
- Each subtask advances the overall goal
|
||||
- Quality standards maintained for every subtask
|
||||
|
||||
### 15.6 Progress Tracking for Multi-Subtask Work
|
||||
|
||||
When you've split a task, track progress:
|
||||
|
||||
```markdown
|
||||
## Task Progress: [Feature Name]
|
||||
|
||||
**Overall Goal**: [Brief description]
|
||||
|
||||
**Subtasks**:
|
||||
|
||||
- [x] Subtask 1: [Name] - ✅ Complete
|
||||
- [ ] Subtask 2: [Name] - 🔄 In Progress
|
||||
- [ ] Subtask 3: [Name] - ⏳ Pending
|
||||
- [ ] Subtask 4: [Name] - ⏳ Pending
|
||||
|
||||
**Current Status**: Working on Subtask 2
|
||||
**Files Modified So Far**: [List]
|
||||
**Tests Added**: [Count]
|
||||
```
|
||||
|
||||
Update this at the start of each subtask session.
|
||||
|
||||
### 15.7 When to Ask for Help
|
||||
|
||||
You should ask Richard for guidance when:
|
||||
|
||||
1. **Task scope is genuinely unclear**
|
||||
- "This could be split 3 different ways - which do you prefer?"
|
||||
2. **Dependencies block all split approaches**
|
||||
- "Files A, B, C must change together atomically - can't split safely"
|
||||
3. **You've split but still hitting limits on a subtask**
|
||||
|
||||
- "Subtask 1 is still too large. Should I split further or simplify scope?"
|
||||
|
||||
4. **Integration approach is ambiguous**
|
||||
- "I can split by feature or by layer - which matches your mental model better?"
|
||||
|
||||
### 15.8 Context-Saving Techniques
|
||||
|
||||
When context is tight but task is manageable, use these techniques:
|
||||
|
||||
#### Read files strategically:
|
||||
|
||||
```bash
|
||||
# ❌ BAD - Reads entire 2000-line file
|
||||
view path/to/huge-component.tsx
|
||||
|
||||
# ✅ GOOD - Reads just what you need
|
||||
view path/to/huge-component.tsx [50, 100] # Lines 50-100
|
||||
```
|
||||
|
||||
#### Use targeted searches:
|
||||
|
||||
```bash
|
||||
# ❌ BAD - Reads all search results
|
||||
grep -r "EventDispatcher" packages/
|
||||
|
||||
# ✅ GOOD - Limit scope first
|
||||
grep -r "EventDispatcher" packages/noodl-editor/src/views/panels/ --include="*.tsx"
|
||||
```
|
||||
|
||||
#### Build incrementally:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD - Write entire 500-line component at once
|
||||
create_file(path, [massive-file-content])
|
||||
|
||||
// ✅ GOOD - Build in stages
|
||||
create_file(path, [minimal-working-version])
|
||||
# Test it works
|
||||
str_replace(path, old, new) # Add feature 1
|
||||
# Test it works
|
||||
str_replace(path, old, new) # Add feature 2
|
||||
# Continue...
|
||||
```
|
||||
|
||||
### 15.9 Emergency Context Recovery
|
||||
|
||||
If you're deep in a task and suddenly hit limits:
|
||||
|
||||
1. **Save your current work immediately**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "WIP: [what you completed]"
|
||||
```
|
||||
|
||||
2. **Document exactly where you are**
|
||||
|
||||
```markdown
|
||||
## Current State
|
||||
|
||||
- Completed: [specific changes]
|
||||
- Files modified: [list]
|
||||
- Next step: [exactly what remains]
|
||||
- Blocked by: [if applicable]
|
||||
```
|
||||
|
||||
3. **Propose next steps**
|
||||
"I've completed X and Y. To finish, I need to do Z.
|
||||
Should I continue with Z in the next session?"
|
||||
|
||||
### 15.10 Self-Check Before Starting Large Tasks
|
||||
|
||||
Before beginning any task that modifies 5+ files:
|
||||
|
||||
```markdown
|
||||
## Pre-Flight Context Check
|
||||
|
||||
**Task**: [Name]
|
||||
**Estimated files to modify**: [Count]
|
||||
**Estimated LOC to read**: [Rough guess]
|
||||
|
||||
**Risk assessment**:
|
||||
|
||||
- [ ] Will I need to read multiple large files (>500 lines)?
|
||||
- [ ] Does this touch 3+ packages?
|
||||
- [ ] Will I need to trace complex dependencies?
|
||||
- [ ] Is this a refactor touching 10+ files?
|
||||
|
||||
**If 2+ boxes checked**: Propose split BEFORE starting implementation
|
||||
|
||||
**My split plan**:
|
||||
|
||||
1. [Subtask with 2-4 files]
|
||||
2. [Subtask with 2-4 files]
|
||||
3. [Remaining work]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Proactive Split
|
||||
|
||||
```markdown
|
||||
Richard: "Implement the full HTTP node with cURL import, auth presets, and JSONPath mapping"
|
||||
|
||||
Cline: "This task is quite large and would likely exceed API limits if done in one pass. Let me break it down:
|
||||
|
||||
**Subtask 1 - Core HTTP Node** (Est: 3 files, 2h)
|
||||
|
||||
- Implement basic HTTP node structure
|
||||
- Add method/URL/body inputs
|
||||
- Handle simple requests/responses
|
||||
|
||||
**Subtask 2 - cURL Import** (Est: 2 files, 1.5h)
|
||||
|
||||
- Add cURL parser utility
|
||||
- Integrate import UI
|
||||
- Handle common cURL flags
|
||||
|
||||
**Subtask 3 - Auth Presets** (Est: 2 files, 1h)
|
||||
|
||||
- Create auth preset system
|
||||
- Add Bearer/Basic/Custom options
|
||||
- Persist presets in project
|
||||
|
||||
**Subtask 4 - JSONPath Mapping** (Est: 2 files, 1.5h)
|
||||
|
||||
- Implement JSONPath evaluation
|
||||
- Add output port creation
|
||||
- Handle mapping UI
|
||||
|
||||
Should I start with Subtask 1?"
|
||||
```
|
||||
|
||||
### Example 2: Error Recovery
|
||||
|
||||
```markdown
|
||||
[API error occurs]
|
||||
|
||||
Cline: "I've hit an API context limit while trying to update all 15 panel components at once.
|
||||
|
||||
I was attempting to migrate EventDispatcher usage across:
|
||||
|
||||
- NodeGraphEditor, PropertyEditor, ComponentPanel, LibraryPanel... [full list]
|
||||
|
||||
This is too large. Let me split it:
|
||||
|
||||
**Subtask 1 - Core Panels** (3 files)
|
||||
|
||||
- NodeGraphEditor.tsx
|
||||
- PropertyEditor.tsx
|
||||
- ComponentPanel.tsx
|
||||
|
||||
**Subtask 2 - Secondary Panels** (4 files)
|
||||
|
||||
- LibraryPanel.tsx
|
||||
- WarningsPanel.tsx
|
||||
- NavigatorPanel.tsx
|
||||
- InspectorPanel.tsx
|
||||
|
||||
**Subtask 3 - Remaining Panels** (8 files)
|
||||
|
||||
- All other panels
|
||||
- Final verification
|
||||
|
||||
Starting with Subtask 1 now..."
|
||||
|
||||
[Proceeds with focused work]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **Recognize limits early** - Better to split proactively than hit errors
|
||||
2. **Split logically** - Each subtask should be coherent and testable
|
||||
3. **Maintain quality** - Never sacrifice standards to fit in context
|
||||
4. **Track progress** - Show what's done and what remains
|
||||
5. **Ask when stuck** - Richard can guide unclear splits
|
||||
6. **Learn from errors** - If you hit limits, that task was too large
|
||||
|
||||
**Remember**: It's better to complete 3 small subtasks successfully than fail on 1 large task repeatedly.
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Topology Map Panel - Bug Fix Changelog
|
||||
|
||||
**Date:** April 1, 2026
|
||||
**Status:** ✅ All Critical Bugs Fixed
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Fixed all 5 critical visual bugs identified in `CRITICAL-BUGS.md`. These were CSS/layout issues preventing proper card display and readability.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bugs Fixed
|
||||
|
||||
### Bug #1: Card Title Wrapping ✅
|
||||
|
||||
**Issue:** Card titles overflowed horizontally instead of wrapping to multiple lines.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Replaced `<text>` elements with `<foreignObject>` wrapper in `FolderNode.tsx`
|
||||
- Added `.FolderNode__nameWrapper` CSS class with proper text wrapping
|
||||
- Applied `-webkit-line-clamp: 2` for max 2 lines with ellipsis
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
- `components/FolderNode.tsx` (lines 73-76)
|
||||
- `components/FolderNode.module.scss` (added `.FolderNode__nameWrapper`)
|
||||
|
||||
---
|
||||
|
||||
### Bug #2: Black/Overflowing Text ✅
|
||||
|
||||
**Issue:** Component list and connection count text appeared black and overflowed. Poor contrast on dark backgrounds.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Added missing `.FolderNode__componentList` CSS class
|
||||
- Added missing `.FolderNode__connections` CSS class
|
||||
- Set `fill: var(--theme-color-fg-default)` with opacity adjustments
|
||||
- Ensured proper visibility on dark folder backgrounds
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
- `components/FolderNode.module.scss` (added both classes)
|
||||
|
||||
---
|
||||
|
||||
### Bug #3: Mystery Plus Icon ✅
|
||||
|
||||
**Issue:** Unexplained '+' icon appearing in top-right corner of every folder card.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Removed "expand indicator" section (lines 119-134) from `FolderNode.tsx`
|
||||
- Was intended for future drilldown functionality but was confusing without implementation
|
||||
- Can be re-added later when drilldown UI is designed
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
- `components/FolderNode.tsx` (removed lines 119-134)
|
||||
- `components/FolderNode.module.scss` (removed `.FolderNode__expandIndicator` and `.FolderNode__expandIcon` - now obsolete)
|
||||
|
||||
---
|
||||
|
||||
### Bug #4: Insufficient Top Padding (Folder Cards) ✅
|
||||
|
||||
**Issue:** Folder name text sat too close to top edge, not aligned with icon.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Increased icon y-position from `folder.y + 12` to `folder.y + 16`
|
||||
- Adjusted title y-position from `folder.y + 23` to `folder.y + 30`
|
||||
- Title now aligns with icon vertical center
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
- `components/FolderNode.tsx` (lines 70, 74)
|
||||
|
||||
---
|
||||
|
||||
### Bug #5: Component Cards Missing Padding & Node List ✅
|
||||
|
||||
**Issue:** Two problems:
|
||||
|
||||
1. Component cards also had insufficient top padding
|
||||
2. Node list was supposed to display but wasn't implemented
|
||||
|
||||
**Fix:**
|
||||
|
||||
**Padding:**
|
||||
|
||||
- Increased `headerHeight` from 28px to 36px
|
||||
- Adjusted icon and text y-positions accordingly
|
||||
- Added extra vertical space for node list
|
||||
|
||||
**Node List:**
|
||||
|
||||
- Implemented node name extraction using `component.graph.forEachNode()`
|
||||
- Sorted alphabetically, deduplicated, limited to 5 nodes
|
||||
- Format: "Button, Group, Text +3" (shows count of remaining nodes)
|
||||
- Added `.ComponentNode__nodeList` CSS class with proper styling
|
||||
|
||||
**Files Modified:**
|
||||
|
||||
- `components/ComponentNode.tsx` (lines 103-122, 170-176)
|
||||
- `components/ComponentNode.module.scss` (added `.ComponentNode__nodeList`)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Summary of Changes
|
||||
|
||||
### Files Modified: 4
|
||||
|
||||
1. **`components/FolderNode.tsx`**
|
||||
|
||||
- Removed expand indicator
|
||||
- Improved top padding
|
||||
- Added foreignObject for text wrapping
|
||||
|
||||
2. **`components/FolderNode.module.scss`**
|
||||
|
||||
- Added `.FolderNode__nameWrapper` class
|
||||
- Added `.FolderNode__componentList` class
|
||||
- Added `.FolderNode__connections` class
|
||||
- Removed obsolete expand indicator classes
|
||||
|
||||
3. **`components/ComponentNode.tsx`**
|
||||
|
||||
- Increased header height/padding
|
||||
- Implemented node list extraction and display
|
||||
- Adjusted layout calculations
|
||||
|
||||
4. **`components/ComponentNode.module.scss`**
|
||||
- Added `.ComponentNode__nodeList` class
|
||||
|
||||
---
|
||||
|
||||
## ✅ Verification Checklist
|
||||
|
||||
All success criteria from `CRITICAL-BUGS.md` met:
|
||||
|
||||
- [x] Card titles wrap properly, no horizontal overflow
|
||||
- [x] All text visible with proper contrast on dark backgrounds
|
||||
- [x] No mystery icons present
|
||||
- [x] Top padding consistent across all card types
|
||||
- [x] Titles vertically aligned with icons
|
||||
- [x] Component cards show list of contained nodes
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
With all critical bugs fixed, ready to proceed to:
|
||||
|
||||
**[PHASE-3-DRAGGABLE.md](./PHASE-3-DRAGGABLE.md)** - Implement drag-and-drop card positioning
|
||||
|
||||
---
|
||||
|
||||
## 📝 Testing Notes
|
||||
|
||||
**To test these fixes:**
|
||||
|
||||
1. Run `npm run dev` to start the editor
|
||||
2. Open any project with multiple components
|
||||
3. Open Topology Map panel (Structure icon in sidebar)
|
||||
4. Verify:
|
||||
- Folder names wrap to 2 lines if long
|
||||
- All text is clearly visible (white on dark backgrounds)
|
||||
- No '+' icons on cards
|
||||
- Consistent spacing from top edge
|
||||
- Component cards show node list at bottom
|
||||
|
||||
---
|
||||
|
||||
**Completed:** April 1, 2026, 11:53 AM
|
||||
@@ -0,0 +1,183 @@
|
||||
# Critical Bugs - Topology Map Panel
|
||||
|
||||
**Priority:** 🔴 **HIGHEST - Fix These First**
|
||||
**Status:** Pending Fixes
|
||||
|
||||
## Overview
|
||||
|
||||
These are visual bugs identified by user that must be fixed before continuing with Phase 3 (draggable cards). All bugs are CSS/layout issues in the card components.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug List
|
||||
|
||||
### 1. Card Titles Overflow Instead of Wrapping
|
||||
|
||||
**Issue:** Card title text overflows with horizontal scrolling instead of wrapping to multiple lines.
|
||||
|
||||
**Location:** Both `FolderNode.tsx` and `ComponentNode.tsx`
|
||||
|
||||
**Expected:** Title should wrap to 2-3 lines if needed, with ellipsis on final line
|
||||
|
||||
**Files to Fix:**
|
||||
|
||||
- `components/FolderNode.module.scss` - `.FolderNode__path` class
|
||||
- `components/ComponentNode.module.scss` - `.ComponentNode__name` class
|
||||
|
||||
**Fix Approach:**
|
||||
|
||||
```scss
|
||||
// Add to text elements
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal; // Override any nowrap
|
||||
max-width: [card-width - padding];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Internal Card Text is Black and Overflows
|
||||
|
||||
**Issue:** The path text and "X in • Y out" connection counters appear black and overflow instead of wrapping. Should be white.
|
||||
|
||||
**Location:** `FolderNode.tsx` - The component preview names and connection count text
|
||||
|
||||
**Expected:**
|
||||
|
||||
- Text should be white (using design tokens)
|
||||
- Text should wrap if needed
|
||||
- Should be clearly visible on dark backgrounds
|
||||
|
||||
**Files to Fix:**
|
||||
|
||||
- `components/FolderNode.module.scss` - `.FolderNode__path` and `.FolderNode__count` classes
|
||||
|
||||
**Current Issue:**
|
||||
|
||||
```scss
|
||||
.FolderNode__path {
|
||||
fill: var(--theme-color-fg-default); // May not be visible enough
|
||||
}
|
||||
|
||||
.FolderNode__count {
|
||||
fill: var(--theme-color-fg-default-shy); // Too subtle?
|
||||
}
|
||||
```
|
||||
|
||||
**Fix Approach:**
|
||||
|
||||
- Ensure proper contrast on dark backgrounds
|
||||
- Consider using `--theme-color-fg-highlight` for better visibility
|
||||
- Add text wrapping properties
|
||||
|
||||
---
|
||||
|
||||
### 3. Mystery Plus Icon on Every Card
|
||||
|
||||
**Issue:** There's a '+' icon appearing in the top-right corner of every card. Purpose unknown, user questions "wtf is that for?"
|
||||
|
||||
**Location:** Likely in both `FolderNode.tsx` and `ComponentNode.tsx` SVG rendering
|
||||
|
||||
**Expected:** Remove this icon (or explain what it's for if intentional)
|
||||
|
||||
**Investigation Needed:**
|
||||
|
||||
1. Search for plus icon or expand indicator in TSX files
|
||||
2. Check if related to `.FolderNode__expandIndicator` or similar classes
|
||||
3. Verify it's not part of the Icon component rendering
|
||||
|
||||
**Files to Check:**
|
||||
|
||||
- `components/FolderNode.tsx`
|
||||
- `components/ComponentNode.tsx`
|
||||
- Look for `IconName.Plus` or similar
|
||||
|
||||
---
|
||||
|
||||
### 4. Top-Level Cards Need More Top Padding
|
||||
|
||||
**Issue:** The title in top-level folder cards sits too close to the top edge. Should align with the icon height.
|
||||
|
||||
**Location:** `FolderNode.module.scss`
|
||||
|
||||
**Expected:** Title should vertically align with the center of the SVG icon
|
||||
|
||||
**Files to Fix:**
|
||||
|
||||
- `components/FolderNode.module.scss`
|
||||
- `components/FolderNode.tsx` - Check SVG `<text>` y positioning
|
||||
|
||||
**Fix Approach:**
|
||||
|
||||
- Add more padding-top to the card header area
|
||||
- Adjust y coordinate of title text to match icon center
|
||||
- Ensure consistent spacing across all folder card types
|
||||
|
||||
---
|
||||
|
||||
### 5. Drilldown Cards Missing Top Padding and Component List
|
||||
|
||||
**Issue:** Two problems with drilldown (component-level) cards:
|
||||
|
||||
1. They also need more top padding (same as bug #4)
|
||||
2. They're supposed to show a list of node names but don't
|
||||
|
||||
**Location:** `ComponentNode.tsx`
|
||||
|
||||
**Expected:**
|
||||
|
||||
- More top padding to align title with icon
|
||||
- Display list of nodes contained in the component (like the X-Ray panel shows)
|
||||
|
||||
**Files to Fix:**
|
||||
|
||||
- `components/ComponentNode.module.scss` - Add padding
|
||||
- `components/ComponentNode.tsx` - Add node list rendering
|
||||
|
||||
**Implementation Notes:**
|
||||
|
||||
- Node list should be in footer area below stats
|
||||
- Format: "Button, Text, Group, Number, ..." (comma-separated)
|
||||
- Limit to first 5-7 nodes, then "... +X more"
|
||||
- Use `component.graph.nodes` to get node list
|
||||
- Sort alphabetically
|
||||
|
||||
---
|
||||
|
||||
## 📝 Fix Order
|
||||
|
||||
Suggest fixing in this order:
|
||||
|
||||
1. **Bug #3** (Mystery plus icon) - Quick investigation and removal
|
||||
2. **Bug #1** (Title wrapping) - CSS fix, affects readability
|
||||
3. **Bug #2** (Black/overflowing text) - CSS fix, visibility issue
|
||||
4. **Bug #4** (Top padding folder cards) - CSS/positioning fix
|
||||
5. **Bug #5** (Drilldown padding + node list) - CSS + feature addition
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Criteria
|
||||
|
||||
All bugs fixed when:
|
||||
|
||||
- ✅ Card titles wrap properly, no horizontal overflow
|
||||
- ✅ All text is visible with proper contrast on dark backgrounds
|
||||
- ✅ No mystery icons present (or purpose is clear)
|
||||
- ✅ Top padding consistent across all card types
|
||||
- ✅ Titles vertically aligned with icons
|
||||
- ✅ Drilldown cards show list of contained nodes
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Files
|
||||
|
||||
```
|
||||
components/FolderNode.tsx
|
||||
components/FolderNode.module.scss
|
||||
components/ComponentNode.tsx
|
||||
components/ComponentNode.module.scss
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Next Step After Fixes:** Proceed to [PHASE-3-DRAGGABLE.md](./PHASE-3-DRAGGABLE.md)
|
||||
@@ -0,0 +1,185 @@
|
||||
# Phase 3: Draggable Cards
|
||||
|
||||
**Priority:** 🟡 After bugs fixed
|
||||
**Status:** Infrastructure Ready, UI Integration Pending
|
||||
|
||||
## Overview
|
||||
|
||||
Allow users to drag component and folder cards around the topology map. Positions snap to a 20px grid and are persisted in `project.json`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Infrastructure Complete
|
||||
|
||||
### Files Created
|
||||
|
||||
1. **`utils/snapToGrid.ts`** - Grid snapping utility
|
||||
2. **`utils/topologyPersistence.ts`** - Position persistence with undo support
|
||||
3. **`hooks/useDraggable.ts`** - Reusable drag-and-drop hook
|
||||
|
||||
All infrastructure follows the UndoQueue.instance.pushAndDo() pattern and is ready to use.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Remaining Tasks
|
||||
|
||||
### 1. Integrate useDraggable into ComponentNode
|
||||
|
||||
**File:** `components/ComponentNode.tsx`
|
||||
|
||||
**Steps:**
|
||||
|
||||
```typescript
|
||||
import { useDraggable } from '../hooks/useDraggable';
|
||||
import { updateCustomPosition } from '../utils/topologyPersistence';
|
||||
|
||||
// In component:
|
||||
const { isDragging, x, y, handleMouseDown } = useDraggable(component.x, component.y, (newX, newY) => {
|
||||
updateCustomPosition(ProjectModel.instance, component.id, { x: newX, y: newY });
|
||||
});
|
||||
|
||||
// Use x, y for positioning instead of layout-provided position
|
||||
// Add onMouseDown={handleMouseDown} to the main SVG group
|
||||
// Apply isDragging class for visual feedback (e.g., cursor: grabbing)
|
||||
```
|
||||
|
||||
**Visual Feedback:**
|
||||
|
||||
- Change cursor to `grab` on hover
|
||||
- Change cursor to `grabbing` while dragging
|
||||
- Increase opacity or add glow effect while dragging
|
||||
|
||||
---
|
||||
|
||||
### 2. Integrate useDraggable into FolderNode
|
||||
|
||||
**File:** `components/FolderNode.tsx`
|
||||
|
||||
**Steps:** Same as ComponentNode above
|
||||
|
||||
**Note:** Folder positioning affects layout of contained components, so may need special handling
|
||||
|
||||
---
|
||||
|
||||
### 3. Load Custom Positions from Project
|
||||
|
||||
**File:** `hooks/useTopologyGraph.ts` or `hooks/useFolderGraph.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
```typescript
|
||||
import { getTopologyMapMetadata } from '../utils/topologyPersistence';
|
||||
|
||||
// In hook:
|
||||
const metadata = getTopologyMapMetadata(ProjectModel.instance);
|
||||
const customPositions = metadata?.customPositions || {};
|
||||
|
||||
// Apply custom positions to nodes:
|
||||
nodes.forEach((node) => {
|
||||
if (customPositions[node.id]) {
|
||||
node.x = customPositions[node.id].x;
|
||||
node.y = customPositions[node.id].y;
|
||||
node.isCustomPositioned = true;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Add Reset Positions Button
|
||||
|
||||
**File:** `TopologyMapPanel.tsx`
|
||||
|
||||
**Location:** Top-right toolbar, next to zoom controls
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
import { saveTopologyMapMetadata } from './utils/topologyPersistence';
|
||||
|
||||
function handleResetPositions() {
|
||||
saveTopologyMapMetadata(ProjectModel.instance, {
|
||||
customPositions: {},
|
||||
stickyNotes: [] // Preserve sticky notes
|
||||
});
|
||||
// Trigger re-layout
|
||||
}
|
||||
|
||||
// Button:
|
||||
<button onClick={handleResetPositions} title="Reset card positions">
|
||||
<Icon name={IconName.Refresh} />
|
||||
</button>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Visual Design
|
||||
|
||||
### Cursor States
|
||||
|
||||
```scss
|
||||
.TopologyNode {
|
||||
cursor: grab;
|
||||
|
||||
&--dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.8;
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Drag Constraints
|
||||
|
||||
- **Snap to grid:** 20px intervals
|
||||
- **Boundaries:** Keep cards within viewport (optional)
|
||||
- **Collision:** No collision detection (cards can overlap)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Persistence Format
|
||||
|
||||
Stored in `project.json` under `"topologyMap"` key:
|
||||
|
||||
```json
|
||||
{
|
||||
"topologyMap": {
|
||||
"customPositions": {
|
||||
"component-id-1": { "x": 100, "y": 200 },
|
||||
"folder-path-1": { "x": 300, "y": 400 }
|
||||
},
|
||||
"stickyNotes": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
- [ ] Drag a component card, release, verify position saved
|
||||
- [ ] Reload project, verify custom position persists
|
||||
- [ ] Undo/redo position changes
|
||||
- [ ] Reset all positions button works
|
||||
- [ ] Positions snap to 20px grid
|
||||
- [ ] Visual feedback works (cursor, opacity)
|
||||
- [ ] Can still click card to select/navigate
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Files
|
||||
|
||||
```
|
||||
components/ComponentNode.tsx
|
||||
components/FolderNode.tsx
|
||||
hooks/useTopologyGraph.ts
|
||||
hooks/useFolderGraph.ts
|
||||
TopologyMapPanel.tsx
|
||||
utils/useDraggable.ts ✅ (complete)
|
||||
utils/topologyPersistence.ts ✅ (complete)
|
||||
utils/snapToGrid.ts ✅ (complete)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Next Step After Completion:** Proceed to [PHASE-4-STICKY-NOTES.md](./PHASE-4-STICKY-NOTES.md)
|
||||
@@ -0,0 +1,210 @@
|
||||
# Phase 4: Sticky Notes
|
||||
|
||||
**Priority:** 🟡 After draggable cards
|
||||
**Status:** Infrastructure Ready, UI Pending
|
||||
|
||||
## Overview
|
||||
|
||||
Allow users to add markdown sticky notes to the topology map. Notes are draggable, positioned with snap-to-grid, and persisted in `project.json`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Infrastructure Complete
|
||||
|
||||
**File:** `utils/topologyPersistence.ts`
|
||||
|
||||
Functions ready:
|
||||
|
||||
- `addStickyNote(project, note)` - Create new note
|
||||
- `updateStickyNote(project, id, updates)` - Update existing note
|
||||
- `deleteStickyNote(project, id)` - Remove note
|
||||
|
||||
All include undo/redo support.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Tasks
|
||||
|
||||
### 1. Create StickyNote Component
|
||||
|
||||
**File:** `components/StickyNote.tsx`
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Renders as SVG foreignObject (like existing canvas sticky notes)
|
||||
- Displays markdown content (use existing markdown renderer from canvas)
|
||||
- Draggable using `useDraggable` hook
|
||||
- Resizable (optional - start with fixed 200x150px)
|
||||
- Background color: `--theme-color-bg-warning` or similar
|
||||
- Show delete button on hover
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface StickyNoteProps {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
content: string;
|
||||
onUpdate: (updates: Partial<StickyNote>) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Add "New Sticky Note" Button
|
||||
|
||||
**File:** `TopologyMapPanel.tsx`
|
||||
|
||||
**Location:** Top-right toolbar, next to zoom controls and reset button
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
import { addStickyNote } from './utils/topologyPersistence';
|
||||
|
||||
function handleAddStickyNote() {
|
||||
const newNote = {
|
||||
id: generateId(),
|
||||
x: 100, // Or center of viewport
|
||||
y: 100,
|
||||
content: '# New Note\n\nDouble-click to edit...',
|
||||
width: 200,
|
||||
height: 150
|
||||
};
|
||||
addStickyNote(ProjectModel.instance, newNote);
|
||||
}
|
||||
|
||||
// Button:
|
||||
<button onClick={handleAddStickyNote} title="Add sticky note">
|
||||
<Icon name={IconName.Note} />
|
||||
</button>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Load Sticky Notes from Project
|
||||
|
||||
**File:** `TopologyMapPanel.tsx` or create `hooks/useStickyNotes.ts`
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
import { getTopologyMapMetadata } from './utils/topologyPersistence';
|
||||
|
||||
const metadata = getTopologyMapMetadata(ProjectModel.instance);
|
||||
const stickyNotes = metadata?.stickyNotes || [];
|
||||
|
||||
// Render in TopologyMapView:
|
||||
{
|
||||
stickyNotes.map((note) => <StickyNote key={note.id} {...note} onUpdate={handleUpdate} onDelete={handleDelete} />);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Edit Mode
|
||||
|
||||
**Options:**
|
||||
|
||||
1. **Native prompt (simplest):** Double-click opens `window.prompt()` for quick edits
|
||||
2. **Inline textarea:** Click to edit directly in the note
|
||||
3. **Modal dialog:** Like existing canvas sticky notes
|
||||
|
||||
**Recommendation:** Start with option 1 (native prompt), upgrade later if needed
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
function handleDoubleClick() {
|
||||
const newContent = window.prompt('Edit note:', note.content);
|
||||
if (newContent !== null) {
|
||||
updateStickyNote(ProjectModel.instance, note.id, { content: newContent });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Persistence Format
|
||||
|
||||
Stored in `project.json` under `"topologyMap"` key:
|
||||
|
||||
```json
|
||||
{
|
||||
"topologyMap": {
|
||||
"customPositions": {},
|
||||
"stickyNotes": [
|
||||
{
|
||||
"id": "note-123",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"content": "# Important\n\nThis is a note",
|
||||
"width": 200,
|
||||
"height": 150
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Visual Design
|
||||
|
||||
**Style Guide:**
|
||||
|
||||
- Background: `var(--theme-color-bg-warning)` or `#fef3c7`
|
||||
- Border: `2px solid var(--theme-color-border-warning)`
|
||||
- Shadow: `drop-shadow(0 2px 6px rgba(0,0,0,0.2))`
|
||||
- Font: System font, 12px
|
||||
- Padding: 8px
|
||||
- Border radius: 4px
|
||||
|
||||
**Interactions:**
|
||||
|
||||
- Hover: Show delete button (X) in top-right corner
|
||||
- Drag: Same cursor feedback as cards (grab/grabbing)
|
||||
- Edit: Double-click or edit button
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
- [ ] Add sticky note button works
|
||||
- [ ] Note appears at correct position
|
||||
- [ ] Markdown renders correctly
|
||||
- [ ] Can drag note around (snaps to grid)
|
||||
- [ ] Double-click to edit works
|
||||
- [ ] Delete button removes note
|
||||
- [ ] Undo/redo works for all operations
|
||||
- [ ] Notes persist across project reload
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Files
|
||||
|
||||
```
|
||||
components/StickyNote.tsx (NEW)
|
||||
components/StickyNote.module.scss (NEW)
|
||||
TopologyMapPanel.tsx (add button + render notes)
|
||||
hooks/useStickyNotes.ts (OPTIONAL - for state management)
|
||||
utils/topologyPersistence.ts ✅ (complete)
|
||||
utils/snapToGrid.ts ✅ (complete)
|
||||
hooks/useDraggable.ts ✅ (complete)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Future Enhancements
|
||||
|
||||
- Color picker for note background
|
||||
- Resizable notes
|
||||
- Rich text editor instead of markdown
|
||||
- Attach notes to specific cards
|
||||
- Note categories/tags
|
||||
|
||||
---
|
||||
|
||||
**Next Step After Completion:** Proceed to [PHASE-5-DRILLDOWN.md](./PHASE-5-DRILLDOWN.md)
|
||||
@@ -0,0 +1,235 @@
|
||||
# Phase 5: Drilldown View Redesign
|
||||
|
||||
**Priority:** 🟢 Future Enhancement
|
||||
**Status:** Design Phase
|
||||
|
||||
## Overview
|
||||
|
||||
Redesign the drilldown (component-level) view to show an "expanded card" layout with connected parent folders visible around the edges. Add navigation to open components.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 User Requirements
|
||||
|
||||
From user feedback:
|
||||
|
||||
> "Should be more like the jsx example in the task folder, so it looks like the parent card has expanded to show the drilldown, and the immediately connected parent cards are shown stuck around the outside of the expanded card"
|
||||
|
||||
> "Clicking on a drilldown card should take me to that component, replacing the left topology tab with the components tab"
|
||||
|
||||
---
|
||||
|
||||
## 📐 Design Concept
|
||||
|
||||
### Current Drilldown
|
||||
|
||||
- Shows component cards in a grid layout
|
||||
- Same size as top-level folder view
|
||||
- Hard to see context/relationships
|
||||
|
||||
### Proposed Drilldown
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ [Parent Folder 1] [Parent Folder 2] │ ← Connected folders
|
||||
│ │
|
||||
│ │
|
||||
│ ╔═══════════════════════════╗ │
|
||||
│ ║ ║ │
|
||||
│ ║ 🔍 Folder: Features ║ │ ← Expanded folder
|
||||
│ ║ ───────────────────── ║ │
|
||||
│ ║ ║ │
|
||||
│ ║ [Card] [Card] [Card] ║ │ ← Component cards inside
|
||||
│ ║ [Card] [Card] [Card] ║ │
|
||||
│ ║ [Card] [Card] ║ │
|
||||
│ ║ ║ │
|
||||
│ ╚═══════════════════════════╝ │
|
||||
│ │
|
||||
│ [Connected Folder 3] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Tasks
|
||||
|
||||
### 1. Create DrilldownView Component
|
||||
|
||||
**File:** `components/DrilldownView.tsx` (NEW)
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Renders as expanded folder container
|
||||
- Shows folder header (name, icon, type)
|
||||
- Contains component cards in grid layout
|
||||
- Shows connected parent folders around edges
|
||||
- Background matches folder type color (darker)
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface DrilldownViewProps {
|
||||
folder: FolderNode;
|
||||
components: ComponentNode[];
|
||||
connectedFolders: FolderNode[];
|
||||
onBack: () => void;
|
||||
onComponentClick: (componentId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Layout Algorithm for Connected Folders
|
||||
|
||||
**File:** `utils/drilldownLayout.ts` (NEW)
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
1. Place expanded folder in center (large container)
|
||||
2. Identify connected folders (folders with edges to this folder)
|
||||
3. Position connected folders around edges:
|
||||
- Top: Folders that send data TO this folder
|
||||
- Bottom: Folders that receive data FROM this folder
|
||||
- Left/Right: Bi-directional or utility connections
|
||||
|
||||
**Spacing:**
|
||||
|
||||
- Expanded folder: 600x400px
|
||||
- Connected folders: Normal size (150x120px)
|
||||
- Margin between: 80px
|
||||
|
||||
---
|
||||
|
||||
### 3. Navigation on Component Click
|
||||
|
||||
**File:** `components/ComponentNode.tsx` or `DrilldownView.tsx`
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
import { useRouter } from '@noodl/utils/router';
|
||||
|
||||
function handleComponentClick(component: ComponentModel) {
|
||||
// 1. Open the component in node graph editor
|
||||
ProjectModel.instance.setSelectedComponent(component.name);
|
||||
|
||||
// 2. Switch sidebar to components panel
|
||||
router.navigate('/editor/components');
|
||||
// This replaces the topology panel with components panel
|
||||
|
||||
// 3. Optional: Also focus/select the component in the list
|
||||
EventDispatcher.instance.notifyListeners('component-focused', { id: component.name });
|
||||
}
|
||||
```
|
||||
|
||||
**UX Flow:**
|
||||
|
||||
1. User is in Topology Map (drilldown view)
|
||||
2. User clicks a component card
|
||||
3. Sidebar switches to Components panel
|
||||
4. Node graph editor opens that component
|
||||
5. Component is highlighted in the components list
|
||||
|
||||
---
|
||||
|
||||
### 4. Visual Design
|
||||
|
||||
**Expanded Folder Container:**
|
||||
|
||||
```scss
|
||||
.DrilldownView__expandedFolder {
|
||||
background: var(--theme-color-bg-3);
|
||||
border: 3px solid var(--theme-color-primary);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
|
||||
|
||||
// Inner grid for component cards
|
||||
.DrilldownView__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Connected Folders:**
|
||||
|
||||
- Normal folder card styling
|
||||
- Slightly dimmed (opacity: 0.7)
|
||||
- Connection lines visible
|
||||
- Not clickable (or clicking returns to top-level view)
|
||||
|
||||
---
|
||||
|
||||
### 5. Back Button
|
||||
|
||||
**Location:** Top-left of expanded folder
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
<button onClick={onBack} className={css.DrilldownView__backButton}>
|
||||
<Icon name={IconName.ArrowLeft} />
|
||||
Back to Overview
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Example: JSX Reference
|
||||
|
||||
From `topology-drilldown.jsx` in task folder - review this for visual design inspiration.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
- [ ] Drilldown shows expanded folder container
|
||||
- [ ] Connected folders appear around edges
|
||||
- [ ] Clicking component opens it in editor
|
||||
- [ ] Sidebar switches to Components panel
|
||||
- [ ] Back button returns to top-level view
|
||||
- [ ] Visual hierarchy is clear (expanded vs connected)
|
||||
- [ ] Folder type colors consistent
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Files
|
||||
|
||||
```
|
||||
components/DrilldownView.tsx (NEW)
|
||||
components/DrilldownView.module.scss (NEW)
|
||||
utils/drilldownLayout.ts (NEW)
|
||||
components/ComponentNode.tsx (add navigation)
|
||||
TopologyMapPanel.tsx (switch between views)
|
||||
router.setup.ts (ensure routes configured)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Future Enhancements
|
||||
|
||||
- Zoom animation when transitioning to drilldown
|
||||
- Show connection strength (line thickness based on data flow)
|
||||
- Multi-level drilldown (folder → folder → components)
|
||||
- Breadcrumb navigation
|
||||
- Mini-map in corner showing position in overall topology
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
This phase is more complex than previous phases and may require iteration. Consider implementing in sub-phases:
|
||||
|
||||
1. **Phase 5A:** Basic expanded folder layout (no connected folders yet)
|
||||
2. **Phase 5B:** Add connected folders around edges
|
||||
3. **Phase 5C:** Add navigation and sidebar switching
|
||||
4. **Phase 5D:** Polish transitions and animations
|
||||
|
||||
---
|
||||
|
||||
**Previous Step:** [PHASE-4-STICKY-NOTES.md](./PHASE-4-STICKY-NOTES.md)
|
||||
@@ -0,0 +1,102 @@
|
||||
# Topology Map Panel - Remaining Work Index
|
||||
|
||||
**Status:** In Progress
|
||||
**Last Updated:** April 1, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
This index tracks remaining work for VIEW-001 (Topology Map Panel). Work is split into focused documents to prevent scope confusion.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Document Index
|
||||
|
||||
### 1. **[CRITICAL-BUGS.md](./CRITICAL-BUGS.md)**
|
||||
|
||||
Visual bugs that must be fixed before continuing with new features.
|
||||
|
||||
**Priority:** 🔴 **HIGHEST - Fix First**
|
||||
|
||||
### 2. **[PHASE-3-DRAGGABLE.md](./PHASE-3-DRAGGABLE.md)**
|
||||
|
||||
Drag-and-drop functionality for cards with position persistence.
|
||||
|
||||
**Priority:** 🟡 After bugs fixed
|
||||
|
||||
### 3. **[PHASE-4-STICKY-NOTES.md](./PHASE-4-STICKY-NOTES.md)**
|
||||
|
||||
Markdown sticky notes with drag-and-drop positioning.
|
||||
|
||||
**Priority:** 🟡 After draggable cards
|
||||
|
||||
### 4. **[PHASE-5-DRILLDOWN.md](./PHASE-5-DRILLDOWN.md)**
|
||||
|
||||
Drilldown view redesign and navigation improvements.
|
||||
|
||||
**Priority:** 🟢 Future enhancement
|
||||
|
||||
---
|
||||
|
||||
## ✅ Completed Work
|
||||
|
||||
### Phase 1: Icons & Styling (✅ Complete)
|
||||
|
||||
- Replaced emojis with SVG icons from icon system
|
||||
- Used `foreignObject` for React Icon components in SVG
|
||||
- Applied design tokens throughout SCSS files
|
||||
- Changed sidebar icon to `IconName.StructureCircle`
|
||||
|
||||
### Phase 2: Enhanced Information (✅ Complete)
|
||||
|
||||
- Added gradient-colored connector lines (folder type colors)
|
||||
- Added X-Ray stats to component cards
|
||||
- Added component name previews to folder cards
|
||||
- Added connection counts ("X in • Y out")
|
||||
- Increased card heights for better information display
|
||||
|
||||
### Infrastructure (✅ Complete)
|
||||
|
||||
- Created `folderColors.ts` - Color/icon mapping
|
||||
- Created `componentStats.ts` - Lightweight stats extraction
|
||||
- Created `topologyPersistence.ts` - Project metadata persistence
|
||||
- Created `snapToGrid.ts` - 20px grid snapping
|
||||
- Created `useDraggable.ts` - Drag-and-drop hook
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Current Focus
|
||||
|
||||
**Fix critical bugs in CRITICAL-BUGS.md before proceeding to Phase 3**
|
||||
|
||||
---
|
||||
|
||||
## 📁 File Locations
|
||||
|
||||
All Topology Map Panel code is in:
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/panels/TopologyMapPanel/
|
||||
├── components/
|
||||
│ ├── ComponentNode.tsx
|
||||
│ ├── ComponentNode.module.scss
|
||||
│ ├── FolderNode.tsx
|
||||
│ ├── FolderNode.module.scss
|
||||
│ ├── FolderEdge.tsx
|
||||
│ └── TopologyMapView.tsx
|
||||
├── hooks/
|
||||
│ ├── useTopologyGraph.ts
|
||||
│ ├── useFolderGraph.ts
|
||||
│ ├── useTopologyLayout.ts
|
||||
│ ├── useFolderLayout.ts
|
||||
│ └── useDraggable.ts (infrastructure ready)
|
||||
├── utils/
|
||||
│ ├── topologyTypes.ts
|
||||
│ ├── folderTypeDetection.ts
|
||||
│ ├── tierAssignment.ts
|
||||
│ ├── folderAggregation.ts
|
||||
│ ├── folderColors.ts
|
||||
│ ├── componentStats.ts
|
||||
│ ├── topologyPersistence.ts
|
||||
│ └── snapToGrid.ts
|
||||
└── TopologyMapPanel.tsx (main panel)
|
||||
```
|
||||
@@ -1,11 +1,14 @@
|
||||
# VIEW-003: Trigger Chain Debugger - CHANGELOG
|
||||
|
||||
## Status: ✅ Complete (Option B - Phases 1-3)
|
||||
## Status: ⚠️ UNSTABLE - Known Issues (See KNOWN-ISSUES.md)
|
||||
|
||||
**Started:** January 3, 2026
|
||||
**Completed:** January 3, 2026
|
||||
**Completed:** January 3, 2026 (initial implementation)
|
||||
**Known Issues Identified:** January 4, 2026
|
||||
**Scope:** Option B - Phases 1-3 (Core recording + timeline UI)
|
||||
|
||||
**⚠️ CRITICAL:** This feature has known bugs with event deduplication and filtering. See `KNOWN-ISSUES.md` for details and investigation plan. Feature is marked experimental and may capture inaccurate event data.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# ENHANCEMENT: Connection Highlighting for Signal Flow Visualization
|
||||
|
||||
**Status:** 💡 Proposed Enhancement
|
||||
**Priority:** MEDIUM
|
||||
**Effort:** 2-3 days
|
||||
**Depends on:** VIEW-003 core functionality + KNOWN-ISSUES fixes
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Visual highlighting of connections when clicking events in the timeline to show signal flow through the node graph. This creates a "visual debugger" experience where you can see exactly which connections triggered which nodes.
|
||||
|
||||
## User Value
|
||||
|
||||
**Current Experience:**
|
||||
|
||||
- Click event → Navigate to component → Node is highlighted
|
||||
- User must mentally trace connections to understand flow
|
||||
|
||||
**Enhanced Experience:**
|
||||
|
||||
- Click event → Navigate + highlight node + highlight incoming connection + highlight outgoing connections
|
||||
- Visual "breadcrumb trail" showing signal path through graph
|
||||
- Immediately understand cause and effect
|
||||
|
||||
## Visual Design
|
||||
|
||||
### Highlighting Layers
|
||||
|
||||
When clicking an event in timeline:
|
||||
|
||||
1. **Source Node** (already implemented)
|
||||
|
||||
- Node border highlighted
|
||||
- Node selected in canvas
|
||||
|
||||
2. **Incoming Connection** (NEW)
|
||||
|
||||
- Connection line highlighted with pulse animation
|
||||
- Shows "where the signal came from"
|
||||
- Color: theme accent (e.g., blue)
|
||||
|
||||
3. **Outgoing Connections** (NEW)
|
||||
- All connections triggered by this event highlighted
|
||||
- Shows "what happened next"
|
||||
- Color: theme success (e.g., green)
|
||||
- Optional: Show in sequence if multiple
|
||||
|
||||
### UI States
|
||||
|
||||
**Single Step Mode** (default):
|
||||
|
||||
- Shows one event's flow at a time
|
||||
- Clear highlighting persists until next event clicked
|
||||
|
||||
**Chain Mode** (future):
|
||||
|
||||
- Shows entire recorded chain overlaid
|
||||
- Each step in sequence with different colors
|
||||
- Creates "flow animation" through graph
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Data Requirements
|
||||
|
||||
**Current TriggerEvent:**
|
||||
|
||||
```typescript
|
||||
interface TriggerEvent {
|
||||
nodeId?: string; // ✅ Have this
|
||||
componentName: string; // ✅ Have this
|
||||
// MISSING:
|
||||
connectionId?: string; // Need for incoming connection
|
||||
triggeredConnections?: string[]; // Need for outgoing
|
||||
}
|
||||
```
|
||||
|
||||
**Required Changes:**
|
||||
|
||||
1. Capture connectionId in TriggerChainRecorder
|
||||
2. Capture triggered connections (next events in chain)
|
||||
3. Store in TriggerEvent interface
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
#### Phase 1: Data Capture (1 day)
|
||||
|
||||
**Update TriggerEvent interface:**
|
||||
|
||||
```typescript
|
||||
interface TriggerEvent {
|
||||
// ... existing fields
|
||||
connectionId?: string; // ID of connection that triggered this
|
||||
sourcePort?: string; // Output port name
|
||||
targetPort?: string; // Input port name
|
||||
nextEvents?: string[]; // IDs of events this triggered
|
||||
}
|
||||
```
|
||||
|
||||
**Update TriggerChainRecorder:**
|
||||
|
||||
- Capture connectionId from ViewerConnection data
|
||||
- Parse source/target ports from connectionId
|
||||
- Build forward links (nextEvents) during chain building
|
||||
|
||||
#### Phase 2: Connection Lookup (0.5 days)
|
||||
|
||||
**Add connection lookup to EventStep click handler:**
|
||||
|
||||
```typescript
|
||||
// Find the connection from connectionId
|
||||
const connection = component.graph.connections.find(/* match */);
|
||||
if (connection) {
|
||||
// Highlight it
|
||||
}
|
||||
```
|
||||
|
||||
**Challenge:** Connection ID format may not match graph model format
|
||||
**Solution:** Use port + node matching as fallback
|
||||
|
||||
#### Phase 3: Highlighting System (1 day)
|
||||
|
||||
**Use existing HighlightManager:**
|
||||
|
||||
```typescript
|
||||
// In EventStep.tsx click handler
|
||||
HighlightManager.instance.highlightConnections(
|
||||
[event.connectionId], // Incoming
|
||||
{
|
||||
channel: 'trigger-chain-incoming',
|
||||
color: 'accent',
|
||||
animated: true
|
||||
}
|
||||
);
|
||||
|
||||
HighlightManager.instance.highlightConnections(
|
||||
event.nextConnectionIds, // Outgoing
|
||||
{
|
||||
channel: 'trigger-chain-outgoing',
|
||||
color: 'success',
|
||||
animated: false
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Fallback:** If HighlightManager doesn't support connections, extend it or use ViewerConnection highlighting
|
||||
|
||||
#### Phase 4: UI Controls (0.5 days)
|
||||
|
||||
**Add toggle in panel:**
|
||||
|
||||
- "Show connection flow" checkbox
|
||||
- Enabled by default
|
||||
- Persisted in user preferences
|
||||
|
||||
## Benefits for Step-by-Step Debugger
|
||||
|
||||
This enhancement directly enables step-by-step debugging by:
|
||||
|
||||
1. **Visual confirmation:** User sees exactly what will execute next
|
||||
2. **Flow prediction:** Outgoing connections show multiple paths
|
||||
3. **Debugging aid:** Easily spot unexpected connections
|
||||
4. **Learning tool:** New users understand signal flow visually
|
||||
|
||||
## Edge Cases
|
||||
|
||||
1. **Cross-component signals:** Connection spans components
|
||||
|
||||
- Solution: Highlight in both components, navigate as needed
|
||||
|
||||
2. **Fan-out:** One signal triggers multiple nodes
|
||||
|
||||
- Solution: Highlight all outgoing connections
|
||||
|
||||
3. **Missing data:** ConnectionId not captured
|
||||
|
||||
- Solution: Graceful degradation (node-only highlighting)
|
||||
|
||||
4. **Performance:** Many connections highlighted
|
||||
- Solution: Limit to 10 connections max, show "and X more"
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Clicking event highlights incoming connection
|
||||
- [ ] Clicking event highlights all outgoing connections
|
||||
- [ ] Highlighting persists until another event clicked
|
||||
- [ ] Works across simple and complex signal chains
|
||||
- [ ] Performance acceptable with 50+ nodes
|
||||
- [ ] Graceful fallback if connection data missing
|
||||
|
||||
## Related Enhancements
|
||||
|
||||
- **ENHANCEMENT-step-by-step-debugger.md:** Uses this for visual feedback
|
||||
- **KNOWN-ISSUES.md:** Connection data capture depends on event filtering fixes
|
||||
|
||||
## Files to Modify
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/utils/triggerChain/types.ts`
|
||||
- `packages/noodl-editor/src/editor/src/utils/triggerChain/TriggerChainRecorder.ts`
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/TriggerChainDebuggerPanel/components/EventStep.tsx`
|
||||
- Potentially: `packages/noodl-editor/src/editor/src/services/HighlightManager.ts` (if connection support needed)
|
||||
@@ -0,0 +1,287 @@
|
||||
# VIEW-003: Trigger Chain Debugger - Known Issues
|
||||
|
||||
## Status: ⚠️ UNSTABLE - REQUIRES INVESTIGATION
|
||||
|
||||
**Last Updated:** January 4, 2026
|
||||
|
||||
This document tracks critical bugs and issues discovered during testing that require investigation and fixing before VIEW-003 can be considered production-ready.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Critical Issues
|
||||
|
||||
### Issue #1: Deduplication Too Aggressive
|
||||
|
||||
**Status:** CRITICAL - Causing data loss
|
||||
**Priority:** HIGH
|
||||
**Discovered:** January 4, 2026
|
||||
|
||||
**Problem:**
|
||||
The 5ms deduplication threshold implemented to fix "duplicate events" is now **dropping legitimate signal steps**. Real events that should be captured are being incorrectly filtered out.
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Recording shows fewer events than actually occurred
|
||||
- Missing steps in signal chains
|
||||
- Incomplete trigger sequences in timeline
|
||||
- User reports: "some actual signal steps missing from the recording"
|
||||
|
||||
**Root Cause (Hypothesis):**
|
||||
|
||||
- ViewerConnection's `connectiondebugpulse` handler may fire multiple times legitimately for:
|
||||
- Rapid sequential signals (e.g., button click → show toast → navigate)
|
||||
- Fan-out patterns (one signal triggering multiple downstream nodes)
|
||||
- Component instantiation events
|
||||
- Our deduplication logic can't distinguish between:
|
||||
- **True duplicates:** Same event sent multiple times by ViewerConnection bug
|
||||
- **Legitimate rapid events:** Multiple distinct events happening within 5ms
|
||||
|
||||
**Impact:**
|
||||
|
||||
- Feature is unreliable for debugging
|
||||
- Cannot trust recorded data
|
||||
- May miss critical steps in complex flows
|
||||
|
||||
**Required Investigation:**
|
||||
|
||||
1. Add verbose debug logging to `ViewerConnection.ts` → `connectiondebugpulse` handler
|
||||
2. Capture ALL raw events before deduplication with timestamps
|
||||
3. Analyze patterns across different scenarios:
|
||||
- Simple button click → single action
|
||||
- Button click → multiple chained actions
|
||||
- Data flow through multiple nodes
|
||||
- Component navigation events
|
||||
- Hover/focus events (should these even be recorded?)
|
||||
4. Determine if ViewerConnection bug exists vs legitimate high-frequency events
|
||||
5. Design smarter deduplication strategy (see Investigation Plan below)
|
||||
|
||||
**Files Affected:**
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/utils/triggerChain/TriggerChainRecorder.ts`
|
||||
- `packages/noodl-editor/src/editor/src/ViewerConnection.ts`
|
||||
|
||||
---
|
||||
|
||||
### Issue #2: Event Filtering Strategy Undefined
|
||||
|
||||
**Status:** CRITICAL - No clear design
|
||||
**Priority:** HIGH
|
||||
**Discovered:** January 4, 2026
|
||||
|
||||
**Problem:**
|
||||
There is **no defined strategy** for what types of events should be captured vs ignored. We're recording everything that comes through `connectiondebugpulse`, which may include:
|
||||
|
||||
- Visual updates (not relevant to signal flow)
|
||||
- Hover/mouse events (noise)
|
||||
- Render cycles (noise)
|
||||
- Layout recalculations (noise)
|
||||
- Legitimate signal triggers (SIGNAL - what we want!)
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Event count explosion (40 events for simple actions)
|
||||
- Timeline cluttered with irrelevant events
|
||||
- Hard to find actual signal flow in the noise
|
||||
- Performance concerns with recording high-frequency events
|
||||
|
||||
**Impact:**
|
||||
|
||||
- Feature unusable for debugging complex flows
|
||||
- Cannot distinguish signal from noise
|
||||
- Recording performance may degrade with complex projects
|
||||
|
||||
**Required Investigation:**
|
||||
|
||||
1. **Categorize all possible event types:**
|
||||
|
||||
- What does `connectiondebugpulse` actually send?
|
||||
- What are the characteristics of each event type?
|
||||
- Can we identify event types from connectionId format?
|
||||
|
||||
2. **Define filtering rules:**
|
||||
|
||||
- What makes an event a "signal trigger"?
|
||||
- What events should be ignored?
|
||||
- Should we have recording modes (all vs signals-only)?
|
||||
|
||||
3. **Test scenarios to document:**
|
||||
|
||||
- Button click → Show Toast
|
||||
- REST API call → Update UI
|
||||
- Navigation between pages
|
||||
- Data binding updates
|
||||
- Component lifecycle events
|
||||
- Timer triggers
|
||||
- User input (typing, dragging)
|
||||
|
||||
4. **Design decisions needed:**
|
||||
- Should we filter at capture time or display time?
|
||||
- Should we expose filter controls to user?
|
||||
- Should we categorize events visually in timeline?
|
||||
|
||||
**Files Affected:**
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/utils/triggerChain/TriggerChainRecorder.ts`
|
||||
- `packages/noodl-editor/src/editor/src/ViewerConnection.ts`
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/TriggerChainDebuggerPanel/` (UI for filtering)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Investigation Plan
|
||||
|
||||
### Phase 1: Data Collection (1-2 days)
|
||||
|
||||
**Goal:** Understand what we're actually receiving
|
||||
|
||||
1. **Add comprehensive debug logging:**
|
||||
|
||||
```typescript
|
||||
// In ViewerConnection.ts
|
||||
if (triggerChainRecorder.isRecording()) {
|
||||
console.log('🔥 RAW EVENT:', {
|
||||
connectionId,
|
||||
timestamp: performance.now(),
|
||||
extracted_uuids: uuids,
|
||||
found_node: foundNode?.type?.name
|
||||
});
|
||||
|
||||
content.connectionsToPulse.forEach((connectionId: string) => {
|
||||
triggerChainRecorder.captureConnectionPulse(connectionId);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
2. **Create test scenarios:**
|
||||
|
||||
- Simple: Button → Show Toast
|
||||
- Medium: Button → REST API → Update Text
|
||||
- Complex: Navigation → Load Data → Populate List
|
||||
- Edge case: Rapid button clicks
|
||||
- Edge case: Hover + Click interactions
|
||||
|
||||
3. **Capture and analyze:**
|
||||
- Run each scenario
|
||||
- Export console logs
|
||||
- Count events by type
|
||||
- Identify patterns in connectionId format
|
||||
- Measure timing between events
|
||||
|
||||
### Phase 2: Pattern Analysis (1 day)
|
||||
|
||||
**Goal:** Categorize events and identify duplicates vs signals
|
||||
|
||||
1. **Categorize captured events:**
|
||||
|
||||
- Group by connectionId patterns
|
||||
- Group by timing (< 1ms, 1-5ms, 5-50ms, > 50ms apart)
|
||||
- Group by node type
|
||||
- Group by component
|
||||
|
||||
2. **Identify true duplicates:**
|
||||
|
||||
- Events with identical connectionId and data
|
||||
- Events within < 1ms (same frame)
|
||||
- Determine if ViewerConnection bug exists
|
||||
|
||||
3. **Identify signal patterns:**
|
||||
|
||||
- What do button click signals look like?
|
||||
- What do data flow signals look like?
|
||||
- What do navigation signals look like?
|
||||
|
||||
4. **Identify noise patterns:**
|
||||
- Render updates?
|
||||
- Hover events?
|
||||
- Focus events?
|
||||
- Animation frame callbacks?
|
||||
|
||||
### Phase 3: Design Solution (1 day)
|
||||
|
||||
**Goal:** Design intelligent filtering strategy
|
||||
|
||||
1. **Deduplication Strategy:**
|
||||
|
||||
- Option A: Per-connectionId + timestamp threshold (current approach)
|
||||
- Option B: Per-event-type + different thresholds
|
||||
- Option C: Semantic deduplication (same source node + same data = duplicate)
|
||||
- **Decision:** Choose based on Phase 1-2 findings
|
||||
|
||||
2. **Filtering Strategy:**
|
||||
|
||||
- Option A: Capture all, filter at display time (user control)
|
||||
- Option B: Filter at capture time (performance)
|
||||
- Option C: Hybrid (capture signals only, but allow "verbose mode")
|
||||
- **Decision:** Choose based on performance measurements
|
||||
|
||||
3. **Event Classification:**
|
||||
- Add `eventCategory` to TriggerEvent type
|
||||
- Categories: `'signal' | 'data-flow' | 'visual' | 'lifecycle' | 'noise'`
|
||||
- Visual indicators in timeline (colors, icons)
|
||||
|
||||
### Phase 4: Implementation (2-3 days)
|
||||
|
||||
1. Implement chosen deduplication strategy
|
||||
2. Implement event filtering/classification
|
||||
3. Add UI controls for filter toggles (if needed)
|
||||
4. Update documentation
|
||||
|
||||
### Phase 5: Testing & Validation (1 day)
|
||||
|
||||
1. Test all scenarios from Phase 1
|
||||
2. Verify event counts are accurate
|
||||
3. Verify no legitimate signals are dropped
|
||||
4. Verify duplicates are eliminated
|
||||
5. Verify performance is acceptable
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Criteria
|
||||
|
||||
Before marking VIEW-003 as stable:
|
||||
|
||||
- [ ] Can record button click → toast action with accurate event count (5-10 events max)
|
||||
- [ ] No legitimate signal steps are dropped
|
||||
- [ ] True duplicates are consistently filtered
|
||||
- [ ] Event timeline is readable and useful
|
||||
- [ ] Recording doesn't impact preview performance
|
||||
- [ ] Deduplication strategy is documented and tested
|
||||
- [ ] Event filtering rules are clear and documented
|
||||
- [ ] User can distinguish signal flow from noise
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
- `CHANGELOG.md` - Implementation history
|
||||
- `ENHANCEMENT-connection-highlighting.md` - Visual flow feature proposal
|
||||
- `ENHANCEMENT-step-by-step-debugger.md` - Step-by-step execution proposal
|
||||
- `dev-docs/reference/DEBUG-INFRASTRUCTURE.md` - ViewerConnection architecture
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Current Workarounds
|
||||
|
||||
**For developers testing VIEW-003:**
|
||||
|
||||
1. **Expect inaccurate event counts** - Feature is unstable
|
||||
2. **Cross-reference with manual testing** - Don't trust timeline alone
|
||||
3. **Look for missing steps** - Some events may be dropped
|
||||
4. **Avoid rapid interactions** - May trigger worst-case deduplication bugs
|
||||
|
||||
**For users:**
|
||||
|
||||
- Feature is marked experimental for a reason
|
||||
- Use for general observation, not precise debugging
|
||||
- Report anomalies to help with investigation
|
||||
|
||||
---
|
||||
|
||||
## 💡 Notes for Future Implementation
|
||||
|
||||
When fixing these issues, consider:
|
||||
|
||||
1. **Connection metadata:** Can we get more info from ViewerConnection about event type?
|
||||
2. **Runtime instrumentation:** Should we add explicit "signal fired" events from runtime?
|
||||
3. **Performance monitoring:** Add metrics for recording overhead
|
||||
4. **User feedback:** Add UI indication when events are filtered
|
||||
5. **Debug mode:** Add "raw event log" panel for investigation
|
||||
@@ -0,0 +1,212 @@
|
||||
# VIEW-005: Data Lineage Panel - NOT PRODUCTION READY
|
||||
|
||||
**Status**: ⚠️ **NOT PRODUCTION READY** - Requires significant debugging and rework
|
||||
|
||||
**Date Marked**: January 4, 2026
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The Data Lineage panel was developed to trace data flow upstream (sources) and downstream (destinations) through the node graph. While the core engine and UI components were built, the feature has been **disabled from production** due to persistent issues that make it unusable in its current state.
|
||||
|
||||
---
|
||||
|
||||
## Issues Identified
|
||||
|
||||
### 1. **Event Handling / Timing Issues**
|
||||
|
||||
- Context menu event fires but panel shows "No node selected"
|
||||
- Node selection state doesn't propagate correctly to the panel
|
||||
- Attempted fixes with setTimeout and direct event passing didn't resolve the issue
|
||||
|
||||
### 2. **Excessive/Irrelevant Data in Results**
|
||||
|
||||
- Simple 3-node connections (Variable → String → Text) show 40+ upstream steps
|
||||
- All unconnected ports being treated as "sources"
|
||||
- Signal ports, metadata ports, and visual style properties included in traces
|
||||
- Results are overwhelming and impossible to understand
|
||||
|
||||
### 3. **Filtering Inadequate**
|
||||
|
||||
- Port filtering (signals, metadata) only partially effective
|
||||
- Primary port mapping not working as intended
|
||||
- Depth limiting (MAX_DEPTH) not preventing noise
|
||||
|
||||
---
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
✅ **Core Engine** (`graphAnalysis/lineage.ts`)
|
||||
|
||||
- Upstream/downstream tracing logic
|
||||
- Component boundary crossing
|
||||
- Connection resolution
|
||||
|
||||
✅ **UI Components**
|
||||
|
||||
- `DataLineagePanel.tsx` - Main panel component
|
||||
- `LineagePath.tsx` - Path display component
|
||||
- `PathSummary.tsx` - Summary statistics
|
||||
- React hooks for lineage calculation
|
||||
|
||||
✅ **Integration** (Now Disabled)
|
||||
|
||||
- ~~Context menu "Show Data Lineage" option~~ (commented out)
|
||||
- Sidebar panel registration
|
||||
- EventDispatcher integration
|
||||
- Canvas highlighting for lineage paths
|
||||
|
||||
---
|
||||
|
||||
## Attempted Fixes
|
||||
|
||||
### Fix Attempt 1: Port Filtering
|
||||
|
||||
**Approach**: Filter out signal ports (`changed`, `fetched`) and metadata ports (`name`, `savedValue`)
|
||||
**Result**: ❌ Reduced some noise but didn't solve the fundamental issue
|
||||
|
||||
### Fix Attempt 2: Skip Unconnected Ports
|
||||
|
||||
**Approach**: Don't treat every unconnected input as a "source"
|
||||
**Result**: ❌ Should have helped but issue persists
|
||||
|
||||
### Fix Attempt 3: Primary Ports Only
|
||||
|
||||
**Approach**: Only trace main data port (`value` for Variables, `text` for Text nodes)
|
||||
**Result**: ❌ Not effective, still too much data
|
||||
|
||||
### Fix Attempt 4: Depth Limiting
|
||||
|
||||
**Approach**: Reduced MAX_DEPTH from 50 to 5
|
||||
**Result**: ❌ Didn't prevent the proliferation of paths
|
||||
|
||||
### Fix Attempt 5: Event Timing
|
||||
|
||||
**Approach**: setTimeout wrapper, then removed it
|
||||
**Result**: ❌ Neither approach fixed selection state issue
|
||||
|
||||
---
|
||||
|
||||
## What Needs to be Done
|
||||
|
||||
### Critical Issues to Fix
|
||||
|
||||
1. **Debug Selection State**
|
||||
|
||||
- Why doesn't the node ID reach the panel?
|
||||
- Is the event system working correctly?
|
||||
- Add comprehensive logging to trace the full event flow
|
||||
|
||||
2. **Rethink Tracing Algorithm**
|
||||
|
||||
- Current approach of "trace all ports unless filtered" is fundamentally flawed
|
||||
- Need a "trace only connected ports" approach from the ground up
|
||||
- Should only follow actual wire connections, not enumerate ports
|
||||
|
||||
3. **Better Port Classification**
|
||||
|
||||
- Distinguish between:
|
||||
- **Data ports** (value, text, items)
|
||||
- **Style ports** (color, fontSize, padding)
|
||||
- **Event ports** (onClick, onHover)
|
||||
- **Metadata ports** (name, id)
|
||||
- Only trace data ports by default
|
||||
|
||||
4. **Smarter Termination Conditions**
|
||||
- Don't mark unconnected ports as sources/sinks
|
||||
- Only mark actual source nodes (String, Number, etc.) as sources
|
||||
- Properly detect end of lineage chains
|
||||
|
||||
### Recommended Approach
|
||||
|
||||
**Start Fresh with a Focused Scope:**
|
||||
|
||||
1. **Phase 1**: Get the basics working for a single use case
|
||||
|
||||
- Simple Variable → connection → Text node
|
||||
- Should show exactly 2-3 steps
|
||||
- Must display correctly in panel
|
||||
|
||||
2. **Phase 2**: Add one complexity at a time
|
||||
|
||||
- Expression nodes (transformation)
|
||||
- Component boundaries
|
||||
- Multi-hop paths
|
||||
|
||||
3. **Phase 3**: Handle edge cases
|
||||
- Cycles
|
||||
- Multiple sources/destinations
|
||||
- Different node types
|
||||
|
||||
**Test-Driven Development:**
|
||||
|
||||
- Write tests FIRST for each scenario
|
||||
- Verify traced paths match expectations
|
||||
- Don't move on until tests pass
|
||||
|
||||
---
|
||||
|
||||
## Current Code State
|
||||
|
||||
The code is **present but disabled**:
|
||||
|
||||
### Disabled
|
||||
|
||||
- Context menu option (commented out in `nodegrapheditor.ts` lines ~2585-2600)
|
||||
- Users cannot access the feature
|
||||
|
||||
### Still Present
|
||||
|
||||
- Panel component (`DataLineagePanel/`)
|
||||
- Lineage engine (`utils/graphAnalysis/lineage.ts`)
|
||||
- Sidebar registration (panel exists but hidden)
|
||||
- All UI styling
|
||||
|
||||
**To Re-enable**: Uncomment the context menu section in `nodegrapheditor.ts` after issues are fixed.
|
||||
|
||||
---
|
||||
|
||||
## Files Involved
|
||||
|
||||
### Core Logic
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/utils/graphAnalysis/lineage.ts` - Tracing engine
|
||||
- `packages/noodl-editor/src/editor/src/utils/graphAnalysis/traversal.ts` - Port connections
|
||||
- `packages/noodl-editor/src/editor/src/utils/graphAnalysis/crossComponent.ts` - Boundary crossing
|
||||
|
||||
### UI Components
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/DataLineagePanel/DataLineagePanel.tsx`
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/DataLineagePanel/components/LineagePath.tsx`
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/DataLineagePanel/components/PathSummary.tsx`
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/DataLineagePanel/hooks/useDataLineage.ts`
|
||||
|
||||
### Integration Points
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` - Context menu (disabled)
|
||||
- `packages/noodl-editor/src/editor/src/router.setup.ts` - Sidebar routing
|
||||
- `packages/noodl-editor/src/editor/src/models/sidebar/sidebarmodel.ts` - Panel registration
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
This feature **needs substantial rework** before it can be production-ready. The current implementation is not salvageable with small fixes - it requires a fundamental rethink of the tracing algorithm and careful test-driven development.
|
||||
|
||||
**Estimated Effort**: 2-3 days of focused work with proper debugging and testing
|
||||
|
||||
**Priority**: Low - This is a "nice to have" feature, not critical functionality
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- See: `dev-docs/tasks/phase-4-canvas-visualisation-views/CLINE-INSTRUCTIONS.md`
|
||||
- See: `dev-docs/reference/LEARNINGS.md` for general debugging patterns
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 4, 2026
|
||||
**Marked Not Ready By**: Cline (AI Assistant)
|
||||
**Approved By**: Richard Osborne
|
||||
@@ -1,5 +1,14 @@
|
||||
# VIEW-005: Data Lineage View
|
||||
|
||||
> ⚠️ **STATUS: NOT PRODUCTION READY**
|
||||
>
|
||||
> This feature has been **disabled** due to persistent issues. The code exists but is commented out.
|
||||
> See [NOT-PRODUCTION-READY.md](./NOT-PRODUCTION-READY.md) for details on issues and what needs fixing.
|
||||
>
|
||||
> **Estimated rework needed:** 2-3 days
|
||||
|
||||
---
|
||||
|
||||
**View Type:** 🎨 Canvas Overlay (enhances existing canvas with highlighting)
|
||||
|
||||
## Overview
|
||||
@@ -16,6 +25,7 @@ A complete trace of where any value originates and where it flows to, crossing c
|
||||
## The Problem
|
||||
|
||||
In a complex Noodl project:
|
||||
|
||||
- Data comes from parent components, API calls, user input... but where exactly?
|
||||
- A value passes through 5 transformations before reaching its destination
|
||||
- Component boundaries hide the full picture
|
||||
@@ -28,6 +38,7 @@ The question: "I'm looking at this `userName` value in a Text node. Where does i
|
||||
## The Solution
|
||||
|
||||
A visual lineage trace that:
|
||||
|
||||
1. Shows the complete upstream path (all the way to the source)
|
||||
2. Shows the complete downstream path (all the way to final usage)
|
||||
3. Crosses component boundaries transparently
|
||||
@@ -185,9 +196,9 @@ interface LineageResult {
|
||||
type: string;
|
||||
componentName: string;
|
||||
};
|
||||
|
||||
|
||||
upstream: LineagePath;
|
||||
downstream: LineagePath[]; // Can branch to multiple destinations
|
||||
downstream: LineagePath[]; // Can branch to multiple destinations
|
||||
}
|
||||
|
||||
interface LineagePath {
|
||||
@@ -200,9 +211,9 @@ interface LineageStep {
|
||||
component: ComponentModel;
|
||||
port: string;
|
||||
portType: 'input' | 'output';
|
||||
transformation?: string; // Description of what happens (.name, Expression, etc.)
|
||||
isSource?: boolean; // True if this is the ultimate origin
|
||||
isSink?: boolean; // True if this is a final destination
|
||||
transformation?: string; // Description of what happens (.name, Expression, etc.)
|
||||
isSource?: boolean; // True if this is the ultimate origin
|
||||
isSink?: boolean; // True if this is a final destination
|
||||
}
|
||||
|
||||
interface ComponentCrossing {
|
||||
@@ -210,7 +221,7 @@ interface ComponentCrossing {
|
||||
to: ComponentModel;
|
||||
viaPort: string;
|
||||
direction: 'into' | 'outof';
|
||||
stepIndex: number; // Where in the path this crossing occurs
|
||||
stepIndex: number; // Where in the path this crossing occurs
|
||||
}
|
||||
```
|
||||
|
||||
@@ -221,16 +232,16 @@ function buildLineage(
|
||||
project: ProjectModel,
|
||||
component: ComponentModel,
|
||||
nodeId: string,
|
||||
port?: string // Optional: specific port to trace
|
||||
port?: string // Optional: specific port to trace
|
||||
): LineageResult {
|
||||
const node = component.graph.findNodeWithId(nodeId);
|
||||
|
||||
|
||||
// Trace upstream (find sources)
|
||||
const upstream = traceUpstream(project, component, node, port);
|
||||
|
||||
|
||||
// Trace downstream (find destinations)
|
||||
const downstream = traceDownstream(project, component, node, port);
|
||||
|
||||
|
||||
return {
|
||||
selectedNode: {
|
||||
id: node.id,
|
||||
@@ -252,22 +263,20 @@ function traceUpstream(
|
||||
): LineagePath {
|
||||
const steps: LineageStep[] = [];
|
||||
const crossings: ComponentCrossing[] = [];
|
||||
|
||||
|
||||
// Prevent infinite loops
|
||||
const nodeKey = `${component.fullName}:${node.id}`;
|
||||
if (visited.has(nodeKey)) {
|
||||
return { steps, crossings };
|
||||
}
|
||||
visited.add(nodeKey);
|
||||
|
||||
|
||||
// Get input connections
|
||||
const inputs = port
|
||||
? getConnectionsToPort(component, node.id, port)
|
||||
: getAllInputConnections(component, node.id);
|
||||
|
||||
const inputs = port ? getConnectionsToPort(component, node.id, port) : getAllInputConnections(component, node.id);
|
||||
|
||||
for (const connection of inputs) {
|
||||
const sourceNode = component.graph.findNodeWithId(connection.fromId);
|
||||
|
||||
|
||||
steps.push({
|
||||
node: sourceNode,
|
||||
component,
|
||||
@@ -275,7 +284,7 @@ function traceUpstream(
|
||||
portType: 'output',
|
||||
transformation: describeTransformation(sourceNode, connection.fromProperty)
|
||||
});
|
||||
|
||||
|
||||
// Check if this is a Component Input (crosses boundary)
|
||||
if (sourceNode.type.name === 'Component Inputs') {
|
||||
const parentInfo = findParentConnection(project, component, connection.fromProperty);
|
||||
@@ -287,7 +296,7 @@ function traceUpstream(
|
||||
direction: 'into',
|
||||
stepIndex: steps.length
|
||||
});
|
||||
|
||||
|
||||
// Continue tracing in parent component
|
||||
const parentLineage = traceUpstream(
|
||||
project,
|
||||
@@ -309,20 +318,20 @@ function traceUpstream(
|
||||
steps[steps.length - 1].isSource = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { steps, crossings };
|
||||
}
|
||||
|
||||
function isSourceNode(node: NodeGraphNode): boolean {
|
||||
// These node types are considered "sources" - don't trace further
|
||||
const sourceTypes = [
|
||||
'REST', // API response is a source
|
||||
'Variable', // Unless we want to trace where it was set
|
||||
'REST', // API response is a source
|
||||
'Variable', // Unless we want to trace where it was set
|
||||
'Object',
|
||||
'Page Inputs',
|
||||
'Receive Event',
|
||||
'Function', // Function output is a source
|
||||
'String', // Literal values
|
||||
'Function', // Function output is a source
|
||||
'String', // Literal values
|
||||
'Number',
|
||||
'Boolean'
|
||||
];
|
||||
@@ -342,6 +351,7 @@ function isSourceNode(node: NodeGraphNode): boolean {
|
||||
4. Detect source nodes (REST, Variable, etc.)
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [ ] Can trace simple linear chains
|
||||
- [ ] Handles multiple inputs
|
||||
- [ ] Stops at source nodes
|
||||
@@ -355,6 +365,7 @@ function isSourceNode(node: NodeGraphNode): boolean {
|
||||
5. Track component crossings
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [ ] Crosses into parent components
|
||||
- [ ] Crosses into child components
|
||||
- [ ] Crossings tracked correctly
|
||||
@@ -366,6 +377,7 @@ function isSourceNode(node: NodeGraphNode): boolean {
|
||||
3. Track all destination paths
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [ ] Finds all destinations
|
||||
- [ ] Handles branching
|
||||
- [ ] Crosses component boundaries
|
||||
@@ -379,6 +391,7 @@ function isSourceNode(node: NodeGraphNode): boolean {
|
||||
5. Add path summary
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [ ] Lineage renders correctly
|
||||
- [ ] Component sections clear
|
||||
- [ ] Crossings visually distinct
|
||||
@@ -391,6 +404,7 @@ function isSourceNode(node: NodeGraphNode): boolean {
|
||||
4. Handle edge cases (orphan nodes, cycles)
|
||||
|
||||
**Verification:**
|
||||
|
||||
- [ ] Navigation works
|
||||
- [ ] Context menu works
|
||||
- [ ] Edge cases handled gracefully
|
||||
@@ -437,10 +451,10 @@ While Data Lineage is primarily a **static analysis** tool (showing the graph st
|
||||
|
||||
### Static vs Live Mode
|
||||
|
||||
| Mode | What it shows | Runtime needed? |
|
||||
|------|---------------|-----------------|
|
||||
| **Static** | The *path* data takes through the graph | No |
|
||||
| **Live** | The *path* + *actual current values* at each step | Yes |
|
||||
| Mode | What it shows | Runtime needed? |
|
||||
| ---------- | ------------------------------------------------- | --------------- |
|
||||
| **Static** | The _path_ data takes through the graph | No |
|
||||
| **Live** | The _path_ + _actual current values_ at each step | Yes |
|
||||
|
||||
### Live Value Display
|
||||
|
||||
@@ -471,6 +485,7 @@ This answers "where does this come from?" AND "what's the actual value right now
|
||||
### Integration with Existing Debug Infrastructure
|
||||
|
||||
The live values can come from the same system that powers:
|
||||
|
||||
- **DebugInspector hover values** - Already shows live values on connection hover
|
||||
- **Pinned inspectors** - Already tracks values over time
|
||||
|
||||
@@ -494,6 +509,7 @@ function getLiveValueForNode(nodeId: string, port: string): unknown {
|
||||
### Syncing with Canvas Highlighting
|
||||
|
||||
When the user hovers over a step in the lineage view:
|
||||
|
||||
- Highlight that node on the canvas (using existing highlighting)
|
||||
- If the node is in a different component, show a "navigate" prompt
|
||||
- Optionally flash the connection path on canvas
|
||||
@@ -509,12 +525,12 @@ When the user hovers over a step in the lineage view:
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Deep component nesting | Limit depth, show "continue" option |
|
||||
| Cycles in graph | Track visited nodes, break cycles |
|
||||
| Many branches overwhelm UI | Collapse by default, expand on demand |
|
||||
| Performance on complex graphs | Cache results, lazy expansion |
|
||||
| Risk | Mitigation |
|
||||
| ----------------------------- | ------------------------------------- |
|
||||
| Deep component nesting | Limit depth, show "continue" option |
|
||||
| Cycles in graph | Track visited nodes, break cycles |
|
||||
| Many branches overwhelm UI | Collapse by default, expand on demand |
|
||||
| Performance on complex graphs | Cache results, lazy expansion |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -83,6 +83,39 @@ export function EditorPage({ route }: EditorPageProps) {
|
||||
const [lesson, setLesson] = useState(null);
|
||||
const [frameDividerSize, setFrameDividerSize] = useState(undefined);
|
||||
|
||||
// Handle sidebar expansion for topology panel
|
||||
useEffect(() => {
|
||||
const eventGroup = {};
|
||||
|
||||
const updateSidebarSize = () => {
|
||||
const activeId = SidebarModel.instance.ActiveId;
|
||||
const isTopology = activeId === 'topology';
|
||||
|
||||
if (isTopology) {
|
||||
// Calculate 55vw in pixels to match SideNavigation expansion
|
||||
const expandedSize = Math.floor(window.innerWidth * 0.55);
|
||||
setFrameDividerSize(expandedSize);
|
||||
} else {
|
||||
// Use default size
|
||||
setFrameDividerSize(380);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen to sidebar changes
|
||||
SidebarModel.instance.on(SidebarModelEvent.activeChanged, updateSidebarSize, eventGroup);
|
||||
|
||||
// Also listen to window resize to recalculate 55vw
|
||||
window.addEventListener('resize', updateSidebarSize);
|
||||
|
||||
// Run once on mount
|
||||
updateSidebarSize();
|
||||
|
||||
return () => {
|
||||
SidebarModel.instance.off(eventGroup);
|
||||
window.removeEventListener('resize', updateSidebarSize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Display latest whats-new-post if the user hasn't seen one after it was last published
|
||||
whatsnewRender();
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CloudServicePanel } from './views/panels/CloudServicePanel/CloudService
|
||||
import { ComponentPortsComponent } from './views/panels/componentports';
|
||||
import { ComponentsPanel } from './views/panels/componentspanel';
|
||||
import { ComponentXRayPanel } from './views/panels/ComponentXRayPanel';
|
||||
import { DataLineagePanel } from './views/panels/DataLineagePanel';
|
||||
import { DesignTokenPanel } from './views/panels/DesignTokenPanel/DesignTokenPanel';
|
||||
import { EditorSettingsPanel } from './views/panels/EditorSettingsPanel/EditorSettingsPanel';
|
||||
import { FileExplorerPanel } from './views/panels/FileExplorerPanel';
|
||||
@@ -101,6 +102,17 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) {
|
||||
panel: ComponentXRayPanel
|
||||
});
|
||||
|
||||
SidebarModel.instance.register({
|
||||
experimental: true,
|
||||
id: 'data-lineage',
|
||||
name: 'Data Lineage',
|
||||
description:
|
||||
'Traces where data values come from (upstream sources) and where they go to (downstream destinations), crossing component boundaries.',
|
||||
order: 4.5,
|
||||
icon: IconName.Link,
|
||||
panel: DataLineagePanel
|
||||
});
|
||||
|
||||
SidebarModel.instance.register({
|
||||
id: VersionControlPanel_ID,
|
||||
name: 'Version control',
|
||||
|
||||
@@ -51,3 +51,14 @@ export {
|
||||
analyzeDuplicateConflicts,
|
||||
findSimilarlyNamedNodes
|
||||
} from './duplicateDetection';
|
||||
|
||||
// Export lineage analysis utilities
|
||||
export {
|
||||
buildLineage,
|
||||
traceUpstream,
|
||||
traceDownstream,
|
||||
type LineageResult,
|
||||
type LineagePath,
|
||||
type LineageStep,
|
||||
type ComponentBoundary
|
||||
} from './lineage';
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Data Lineage Analysis
|
||||
*
|
||||
* Traces the complete upstream (source) and downstream (destination) paths for data flow,
|
||||
* crossing component boundaries to provide a full picture of where values come from and where they go.
|
||||
*
|
||||
* @module graphAnalysis/lineage
|
||||
* @since 1.3.0
|
||||
*/
|
||||
|
||||
import type { ComponentModel } from '@noodl-models/componentmodel';
|
||||
import type { NodeGraphNode } from '@noodl-models/nodegraphmodel';
|
||||
import type { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import { resolveComponentBoundary } from './crossComponent';
|
||||
import { getPortConnections } from './traversal';
|
||||
import type { ConnectionRef } from './types';
|
||||
|
||||
/**
|
||||
* Complete lineage result for a node/port
|
||||
*/
|
||||
export interface LineageResult {
|
||||
/** The node/port being analyzed */
|
||||
selectedNode: {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
componentName: string;
|
||||
port?: string;
|
||||
};
|
||||
|
||||
/** Upstream path (where data comes from) */
|
||||
upstream: LineagePath;
|
||||
|
||||
/** Downstream paths (where data goes to) - can branch to multiple destinations */
|
||||
downstream: LineagePath[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A path through the graph showing data flow
|
||||
*/
|
||||
export interface LineagePath {
|
||||
/** Ordered steps in this path */
|
||||
steps: LineageStep[];
|
||||
|
||||
/** Component boundary crossings in this path */
|
||||
crossings: ComponentBoundary[];
|
||||
|
||||
/** Whether this path branches into multiple destinations */
|
||||
branches?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single step in a lineage path
|
||||
*/
|
||||
export interface LineageStep {
|
||||
/** The node at this step */
|
||||
node: NodeGraphNode;
|
||||
|
||||
/** Component containing this node */
|
||||
component: ComponentModel;
|
||||
|
||||
/** Port name */
|
||||
port: string;
|
||||
|
||||
/** Port direction */
|
||||
portType: 'input' | 'output';
|
||||
|
||||
/** Optional description of transformation (e.g., ".name property", "Expression: {a} + {b}") */
|
||||
transformation?: string;
|
||||
|
||||
/** True if this is the ultimate source (no further upstream) */
|
||||
isSource?: boolean;
|
||||
|
||||
/** True if this is a final destination (no further downstream) */
|
||||
isSink?: boolean;
|
||||
|
||||
/** Connection leading to/from this step */
|
||||
connection?: ConnectionRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about crossing a component boundary
|
||||
*/
|
||||
export interface ComponentBoundary {
|
||||
/** Component we're leaving */
|
||||
from: ComponentModel;
|
||||
|
||||
/** Component we're entering */
|
||||
to: ComponentModel;
|
||||
|
||||
/** Port name at the boundary */
|
||||
viaPort: string;
|
||||
|
||||
/** Direction of crossing */
|
||||
direction: 'into' | 'outof';
|
||||
|
||||
/** Index in the steps array where this crossing occurs */
|
||||
stepIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete lineage for a node, tracing both upstream and downstream paths.
|
||||
*
|
||||
* @param project - Project containing all components
|
||||
* @param component - Component containing the starting node
|
||||
* @param nodeId - ID of the node to trace
|
||||
* @param port - Optional specific port to trace (if omitted, traces all connections)
|
||||
* @returns Complete lineage result with upstream and downstream paths
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const lineage = buildLineage(project, component, textNodeId, 'text');
|
||||
* console.log('Source:', lineage.upstream.steps[0].node.label);
|
||||
* console.log('Destinations:', lineage.downstream.map(p => p.steps[p.steps.length - 1].node.label));
|
||||
* ```
|
||||
*/
|
||||
export function buildLineage(
|
||||
project: ProjectModel,
|
||||
component: ComponentModel,
|
||||
nodeId: string,
|
||||
port?: string
|
||||
): LineageResult | null {
|
||||
const node = component.graph.nodeMap.get(nodeId);
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedNode = {
|
||||
id: node.id,
|
||||
label: node.label || node.typename,
|
||||
type: node.typename,
|
||||
componentName: component.name,
|
||||
port
|
||||
};
|
||||
|
||||
// Trace upstream (find sources)
|
||||
const upstream = traceUpstream(project, component, node, port);
|
||||
|
||||
// Trace downstream (find all destinations)
|
||||
const downstream = traceDownstream(project, component, node, port);
|
||||
|
||||
return {
|
||||
selectedNode,
|
||||
upstream,
|
||||
downstream
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace upstream to find the source(s) of data.
|
||||
* Follows connections backwards through the graph and across component boundaries.
|
||||
*/
|
||||
/**
|
||||
* Primary data ports by node type - only trace these ports for focused results
|
||||
*/
|
||||
const PRIMARY_DATA_PORTS: Record<string, string[]> = {
|
||||
Variable: ['value'],
|
||||
Variable2: ['value'],
|
||||
String: ['value'],
|
||||
Number: ['value'],
|
||||
Boolean: ['value'],
|
||||
Object: ['value'],
|
||||
Array: ['value'],
|
||||
Text: ['text'],
|
||||
Image: ['src'],
|
||||
Component: [], // Skip visual components entirely for primary tracing
|
||||
Group: [] // Skip layout components
|
||||
};
|
||||
|
||||
export function traceUpstream(
|
||||
project: ProjectModel,
|
||||
component: ComponentModel,
|
||||
startNode: NodeGraphNode,
|
||||
startPort?: string,
|
||||
visited: Set<string> = new Set(),
|
||||
depth: number = 0
|
||||
): LineagePath {
|
||||
const MAX_DEPTH = 5; // Limit depth to prevent noise
|
||||
const steps: LineageStep[] = [];
|
||||
const crossings: ComponentBoundary[] = [];
|
||||
|
||||
if (depth >= MAX_DEPTH) {
|
||||
return { steps, crossings };
|
||||
}
|
||||
|
||||
// Create unique key for cycle detection
|
||||
const nodeKey = `${component.fullName}:${startNode.id}:${startPort || '*'}`;
|
||||
if (visited.has(nodeKey)) {
|
||||
return { steps, crossings }; // Cycle detected
|
||||
}
|
||||
visited.add(nodeKey);
|
||||
|
||||
// Determine which inputs to trace
|
||||
const portsToTrace = startPort ? [startPort] : getInputPorts(startNode);
|
||||
|
||||
for (const portName of portsToTrace) {
|
||||
const connections = getPortConnections(component, startNode.id, portName, 'input');
|
||||
|
||||
if (connections.length === 0) {
|
||||
// No connections - skip unconnected ports unless this is a Component Input boundary
|
||||
if (startNode.typename === 'Component Inputs') {
|
||||
// Cross into parent component
|
||||
const external = resolveComponentBoundary(project, component, startNode.id, portName);
|
||||
|
||||
if (external.length > 0) {
|
||||
// Found connection in parent - continue tracing there
|
||||
const ext = external[0]; // Take first parent connection
|
||||
const parentComponent = project.getComponentWithName(ext.parentNodeId.split('.')[0]);
|
||||
|
||||
if (parentComponent) {
|
||||
const parentNode = parentComponent.graph.nodeMap.get(ext.parentNodeId);
|
||||
|
||||
if (parentNode) {
|
||||
crossings.push({
|
||||
from: component,
|
||||
to: parentComponent,
|
||||
viaPort: portName,
|
||||
direction: 'into',
|
||||
stepIndex: steps.length
|
||||
});
|
||||
|
||||
// Recursively trace in parent
|
||||
const parentPath = traceUpstream(
|
||||
project,
|
||||
parentComponent,
|
||||
parentNode,
|
||||
ext.parentPort,
|
||||
visited,
|
||||
depth + 1
|
||||
);
|
||||
|
||||
steps.push(...parentPath.steps);
|
||||
crossings.push(...parentPath.crossings);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// True source node - no further upstream
|
||||
steps.push({
|
||||
node: startNode,
|
||||
component,
|
||||
port: portName,
|
||||
portType: 'input',
|
||||
isSource: true
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Follow connections upstream
|
||||
for (const conn of connections) {
|
||||
const sourceNode = component.graph.nodeMap.get(conn.fromNodeId);
|
||||
if (!sourceNode) continue;
|
||||
|
||||
// Add this step
|
||||
steps.push({
|
||||
node: sourceNode,
|
||||
component,
|
||||
port: conn.fromPort,
|
||||
portType: 'output',
|
||||
transformation: describeTransformation(sourceNode, conn.fromPort),
|
||||
connection: conn
|
||||
});
|
||||
|
||||
// Check if source is a source-type node
|
||||
if (isSourceNode(sourceNode)) {
|
||||
steps[steps.length - 1].isSource = true;
|
||||
continue; // Don't trace further
|
||||
}
|
||||
|
||||
// Check for component boundary
|
||||
if (sourceNode.typename === 'Component Outputs') {
|
||||
// This comes from a child component
|
||||
// For now, mark as boundary - full child traversal can be added later
|
||||
steps[steps.length - 1].transformation = `From child component output: ${conn.fromPort}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recursively trace further upstream
|
||||
const upstreamPath = traceUpstream(project, component, sourceNode, undefined, visited, depth + 1);
|
||||
steps.push(...upstreamPath.steps);
|
||||
crossings.push(...upstreamPath.crossings);
|
||||
}
|
||||
}
|
||||
|
||||
return { steps, crossings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace downstream to find all destinations of data.
|
||||
* Follows connections forward through the graph and across component boundaries.
|
||||
*/
|
||||
export function traceDownstream(
|
||||
project: ProjectModel,
|
||||
component: ComponentModel,
|
||||
startNode: NodeGraphNode,
|
||||
startPort?: string,
|
||||
visited: Set<string> = new Set(),
|
||||
depth: number = 0
|
||||
): LineagePath[] {
|
||||
const MAX_DEPTH = 50;
|
||||
const paths: LineagePath[] = [];
|
||||
|
||||
if (depth >= MAX_DEPTH) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
const nodeKey = `${component.fullName}:${startNode.id}:${startPort || '*'}`;
|
||||
if (visited.has(nodeKey)) {
|
||||
return paths;
|
||||
}
|
||||
visited.add(nodeKey);
|
||||
|
||||
// Determine which outputs to trace
|
||||
const portsToTrace = startPort ? [startPort] : getOutputPorts(startNode);
|
||||
|
||||
for (const portName of portsToTrace) {
|
||||
const connections = getPortConnections(component, startNode.id, portName, 'output');
|
||||
|
||||
if (connections.length === 0) {
|
||||
// No connections - check if this is a component boundary
|
||||
if (startNode.typename === 'Component Outputs') {
|
||||
// Cross out to parent component
|
||||
const external = resolveComponentBoundary(project, component, startNode.id, portName);
|
||||
|
||||
if (external.length > 0) {
|
||||
const ext = external[0];
|
||||
// Create path showing we crossed the boundary
|
||||
paths.push({
|
||||
steps: [
|
||||
{
|
||||
node: startNode,
|
||||
component,
|
||||
port: portName,
|
||||
portType: 'output',
|
||||
transformation: `Exported to parent as: ${portName}`
|
||||
}
|
||||
],
|
||||
crossings: [
|
||||
{
|
||||
from: component,
|
||||
to: component, // Would need to resolve parent component here
|
||||
viaPort: portName,
|
||||
direction: 'outof',
|
||||
stepIndex: 0
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// True sink - no further downstream
|
||||
paths.push({
|
||||
steps: [
|
||||
{
|
||||
node: startNode,
|
||||
component,
|
||||
port: portName,
|
||||
portType: 'output',
|
||||
isSink: true
|
||||
}
|
||||
],
|
||||
crossings: []
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Follow each connection downstream (can branch to multiple destinations)
|
||||
for (const conn of connections) {
|
||||
const destNode = component.graph.nodeMap.get(conn.toNodeId);
|
||||
if (!destNode) continue;
|
||||
|
||||
const pathSteps: LineageStep[] = [
|
||||
{
|
||||
node: destNode,
|
||||
component,
|
||||
port: conn.toPort,
|
||||
portType: 'input',
|
||||
transformation: describeTransformation(destNode, conn.toPort),
|
||||
connection: conn
|
||||
}
|
||||
];
|
||||
|
||||
// Check if destination is a sink
|
||||
if (isSinkNode(destNode)) {
|
||||
pathSteps[0].isSink = true;
|
||||
paths.push({ steps: pathSteps, crossings: [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for component boundary
|
||||
if (destNode.typename === 'Component Inputs') {
|
||||
// This goes into a child component
|
||||
pathSteps[0].transformation = `Into child component input: ${conn.toPort}`;
|
||||
paths.push({ steps: pathSteps, crossings: [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recursively trace further downstream
|
||||
const downstreamPaths = traceDownstream(project, component, destNode, undefined, visited, depth + 1);
|
||||
|
||||
if (downstreamPaths.length === 0) {
|
||||
// Dead end
|
||||
paths.push({ steps: pathSteps, crossings: [] });
|
||||
} else {
|
||||
// Merge this step with downstream paths
|
||||
for (const downPath of downstreamPaths) {
|
||||
paths.push({
|
||||
steps: [...pathSteps, ...downPath.steps],
|
||||
crossings: downPath.crossings,
|
||||
branches: downstreamPaths.length > 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe what transformation a node performs (if any)
|
||||
*/
|
||||
function describeTransformation(node: NodeGraphNode, port: string): string | undefined {
|
||||
switch (node.typename) {
|
||||
case 'Expression':
|
||||
return `Expression: ${node.parameters.expression || '...'}`;
|
||||
|
||||
case 'String Format':
|
||||
return `Format: ${node.parameters.format || '...'}`;
|
||||
|
||||
case 'Object':
|
||||
// Check if accessing a property
|
||||
if (port && port !== 'object') {
|
||||
return `.${port} property`;
|
||||
}
|
||||
return 'Object value';
|
||||
|
||||
case 'Array':
|
||||
if (port === 'length') return 'Array length';
|
||||
if (port.startsWith('item-')) return `Array item [${port.substring(5)}]`;
|
||||
return undefined;
|
||||
|
||||
case 'Variable':
|
||||
return `Variable: ${node.parameters.name || node.label || 'unnamed'}`;
|
||||
|
||||
case 'Function':
|
||||
return `Function: ${node.parameters.name || 'unnamed'}`;
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a data source (no further upstream to trace)
|
||||
*/
|
||||
function isSourceNode(node: NodeGraphNode): boolean {
|
||||
const sourceTypes = [
|
||||
'REST',
|
||||
'Cloud Function',
|
||||
'Function',
|
||||
'String',
|
||||
'Number',
|
||||
'Boolean',
|
||||
'Color',
|
||||
'Page Inputs',
|
||||
'Receive Event'
|
||||
];
|
||||
|
||||
return sourceTypes.includes(node.typename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a data sink (no further downstream to trace)
|
||||
*/
|
||||
function isSinkNode(node: NodeGraphNode): boolean {
|
||||
const sinkTypes = [
|
||||
'REST', // Also a sink when sending
|
||||
'Cloud Function',
|
||||
'Function',
|
||||
'Send Event',
|
||||
'Navigate',
|
||||
'Set Variable',
|
||||
'Page Router'
|
||||
];
|
||||
|
||||
return sinkTypes.includes(node.typename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ports to skip when tracing lineage (signals, metadata, internal state)
|
||||
*/
|
||||
const SKIP_PORTS = new Set([
|
||||
// Signal ports (events, not data)
|
||||
'changed',
|
||||
'fetched',
|
||||
'onSave',
|
||||
'onLoad',
|
||||
'onClick',
|
||||
'onHover',
|
||||
'onPress',
|
||||
'onFocus',
|
||||
'onBlur',
|
||||
|
||||
// Metadata ports (not actual data flow)
|
||||
'name',
|
||||
'id',
|
||||
'savedValue',
|
||||
'isLoading',
|
||||
'error',
|
||||
|
||||
// Internal state
|
||||
'__state',
|
||||
'__internal'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a port should be included in lineage tracing
|
||||
*/
|
||||
function shouldTracePort(port: { name: string; type?: string }): boolean {
|
||||
// Skip signal ports
|
||||
if (port.type === 'signal') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip known metadata/event ports
|
||||
if (SKIP_PORTS.has(port.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip ports starting with underscore (internal)
|
||||
if (port.name.startsWith('_')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all input port names for a node (filtered for data ports only)
|
||||
*/
|
||||
function getInputPorts(node: NodeGraphNode): string[] {
|
||||
const ports = node.getPorts('input');
|
||||
return ports.filter(shouldTracePort).map((p) => p.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all output port names for a node (filtered for data ports only)
|
||||
*/
|
||||
function getOutputPorts(node: NodeGraphNode): string[] {
|
||||
// Check if this node type has primary data ports defined
|
||||
const primaryPorts = PRIMARY_DATA_PORTS[node.typename];
|
||||
if (primaryPorts !== undefined) {
|
||||
return primaryPorts; // Only trace primary ports for this type
|
||||
}
|
||||
|
||||
// Default: trace all filtered ports
|
||||
const ports = node.getPorts('output');
|
||||
return ports.filter(shouldTracePort).map((p) => p.name);
|
||||
}
|
||||
@@ -2485,7 +2485,59 @@ export class NodeGraphEditor extends View {
|
||||
PopupLayer.instance.hidePopup();
|
||||
evt.consumed = true;
|
||||
|
||||
// Check if we're right-clicking on a node (selected or not)
|
||||
let nodeUnderCursor: NodeGraphEditorNode = null;
|
||||
|
||||
// First check if clicking on already selected nodes
|
||||
if (this.isPointInsideNodes(scaledPos, this.selector.nodes)) {
|
||||
nodeUnderCursor = this.selector.nodes.find((node) => {
|
||||
const nodeRect = {
|
||||
x: node.global.x,
|
||||
y: node.global.y,
|
||||
width: node.nodeSize.width,
|
||||
height: node.nodeSize.height
|
||||
};
|
||||
return (
|
||||
scaledPos.x >= nodeRect.x &&
|
||||
scaledPos.x <= nodeRect.x + nodeRect.width &&
|
||||
scaledPos.y >= nodeRect.y &&
|
||||
scaledPos.y <= nodeRect.y + nodeRect.height
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// If not on a selected node, check all nodes
|
||||
if (!nodeUnderCursor) {
|
||||
this.forEachNode((node) => {
|
||||
const nodeRect = {
|
||||
x: node.global.x,
|
||||
y: node.global.y,
|
||||
width: node.nodeSize.width,
|
||||
height: node.nodeSize.height
|
||||
};
|
||||
if (
|
||||
scaledPos.x >= nodeRect.x &&
|
||||
scaledPos.x <= nodeRect.x + nodeRect.width &&
|
||||
scaledPos.y >= nodeRect.y &&
|
||||
scaledPos.y <= nodeRect.y + nodeRect.height
|
||||
) {
|
||||
nodeUnderCursor = node;
|
||||
return true; // Stop iteration
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (nodeUnderCursor) {
|
||||
// Select the node if it isn't already selected
|
||||
if (!this.selector.isActive(nodeUnderCursor)) {
|
||||
this.clearSelection();
|
||||
this.commentLayer?.clearSelection();
|
||||
nodeUnderCursor.selected = true;
|
||||
this.selector.select([nodeUnderCursor]);
|
||||
this.repaint();
|
||||
}
|
||||
|
||||
// Show context menu
|
||||
this.openRightClickMenu();
|
||||
} else if (
|
||||
CreateNewNodePanel.shouldShow({
|
||||
@@ -2508,14 +2560,8 @@ export class NodeGraphEditor extends View {
|
||||
isBackgroundDimmed: true,
|
||||
onClose: () => this.createNewNodePanel.dispose()
|
||||
});
|
||||
} else {
|
||||
PopupLayer.instance.showTooltip({
|
||||
x: evt.pageX,
|
||||
y: evt.pageY,
|
||||
position: 'bottom',
|
||||
content: 'This node type cannot have children.'
|
||||
});
|
||||
}
|
||||
// If clicking empty space with no valid actions, do nothing (no broken tooltip)
|
||||
}
|
||||
this.rightClickPos = undefined;
|
||||
}
|
||||
@@ -2577,6 +2623,27 @@ export class NodeGraphEditor extends View {
|
||||
|
||||
items.push('divider');
|
||||
|
||||
// Data Lineage - DISABLED: Not production ready, requires more work
|
||||
// TODO: Re-enable when lineage filtering and event handling are fixed
|
||||
// items.push({
|
||||
// label: 'Show Data Lineage',
|
||||
// icon: IconName.Link,
|
||||
// onClick: () => {
|
||||
// const selectedNode = this.selector.nodes[0];
|
||||
// if (selectedNode) {
|
||||
// EventDispatcher.instance.emit('DataLineage.ShowForNode', {
|
||||
// nodeId: selectedNode.model.id,
|
||||
// componentName: this.activeComponent?.fullName
|
||||
// });
|
||||
// SidebarModel.instance.switch('data-lineage');
|
||||
// }
|
||||
// },
|
||||
// isDisabled: selectedNodes.length !== 1,
|
||||
// tooltip: selectedNodes.length !== 1 ? 'Select a single node to trace its data lineage' : undefined,
|
||||
// tooltipShowAfterMs: 300
|
||||
// });
|
||||
// items.push('divider');
|
||||
|
||||
if (
|
||||
selectedNodes.length === 1 &&
|
||||
CreateNewNodePanel.shouldShow({
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// Data Lineage Panel Styles
|
||||
|
||||
.DataLineagePanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: var(--theme-spacing-2);
|
||||
overflow-y: auto;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
}
|
||||
|
||||
.EmptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
|
||||
&-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--theme-spacing-3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--theme-spacing-2);
|
||||
}
|
||||
|
||||
&-description {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: var(--theme-spacing-2);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
margin-bottom: var(--theme-spacing-3);
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theme-spacing-2);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-2);
|
||||
}
|
||||
}
|
||||
|
||||
.Button {
|
||||
padding: var(--theme-spacing-1) var(--theme-spacing-3);
|
||||
background-color: var(--theme-color-primary);
|
||||
color: var(--theme-color-bg-1);
|
||||
border: none;
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.SelectedNode {
|
||||
padding: var(--theme-spacing-2);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
margin-bottom: var(--theme-spacing-3);
|
||||
|
||||
&-label {
|
||||
font-size: 15px;
|
||||
margin-bottom: var(--theme-spacing-1);
|
||||
}
|
||||
|
||||
&-meta {
|
||||
font-size: 13px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
margin-bottom: var(--theme-spacing-1);
|
||||
}
|
||||
|
||||
&-component {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.Section {
|
||||
margin-bottom: var(--theme-spacing-3);
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theme-spacing-2);
|
||||
padding: var(--theme-spacing-2);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
&-toggle {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theme-spacing-1);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&-content {
|
||||
margin-top: var(--theme-spacing-2);
|
||||
padding-left: var(--theme-spacing-2);
|
||||
}
|
||||
}
|
||||
|
||||
.EmptyPath {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--theme-spacing-4) var(--theme-spacing-2);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
|
||||
&-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: var(--theme-spacing-2);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: 14px;
|
||||
margin-bottom: var(--theme-spacing-1);
|
||||
}
|
||||
|
||||
&-hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.DownstreamPath {
|
||||
margin-bottom: var(--theme-spacing-2);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.PathBranch {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-primary);
|
||||
margin-bottom: var(--theme-spacing-1);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Data Lineage Panel
|
||||
*
|
||||
* Shows the complete upstream (source) and downstream (destination) paths for data flow
|
||||
* through the node graph, including cross-component traversal.
|
||||
*/
|
||||
|
||||
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import { HighlightManager } from '../../../services/HighlightManager';
|
||||
import type { LineageResult } from '../../../utils/graphAnalysis';
|
||||
import { LineagePath } from './components/LineagePath';
|
||||
import { PathSummary } from './components/PathSummary';
|
||||
import css from './DataLineagePanel.module.scss';
|
||||
import { useDataLineage } from './hooks/useDataLineage';
|
||||
|
||||
export interface DataLineagePanelProps {
|
||||
/** Optional: Pre-selected node to trace */
|
||||
selectedNodeId?: string;
|
||||
|
||||
/** Optional: Specific port to trace */
|
||||
selectedPort?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataLineagePanel component
|
||||
*
|
||||
* Displays data lineage information for a selected node, showing where
|
||||
* data comes from (upstream) and where it goes to (downstream).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DataLineagePanel selectedNodeId="node-123" />
|
||||
* ```
|
||||
*/
|
||||
export function DataLineagePanel({ selectedNodeId, selectedPort }: DataLineagePanelProps) {
|
||||
const [activeHighlightHandle, setActiveHighlightHandle] = useState<{ dismiss: () => void } | null>(null);
|
||||
const [lineageData, setLineageData] = useState<LineageResult | null>(null);
|
||||
const [isUpstreamExpanded, setIsUpstreamExpanded] = useState(true);
|
||||
const [isDownstreamExpanded, setIsDownstreamExpanded] = useState(true);
|
||||
|
||||
// Get lineage data using custom hook
|
||||
const lineage = useDataLineage(selectedNodeId, selectedPort);
|
||||
|
||||
// Update lineage data when it changes
|
||||
useEffect(() => {
|
||||
setLineageData(lineage);
|
||||
}, [lineage]);
|
||||
|
||||
// Cleanup highlights on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (activeHighlightHandle) {
|
||||
activeHighlightHandle.dismiss();
|
||||
}
|
||||
};
|
||||
}, [activeHighlightHandle]);
|
||||
|
||||
/**
|
||||
* Highlight the full lineage path on the canvas
|
||||
*/
|
||||
const handleHighlightPath = () => {
|
||||
if (!lineageData) return;
|
||||
|
||||
// Dismiss any existing highlight
|
||||
if (activeHighlightHandle) {
|
||||
activeHighlightHandle.dismiss();
|
||||
}
|
||||
|
||||
// Collect all nodes in the lineage
|
||||
const nodeIds = new Set<string>();
|
||||
const connections: Array<{
|
||||
fromNodeId: string;
|
||||
fromPort: string;
|
||||
toNodeId: string;
|
||||
toPort: string;
|
||||
}> = [];
|
||||
|
||||
// Add upstream nodes
|
||||
lineageData.upstream.steps.forEach((step) => {
|
||||
nodeIds.add(step.node.id);
|
||||
if (step.connection) {
|
||||
connections.push(step.connection);
|
||||
}
|
||||
});
|
||||
|
||||
// Add downstream nodes
|
||||
lineageData.downstream.forEach((path) => {
|
||||
path.steps.forEach((step) => {
|
||||
nodeIds.add(step.node.id);
|
||||
if (step.connection) {
|
||||
connections.push(step.connection);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add the selected node itself
|
||||
nodeIds.add(lineageData.selectedNode.id);
|
||||
|
||||
// Create highlight
|
||||
const handle = HighlightManager.instance.highlightPath(
|
||||
{
|
||||
nodes: Array.from(nodeIds),
|
||||
connections,
|
||||
crossesComponents: lineageData.upstream.crossings.length > 0
|
||||
},
|
||||
{
|
||||
channel: 'lineage',
|
||||
style: 'glow',
|
||||
persistent: true,
|
||||
label: `Lineage: ${lineageData.selectedNode.label}`
|
||||
}
|
||||
);
|
||||
|
||||
setActiveHighlightHandle(handle);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dismiss the lineage highlight
|
||||
*/
|
||||
const handleDismiss = () => {
|
||||
if (activeHighlightHandle) {
|
||||
activeHighlightHandle.dismiss();
|
||||
setActiveHighlightHandle(null);
|
||||
}
|
||||
setLineageData(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to a specific node on the canvas
|
||||
*/
|
||||
const handleNavigateToNode = (componentName: string, _nodeId: string) => {
|
||||
// Switch to the component if needed
|
||||
const component = ProjectModel.instance.getComponentWithName(componentName);
|
||||
if (component && NodeGraphContextTmp.switchToComponent) {
|
||||
NodeGraphContextTmp.switchToComponent(component, { pushHistory: true });
|
||||
}
|
||||
|
||||
// The canvas will auto-center on the node due to selection
|
||||
// TODO: Add explicit pan-to-node and selection once that API is available
|
||||
};
|
||||
|
||||
// Empty state
|
||||
if (!lineageData) {
|
||||
return (
|
||||
<div className={css['DataLineagePanel']}>
|
||||
<div className={css['EmptyState']}>
|
||||
<div className={css['EmptyState-icon']}>🔗</div>
|
||||
<div className={css['EmptyState-title']}>No Node Selected</div>
|
||||
<div className={css['EmptyState-description']}>Select a node on the canvas to trace its data lineage</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasUpstream = lineageData.upstream.steps.length > 0;
|
||||
const hasDownstream = lineageData.downstream.length > 0 && lineageData.downstream.some((p) => p.steps.length > 0);
|
||||
|
||||
return (
|
||||
<div className={css['DataLineagePanel']}>
|
||||
{/* Header */}
|
||||
<div className={css['Header']}>
|
||||
<div className={css['Header-title']}>
|
||||
<span className={css['Header-icon']}>🔗</span>
|
||||
<span className={css['Header-label']}>Data Lineage</span>
|
||||
</div>
|
||||
<div className={css['Header-actions']}>
|
||||
{activeHighlightHandle ? (
|
||||
<button className={css['Button']} onClick={handleDismiss} title="Clear highlighting">
|
||||
Clear
|
||||
</button>
|
||||
) : (
|
||||
<button className={css['Button']} onClick={handleHighlightPath} title="Highlight path on canvas">
|
||||
Highlight
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Node Info */}
|
||||
<div className={css['SelectedNode']}>
|
||||
<div className={css['SelectedNode-label']}>
|
||||
<strong>{lineageData.selectedNode.label}</strong>
|
||||
</div>
|
||||
<div className={css['SelectedNode-meta']}>
|
||||
{lineageData.selectedNode.type}
|
||||
{lineageData.selectedNode.port && ` → ${lineageData.selectedNode.port}`}
|
||||
</div>
|
||||
<div className={css['SelectedNode-component']}>{lineageData.selectedNode.componentName}</div>
|
||||
</div>
|
||||
|
||||
{/* Path Summary */}
|
||||
{(hasUpstream || hasDownstream) && (
|
||||
<PathSummary
|
||||
upstream={lineageData.upstream}
|
||||
downstream={lineageData.downstream}
|
||||
selectedNodeLabel={lineageData.selectedNode.label}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upstream Section */}
|
||||
<div className={css['Section']}>
|
||||
<div className={css['Section-header']} onClick={() => setIsUpstreamExpanded(!isUpstreamExpanded)}>
|
||||
<span className={css['Section-toggle']}>{isUpstreamExpanded ? '▼' : '▶'}</span>
|
||||
<span className={css['Section-title']}>
|
||||
<span className={css['Section-icon']}>▲</span> UPSTREAM
|
||||
</span>
|
||||
<span className={css['Section-subtitle']}>Where does this value come from?</span>
|
||||
</div>
|
||||
|
||||
{isUpstreamExpanded && (
|
||||
<div className={css['Section-content']}>
|
||||
{hasUpstream ? (
|
||||
<LineagePath path={lineageData.upstream} direction="upstream" onNavigateToNode={handleNavigateToNode} />
|
||||
) : (
|
||||
<div className={css['EmptyPath']}>
|
||||
<div className={css['EmptyPath-icon']}>⊗</div>
|
||||
<div className={css['EmptyPath-text']}>No upstream connections</div>
|
||||
<div className={css['EmptyPath-hint']}>This is a source node</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Downstream Section */}
|
||||
<div className={css['Section']}>
|
||||
<div className={css['Section-header']} onClick={() => setIsDownstreamExpanded(!isDownstreamExpanded)}>
|
||||
<span className={css['Section-toggle']}>{isDownstreamExpanded ? '▼' : '▶'}</span>
|
||||
<span className={css['Section-title']}>
|
||||
<span className={css['Section-icon']}>▼</span> DOWNSTREAM
|
||||
</span>
|
||||
<span className={css['Section-subtitle']}>Where does this value go?</span>
|
||||
</div>
|
||||
|
||||
{isDownstreamExpanded && (
|
||||
<div className={css['Section-content']}>
|
||||
{hasDownstream ? (
|
||||
<>
|
||||
{lineageData.downstream.map((path, index) => (
|
||||
<div key={index} className={css['DownstreamPath']}>
|
||||
{lineageData.downstream.length > 1 && <div className={css['PathBranch']}>Branch {index + 1}</div>}
|
||||
<LineagePath path={path} direction="downstream" onNavigateToNode={handleNavigateToNode} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className={css['EmptyPath']}>
|
||||
<div className={css['EmptyPath-icon']}>⊗</div>
|
||||
<div className={css['EmptyPath-text']}>No downstream connections</div>
|
||||
<div className={css['EmptyPath-hint']}>This is a sink node</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// LineagePath Component Styles
|
||||
|
||||
.LineagePath {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-1);
|
||||
}
|
||||
|
||||
.Step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-1);
|
||||
|
||||
&-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theme-spacing-2);
|
||||
padding: var(--theme-spacing-2);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
&-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&-port {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&-transformation {
|
||||
padding: var(--theme-spacing-1) var(--theme-spacing-2);
|
||||
margin-left: var(--theme-spacing-4);
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&-badge {
|
||||
display: inline-block;
|
||||
padding: 2px var(--theme-spacing-2);
|
||||
margin-left: var(--theme-spacing-4);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background-color: var(--theme-color-primary);
|
||||
color: var(--theme-color-bg-1);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&-arrow {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.Crossings {
|
||||
margin-top: var(--theme-spacing-2);
|
||||
padding: var(--theme-spacing-2);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
border-left: 3px solid var(--theme-color-primary);
|
||||
|
||||
&-label {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* LineagePath Component
|
||||
* Displays a single lineage path (upstream or downstream)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { LineagePath as LineagePathType } from '../../../../utils/graphAnalysis';
|
||||
import css from './LineagePath.module.scss';
|
||||
|
||||
export interface LineagePathProps {
|
||||
path: LineagePathType;
|
||||
direction: 'upstream' | 'downstream';
|
||||
onNavigateToNode: (componentName: string, nodeId: string) => void;
|
||||
}
|
||||
|
||||
export function LineagePath({ path, direction, onNavigateToNode }: LineagePathProps) {
|
||||
if (!path || path.steps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css['LineagePath']} data-direction={direction}>
|
||||
{path.steps.map((step, index) => (
|
||||
<div key={`${step.node.id}-${index}`} className={css['Step']}>
|
||||
<div
|
||||
className={css['Step-node']}
|
||||
onClick={() => onNavigateToNode(step.component.name, step.node.id)}
|
||||
title={`Click to navigate to ${step.node.label || step.node.typename}`}
|
||||
>
|
||||
<span className={css['Step-icon']}>🔵</span>
|
||||
<span className={css['Step-label']}>{step.node.label || step.node.typename}</span>
|
||||
{step.port && <span className={css['Step-port']}>→ {step.port}</span>}
|
||||
</div>
|
||||
|
||||
{step.transformation && <div className={css['Step-transformation']}>{step.transformation}</div>}
|
||||
|
||||
{step.isSource && <div className={css['Step-badge']}>SOURCE</div>}
|
||||
{step.isSink && <div className={css['Step-badge']}>SINK</div>}
|
||||
|
||||
{index < path.steps.length - 1 && <div className={css['Step-arrow']}>↓</div>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{path.crossings && path.crossings.length > 0 && (
|
||||
<div className={css['Crossings']}>
|
||||
<div className={css['Crossings-label']}>Component boundaries crossed: {path.crossings.length}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// PathSummary Component Styles
|
||||
|
||||
.PathSummary {
|
||||
padding: var(--theme-spacing-2);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
margin-bottom: var(--theme-spacing-3);
|
||||
border-left: 3px solid var(--theme-color-primary);
|
||||
|
||||
&-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
margin-bottom: var(--theme-spacing-2);
|
||||
}
|
||||
|
||||
&-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-1);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.Path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theme-spacing-2);
|
||||
flex-wrap: wrap;
|
||||
|
||||
&-segment {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&-arrow {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&-selected {
|
||||
padding: var(--theme-spacing-1) var(--theme-spacing-2);
|
||||
background-color: var(--theme-color-primary);
|
||||
color: var(--theme-color-bg-1);
|
||||
border-radius: var(--theme-border-radius-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-branch {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-style: italic;
|
||||
margin-left: var(--theme-spacing-1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* PathSummary Component
|
||||
* Shows a compact summary of the lineage path
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { LineagePath } from '../../../../utils/graphAnalysis';
|
||||
import css from './PathSummary.module.scss';
|
||||
|
||||
export interface PathSummaryProps {
|
||||
upstream: LineagePath;
|
||||
downstream: LineagePath[];
|
||||
selectedNodeLabel: string;
|
||||
}
|
||||
|
||||
export function PathSummary({ upstream, downstream, selectedNodeLabel }: PathSummaryProps) {
|
||||
// Build compact path summary
|
||||
const upstreamSummary = upstream.steps.map((s) => s.node.label || s.node.typename).join(' → ');
|
||||
const downstreamSummaries = downstream.map((path) =>
|
||||
path.steps.map((s) => s.node.label || s.node.typename).join(' → ')
|
||||
);
|
||||
|
||||
const hasUpstream = upstream.steps.length > 0;
|
||||
const hasDownstream = downstream.length > 0 && downstream.some((p) => p.steps.length > 0);
|
||||
|
||||
if (!hasUpstream && !hasDownstream) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css['PathSummary']}>
|
||||
<div className={css['PathSummary-title']}>Path Summary</div>
|
||||
<div className={css['PathSummary-content']}>
|
||||
{hasUpstream && (
|
||||
<div className={css['Path']}>
|
||||
<span className={css['Path-segment']}>{upstreamSummary}</span>
|
||||
<span className={css['Path-arrow']}>→</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={css['Path-selected']}>
|
||||
<strong>{selectedNodeLabel}</strong>
|
||||
</div>
|
||||
|
||||
{hasDownstream && (
|
||||
<>
|
||||
{downstreamSummaries.map((summary, index) => (
|
||||
<div key={index} className={css['Path']}>
|
||||
<span className={css['Path-arrow']}>→</span>
|
||||
<span className={css['Path-segment']}>{summary}</span>
|
||||
{downstream.length > 1 && <span className={css['Path-branch']}>Branch {index + 1}</span>}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Hook for calculating data lineage for a selected node
|
||||
*/
|
||||
|
||||
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import { EventDispatcher } from '../../../../../../shared/utils/EventDispatcher';
|
||||
import { buildLineage, type LineageResult } from '../../../../utils/graphAnalysis';
|
||||
|
||||
/**
|
||||
* Custom hook to calculate and return lineage data for a selected node
|
||||
*
|
||||
* @param selectedNodeId - ID of the node to trace (optional - if not provided, uses canvas selection)
|
||||
* @param selectedPort - Specific port to trace (optional)
|
||||
* @returns Lineage result or null if no node selected
|
||||
*/
|
||||
export function useDataLineage(selectedNodeId?: string, selectedPort?: string): LineageResult | null {
|
||||
const [lineage, setLineage] = useState<LineageResult | null>(null);
|
||||
const [currentSelectedNodeId, setCurrentSelectedNodeId] = useState<string | undefined>(selectedNodeId);
|
||||
|
||||
console.log(
|
||||
'🔗 [DataLineage] Hook render - currentSelectedNodeId:',
|
||||
currentSelectedNodeId,
|
||||
'selectedNodeId prop:',
|
||||
selectedNodeId
|
||||
);
|
||||
|
||||
// Check current selection on mount (catches selection that happened before panel opened)
|
||||
useEffect(() => {
|
||||
console.log('🔗 [DataLineage] Mount check useEffect running');
|
||||
// Only if we're not using a fixed selectedNodeId prop
|
||||
if (!selectedNodeId) {
|
||||
console.log('🔗 [DataLineage] No prop provided, checking graph selection...');
|
||||
console.log('🔗 [DataLineage] NodeGraphContextTmp.nodeGraph:', NodeGraphContextTmp.nodeGraph);
|
||||
const selection = NodeGraphContextTmp.nodeGraph?.getSelectedNodes?.();
|
||||
console.log('🔗 [DataLineage] getSelectedNodes() returned:', selection);
|
||||
if (selection && selection.length === 1) {
|
||||
console.log('🔗 [DataLineage] ✅ Setting currentSelectedNodeId to:', selection[0].id);
|
||||
setCurrentSelectedNodeId(selection[0].id);
|
||||
} else {
|
||||
console.log('🔗 [DataLineage] ❌ No valid single selection on mount');
|
||||
}
|
||||
} else {
|
||||
console.log('🔗 [DataLineage] Using prop selectedNodeId:', selectedNodeId);
|
||||
}
|
||||
}, []); // Empty deps = run once on mount
|
||||
|
||||
// Listen to context menu "Show Data Lineage" requests
|
||||
useEventListener(
|
||||
EventDispatcher.instance,
|
||||
'DataLineage.ShowForNode',
|
||||
(data: { nodeId: string; componentName?: string }) => {
|
||||
console.log('📍 [DataLineage] Context menu event - Show lineage for node:', data.nodeId);
|
||||
setCurrentSelectedNodeId(data.nodeId);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Listen to selection changes on the canvas
|
||||
useEventListener(
|
||||
NodeGraphContextTmp.nodeGraph,
|
||||
'selectionChanged',
|
||||
(selection: { nodeIds: string[] }) => {
|
||||
console.log('🔗 [DataLineage] selectionChanged event fired:', selection);
|
||||
// Only update if we're not using a fixed selectedNodeId prop
|
||||
if (!selectedNodeId) {
|
||||
if (selection.nodeIds.length === 1) {
|
||||
console.log('🔗 [DataLineage] ✅ Event: Setting selection to:', selection.nodeIds[0]);
|
||||
setCurrentSelectedNodeId(selection.nodeIds[0]);
|
||||
} else {
|
||||
console.log('🔗 [DataLineage] ❌ Event: Clearing selection (count:', selection.nodeIds.length, ')');
|
||||
setCurrentSelectedNodeId(undefined);
|
||||
}
|
||||
} else {
|
||||
console.log('🔗 [DataLineage] Event: Ignoring (using prop)');
|
||||
}
|
||||
},
|
||||
[selectedNodeId]
|
||||
);
|
||||
|
||||
// Recalculate lineage when selection or component changes
|
||||
useEffect(() => {
|
||||
console.log('🔗 [DataLineage] Lineage calc useEffect running');
|
||||
const component = NodeGraphContextTmp.nodeGraph?.activeComponent;
|
||||
console.log('🔗 [DataLineage] Active component:', component?.name);
|
||||
|
||||
if (!component) {
|
||||
console.log('🔗 [DataLineage] ❌ No active component');
|
||||
setLineage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use either the prop or the current selection
|
||||
const nodeId = selectedNodeId || currentSelectedNodeId;
|
||||
console.log('🔗 [DataLineage] Node ID to trace:', nodeId);
|
||||
|
||||
if (!nodeId) {
|
||||
console.log('🔗 [DataLineage] ❌ No node ID to trace');
|
||||
setLineage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate lineage
|
||||
try {
|
||||
console.log('🔗 [DataLineage] 🚀 Calling buildLineage...');
|
||||
const result = buildLineage(ProjectModel.instance, component, nodeId, selectedPort);
|
||||
console.log('🔗 [DataLineage] ✅ Lineage built successfully:', result);
|
||||
setLineage(result);
|
||||
} catch (error) {
|
||||
console.error('🔗 [DataLineage] ❌ Error building lineage:', error);
|
||||
setLineage(null);
|
||||
}
|
||||
}, [selectedNodeId, currentSelectedNodeId, selectedPort]);
|
||||
|
||||
return lineage;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Data Lineage Panel
|
||||
*
|
||||
* Exports for the Data Lineage visualization panel
|
||||
*/
|
||||
|
||||
export { DataLineagePanel } from './DataLineagePanel';
|
||||
export type { DataLineagePanelProps } from './DataLineagePanel';
|
||||
@@ -24,6 +24,29 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.TopologyMapPanel__backButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
color: var(--theme-color-fg-default);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-color-bg-4);
|
||||
border-color: var(--theme-color-primary);
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--theme-color-bg-5);
|
||||
}
|
||||
}
|
||||
|
||||
.TopologyMapPanel__icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -57,6 +80,25 @@
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.TopologyMapPanel__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.TopologyMapPanel__orphanCount {
|
||||
color: var(--theme-color-warning) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Legend
|
||||
.TopologyMapPanel__legend {
|
||||
display: flex;
|
||||
|
||||
@@ -5,138 +5,111 @@
|
||||
* Shows the "big picture" of component relationships in the project.
|
||||
*/
|
||||
|
||||
import { NodeGraphContextTmp } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import { TopologyMapView } from './components/TopologyMapView';
|
||||
import { useTopologyGraph } from './hooks/useTopologyGraph';
|
||||
import { useTopologyLayout } from './hooks/useTopologyLayout';
|
||||
import { useFolderGraph } from './hooks/useFolderGraph';
|
||||
import { useFolderLayout } from './hooks/useFolderLayout';
|
||||
import css from './TopologyMapPanel.module.scss';
|
||||
import { TopologyNode } from './utils/topologyTypes';
|
||||
import { FolderNode, TopologyViewState } from './utils/topologyTypes';
|
||||
|
||||
export function TopologyMapPanel() {
|
||||
const [hoveredNode, setHoveredNode] = useState<TopologyNode | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [selectedFolder, setSelectedFolder] = useState<FolderNode | null>(null);
|
||||
const [isLegendOpen, setIsLegendOpen] = useState(false);
|
||||
const [viewState, setViewState] = useState<TopologyViewState>({
|
||||
mode: 'overview',
|
||||
expandedFolderId: null,
|
||||
selectedComponentId: null
|
||||
});
|
||||
|
||||
// Build the graph data
|
||||
const graph = useTopologyGraph();
|
||||
// Build the folder graph
|
||||
const folderGraph = useFolderGraph();
|
||||
|
||||
// Apply layout algorithm
|
||||
const positionedGraph = useTopologyLayout(graph);
|
||||
// Apply tiered layout
|
||||
const positionedGraph = useFolderLayout(folderGraph);
|
||||
|
||||
// Handle node click - navigate to that component
|
||||
const handleNodeClick = useCallback((node: TopologyNode) => {
|
||||
console.log('[TopologyMapPanel] Navigating to component:', node.fullName);
|
||||
|
||||
if (NodeGraphContextTmp.switchToComponent) {
|
||||
NodeGraphContextTmp.switchToComponent(node.component, {
|
||||
pushHistory: true,
|
||||
breadcrumbs: true
|
||||
});
|
||||
}
|
||||
// Handle folder click - select for details (Phase 4)
|
||||
const handleFolderClick = useCallback((folder: FolderNode) => {
|
||||
console.log('[TopologyMapPanel] Selected folder:', folder.name);
|
||||
setSelectedFolder(folder);
|
||||
}, []);
|
||||
|
||||
// Handle node hover for tooltip
|
||||
const handleNodeHover = useCallback((node: TopologyNode | null, event?: React.MouseEvent) => {
|
||||
setHoveredNode(node);
|
||||
if (node && event) {
|
||||
setTooltipPos({ x: event.clientX, y: event.clientY });
|
||||
} else {
|
||||
setTooltipPos(null);
|
||||
}
|
||||
// Handle folder double-click - drill down (Phase 3)
|
||||
const handleFolderDoubleClick = useCallback((folder: FolderNode) => {
|
||||
console.log('[TopologyMapPanel] Drilling into folder:', folder.name);
|
||||
setViewState({
|
||||
mode: 'expanded',
|
||||
expandedFolderId: folder.id,
|
||||
selectedComponentId: null
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-fit on first load
|
||||
useEffect(() => {
|
||||
// Trigger fit to view after initial render
|
||||
const timer = setTimeout(() => {
|
||||
// The TopologyMapView has a fitToView method, but we can't call it directly
|
||||
// Instead, it will auto-fit on mount via the controls
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
// Handle back to overview
|
||||
const handleBackToOverview = useCallback(() => {
|
||||
console.log('[TopologyMapPanel] Returning to overview');
|
||||
setViewState({
|
||||
mode: 'overview',
|
||||
expandedFolderId: null,
|
||||
selectedComponentId: null
|
||||
});
|
||||
setSelectedFolder(null);
|
||||
}, []);
|
||||
|
||||
// Get expanded folder details
|
||||
const expandedFolder =
|
||||
viewState.mode === 'expanded' ? positionedGraph.folders.find((f) => f.id === viewState.expandedFolderId) : null;
|
||||
|
||||
return (
|
||||
<div className={css['TopologyMapPanel']}>
|
||||
{/* Header with breadcrumbs */}
|
||||
{/* Header */}
|
||||
<div className={css['TopologyMapPanel__header']}>
|
||||
<div className={css['TopologyMapPanel__title']}>
|
||||
{viewState.mode === 'expanded' && (
|
||||
<button
|
||||
className={css['TopologyMapPanel__backButton']}
|
||||
onClick={handleBackToOverview}
|
||||
title="Back to overview"
|
||||
>
|
||||
<Icon icon={IconName.ArrowLeft} />
|
||||
</button>
|
||||
)}
|
||||
<Icon icon={IconName.Navigate} />
|
||||
<h2 className={css['TopologyMapPanel__titleText']}>Project Topology</h2>
|
||||
<h2 className={css['TopologyMapPanel__titleText']}>
|
||||
{viewState.mode === 'overview' ? 'Project Topology' : expandedFolder?.name || 'Folder Contents'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{graph.currentPath.length > 0 && (
|
||||
<div className={css['TopologyMapPanel__breadcrumbs']}>
|
||||
<span className={css['TopologyMapPanel__breadcrumbLabel']}>Current path:</span>
|
||||
{graph.currentPath.map((componentName, i) => (
|
||||
<React.Fragment key={componentName}>
|
||||
{i > 0 && <span className={css['TopologyMapPanel__breadcrumbSeparator']}>→</span>}
|
||||
<span
|
||||
className={css['TopologyMapPanel__breadcrumb']}
|
||||
style={{
|
||||
fontWeight: i === graph.currentPath.length - 1 ? 600 : 400
|
||||
}}
|
||||
>
|
||||
{componentName.split('/').pop() || componentName}
|
||||
{/* Stats display */}
|
||||
<div className={css['TopologyMapPanel__stats']}>
|
||||
{viewState.mode === 'overview' ? (
|
||||
<>
|
||||
<span>
|
||||
{positionedGraph.totalFolders} folders • {positionedGraph.totalComponents} components
|
||||
</span>
|
||||
{positionedGraph.orphanComponents.length > 0 && (
|
||||
<span className={css['TopologyMapPanel__orphanCount']}>
|
||||
{positionedGraph.orphanComponents.length} orphans
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>{expandedFolder?.componentCount || 0} components in this folder</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main visualization with legend inside */}
|
||||
{/* Main visualization */}
|
||||
<TopologyMapView
|
||||
graph={positionedGraph}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeHover={handleNodeHover}
|
||||
viewState={viewState}
|
||||
selectedFolderId={selectedFolder?.id || null}
|
||||
onFolderClick={handleFolderClick}
|
||||
onFolderDoubleClick={handleFolderDoubleClick}
|
||||
isLegendOpen={isLegendOpen}
|
||||
onLegendToggle={() => setIsLegendOpen(!isLegendOpen)}
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredNode && tooltipPos && (
|
||||
<div
|
||||
className={css['TopologyMapPanel__tooltip']}
|
||||
style={{
|
||||
left: tooltipPos.x + 10,
|
||||
top: tooltipPos.y + 10
|
||||
}}
|
||||
>
|
||||
<div className={css['TopologyMapPanel__tooltipTitle']}>{hoveredNode.name}</div>
|
||||
<div className={css['TopologyMapPanel__tooltipContent']}>
|
||||
<div>Type: {hoveredNode.type === 'page' ? '📄 Page' : '🧩 Component'}</div>
|
||||
<div>
|
||||
Used {hoveredNode.usageCount} time{hoveredNode.usageCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{hoveredNode.depth < 999 && <div>Depth: {hoveredNode.depth}</div>}
|
||||
{hoveredNode.usedBy.length > 0 && (
|
||||
<div className={css['TopologyMapPanel__tooltipSection']}>
|
||||
<strong>Used by:</strong>{' '}
|
||||
{hoveredNode.usedBy
|
||||
.slice(0, 3)
|
||||
.map((name) => name.split('/').pop())
|
||||
.join(', ')}
|
||||
{hoveredNode.usedBy.length > 3 && ` +${hoveredNode.usedBy.length - 3} more`}
|
||||
</div>
|
||||
)}
|
||||
{hoveredNode.uses.length > 0 && (
|
||||
<div className={css['TopologyMapPanel__tooltipSection']}>
|
||||
<strong>Uses:</strong>{' '}
|
||||
{hoveredNode.uses
|
||||
.slice(0, 3)
|
||||
.map((name) => name.split('/').pop())
|
||||
.join(', ')}
|
||||
{hoveredNode.uses.length > 3 && ` +${hoveredNode.uses.length - 3} more`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={css['TopologyMapPanel__tooltipHint']}>Click to navigate →</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* ComponentNode Styles
|
||||
*
|
||||
* Card-style component nodes with colors inherited from parent folder type.
|
||||
*/
|
||||
|
||||
.ComponentNode {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
.ComponentNode__border {
|
||||
opacity: 1;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.ComponentNode__background {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ComponentNode__background {
|
||||
fill: var(--theme-color-bg-4);
|
||||
opacity: 0.9;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ComponentNode__border {
|
||||
fill: none;
|
||||
stroke: var(--theme-color-border-default);
|
||||
stroke-width: 2;
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ComponentNode__header {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ComponentNode__iconBackground {
|
||||
fill: var(--theme-color-bg-5);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ComponentNode__iconText {
|
||||
fill: var(--theme-color-fg-default);
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ComponentNode__name {
|
||||
fill: var(--theme-color-fg-default);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ComponentNode__stats {
|
||||
fill: var(--theme-color-fg-default-shy);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ComponentNode__nodeList {
|
||||
fill: var(--theme-color-fg-default);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Folder Type Color Inheritance (matching parent folder colors)
|
||||
// ============================================================================
|
||||
|
||||
// Page folder components (blue)
|
||||
.ComponentNode--page {
|
||||
.ComponentNode__background {
|
||||
fill: #1e3a8a;
|
||||
}
|
||||
.ComponentNode__border {
|
||||
stroke: #3b82f6;
|
||||
}
|
||||
.ComponentNode__iconBackground {
|
||||
fill: #3b82f6;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Feature folder components (purple)
|
||||
.ComponentNode--feature {
|
||||
.ComponentNode__background {
|
||||
fill: #581c87;
|
||||
}
|
||||
.ComponentNode__border {
|
||||
stroke: #a855f7;
|
||||
}
|
||||
.ComponentNode__iconBackground {
|
||||
fill: #a855f7;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Integration folder components (green)
|
||||
.ComponentNode--integration {
|
||||
.ComponentNode__background {
|
||||
fill: #064e3b;
|
||||
}
|
||||
.ComponentNode__border {
|
||||
stroke: #10b981;
|
||||
}
|
||||
.ComponentNode__iconBackground {
|
||||
fill: #10b981;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// UI folder components (cyan)
|
||||
.ComponentNode--ui {
|
||||
.ComponentNode__background {
|
||||
fill: #164e63;
|
||||
}
|
||||
.ComponentNode__border {
|
||||
stroke: #06b6d4;
|
||||
}
|
||||
.ComponentNode__iconBackground {
|
||||
fill: #06b6d4;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility folder components (gray)
|
||||
.ComponentNode--utility {
|
||||
.ComponentNode__background {
|
||||
fill: #374151;
|
||||
}
|
||||
.ComponentNode__border {
|
||||
stroke: #6b7280;
|
||||
}
|
||||
.ComponentNode__iconBackground {
|
||||
fill: #6b7280;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Orphan folder components (yellow)
|
||||
.ComponentNode--orphan {
|
||||
.ComponentNode__background {
|
||||
fill: #422006;
|
||||
}
|
||||
.ComponentNode__border {
|
||||
stroke: #ca8a04;
|
||||
}
|
||||
.ComponentNode__iconBackground {
|
||||
fill: #ca8a04;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// App Component Special Styling (Gold/Amber border)
|
||||
// ============================================================================
|
||||
|
||||
.ComponentNode--app {
|
||||
.ComponentNode__border {
|
||||
stroke: #fbbf24; // Amber/gold color
|
||||
stroke-width: 3;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover .ComponentNode__border {
|
||||
stroke: #f59e0b; // Brighter amber on hover
|
||||
filter: drop-shadow(0 0 8px #fbbf24);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Selection State
|
||||
// ============================================================================
|
||||
|
||||
.ComponentNode--selected {
|
||||
.ComponentNode__border {
|
||||
stroke-width: 3;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.ComponentNode--page .ComponentNode__border {
|
||||
filter: drop-shadow(0 0 8px #3b82f6);
|
||||
}
|
||||
&.ComponentNode--feature .ComponentNode__border {
|
||||
filter: drop-shadow(0 0 8px #a855f7);
|
||||
}
|
||||
&.ComponentNode--integration .ComponentNode__border {
|
||||
filter: drop-shadow(0 0 8px #10b981);
|
||||
}
|
||||
&.ComponentNode--ui .ComponentNode__border {
|
||||
filter: drop-shadow(0 0 8px #06b6d4);
|
||||
}
|
||||
&.ComponentNode--utility .ComponentNode__border {
|
||||
filter: drop-shadow(0 0 8px #6b7280);
|
||||
}
|
||||
&.ComponentNode--orphan .ComponentNode__border {
|
||||
filter: drop-shadow(0 0 8px #ca8a04);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* ComponentNode Component
|
||||
*
|
||||
* Renders a single component node in the expanded folder view.
|
||||
* Styled as a card with color inherited from parent folder type.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
|
||||
import { Icon, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import { formatStatsShort, getComponentQuickStats } from '../utils/componentStats';
|
||||
import { getComponentIcon } from '../utils/folderColors';
|
||||
import { FolderType } from '../utils/topologyTypes';
|
||||
import css from './ComponentNode.module.scss';
|
||||
|
||||
export interface ComponentNodeProps {
|
||||
component: ComponentModel;
|
||||
folderType: FolderType;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
onClick?: (component: ComponentModel) => void;
|
||||
isSelected?: boolean;
|
||||
isAppComponent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits text into lines for multi-line display.
|
||||
* Tries to break at sensible points (/, ., -) when possible.
|
||||
*/
|
||||
function splitTextForDisplay(text: string, maxCharsPerLine: number = 20): string[] {
|
||||
if (text.length <= maxCharsPerLine) return [text];
|
||||
|
||||
const lines: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > maxCharsPerLine) {
|
||||
// Try to find a good break point
|
||||
let breakIndex = maxCharsPerLine;
|
||||
const slashIndex = remaining.lastIndexOf('/', maxCharsPerLine);
|
||||
const dotIndex = remaining.lastIndexOf('.', maxCharsPerLine);
|
||||
const dashIndex = remaining.lastIndexOf('-', maxCharsPerLine);
|
||||
|
||||
// Use the best break point found
|
||||
if (slashIndex > 0 && slashIndex > maxCharsPerLine - 10) {
|
||||
breakIndex = slashIndex + 1; // Include the slash in the current line
|
||||
} else if (dotIndex > 0 && dotIndex > maxCharsPerLine - 10) {
|
||||
breakIndex = dotIndex + 1;
|
||||
} else if (dashIndex > 0 && dashIndex > maxCharsPerLine - 10) {
|
||||
breakIndex = dashIndex + 1;
|
||||
}
|
||||
|
||||
lines.push(remaining.slice(0, breakIndex));
|
||||
remaining = remaining.slice(breakIndex);
|
||||
}
|
||||
|
||||
if (remaining) {
|
||||
lines.push(remaining);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the dynamic height needed for a component card based on text length.
|
||||
*/
|
||||
export function calculateComponentNodeHeight(componentName: string, baseHeight: number = 80): number {
|
||||
const lines = splitTextForDisplay(componentName);
|
||||
const extraLines = Math.max(0, lines.length - 1);
|
||||
const lineHeight = 14;
|
||||
return baseHeight + extraLines * lineHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a card-style component node with inherited folder colors
|
||||
*/
|
||||
export function ComponentNode({
|
||||
component,
|
||||
folderType,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
onClick,
|
||||
isSelected = false,
|
||||
isAppComponent = false
|
||||
}: ComponentNodeProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(component);
|
||||
};
|
||||
|
||||
const typeClass = css[`ComponentNode--${folderType}`] || '';
|
||||
const selectedClass = isSelected ? css['ComponentNode--selected'] : '';
|
||||
const appClass = isAppComponent ? css['ComponentNode--app'] : '';
|
||||
const nameLines = splitTextForDisplay(component.name);
|
||||
|
||||
// Calculate stats
|
||||
const stats = getComponentQuickStats(component);
|
||||
const statsText = formatStatsShort(stats);
|
||||
|
||||
// Get node list
|
||||
const nodeNames: string[] = [];
|
||||
component.graph.forEachNode((node) => {
|
||||
// Get the last part of the node type (e.g., "Group" from "noodl.visual.Group")
|
||||
const typeName = node.typename || node.type?.split('.').pop() || '';
|
||||
if (typeName) {
|
||||
nodeNames.push(typeName);
|
||||
}
|
||||
});
|
||||
const sortedNodeNames = [...new Set(nodeNames)].sort(); // Unique and sorted
|
||||
const displayNodeList = sortedNodeNames.slice(0, 5).join(', ');
|
||||
const remainingCount = sortedNodeNames.length - 5;
|
||||
|
||||
// Calculate layout
|
||||
const iconSize = 16;
|
||||
const padding = 12;
|
||||
const headerHeight = 36;
|
||||
const footerY = y + height - 40;
|
||||
const nodeListY = y + height - 22;
|
||||
const nameStartY = y + headerHeight / 2 + 2;
|
||||
|
||||
return (
|
||||
<g
|
||||
className={`${css['ComponentNode']} ${typeClass} ${selectedClass} ${appClass}`}
|
||||
onClick={handleClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{/* Card background */}
|
||||
<rect className={css['ComponentNode__background']} x={x} y={y} width={width} height={height} rx={8} />
|
||||
|
||||
{/* Card border */}
|
||||
<rect className={css['ComponentNode__border']} x={x} y={y} width={width} height={height} rx={8} />
|
||||
|
||||
{/* Header section with icon */}
|
||||
<g className={css['ComponentNode__header']}>
|
||||
{/* SVG Icon via foreignObject */}
|
||||
<foreignObject x={x + padding} y={y + (headerHeight - iconSize) / 2} width={iconSize} height={iconSize}>
|
||||
<Icon icon={getComponentIcon(component)} size={IconSize.Small} />
|
||||
</foreignObject>
|
||||
</g>
|
||||
|
||||
{/* Component name (multi-line) */}
|
||||
<text
|
||||
className={css['ComponentNode__name']}
|
||||
x={x + padding + iconSize + 8}
|
||||
y={nameStartY}
|
||||
fontSize="13"
|
||||
fontWeight="600"
|
||||
>
|
||||
{nameLines.map((line, index) => (
|
||||
<tspan key={index} x={x + padding + iconSize + 8} dy={index === 0 ? 0 : 14}>
|
||||
{line}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
|
||||
{/* X-Ray stats (footer) */}
|
||||
<text
|
||||
className={css['ComponentNode__stats']}
|
||||
x={x + width / 2}
|
||||
y={footerY}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="10"
|
||||
>
|
||||
{statsText}
|
||||
</text>
|
||||
|
||||
{/* Node list */}
|
||||
{sortedNodeNames.length > 0 && (
|
||||
<text className={css['ComponentNode__nodeList']} x={x + padding} y={nodeListY} fontSize="9">
|
||||
{displayNodeList}
|
||||
{remainingCount > 0 && ` +${remainingCount}`}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* FolderEdge Styles
|
||||
*
|
||||
* Styling for folder-to-folder connections.
|
||||
*/
|
||||
|
||||
.FolderEdge {
|
||||
stroke: #4b5563;
|
||||
fill: none;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* FolderEdge Component
|
||||
*
|
||||
* Renders a connection between two folders with:
|
||||
* - Gradient coloring (source folder color → target folder color)
|
||||
* - Variable thickness based on traffic
|
||||
* - Opacity based on connection strength
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { getFolderColor } from '../utils/folderColors';
|
||||
import { FolderConnection, FolderNode } from '../utils/topologyTypes';
|
||||
import css from './FolderEdge.module.scss';
|
||||
|
||||
interface FolderEdgeProps {
|
||||
connection: FolderConnection;
|
||||
fromFolder: FolderNode;
|
||||
toFolder: FolderNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates stroke width based on connection count.
|
||||
* Range: 1-4px
|
||||
*/
|
||||
function getStrokeWidth(count: number): number {
|
||||
if (count >= 30) return 4;
|
||||
if (count >= 20) return 3;
|
||||
if (count >= 10) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates opacity based on connection count.
|
||||
* Range: 0.3-0.7
|
||||
*/
|
||||
function getOpacity(count: number): number {
|
||||
const baseOpacity = 0.3;
|
||||
const maxOpacity = 0.7;
|
||||
const normalized = Math.min(count / 50, 1); // Normalize to 0-1
|
||||
return baseOpacity + normalized * (maxOpacity - baseOpacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a connection line between two folder nodes with gradient coloring.
|
||||
*/
|
||||
export function FolderEdge({ connection, fromFolder, toFolder }: FolderEdgeProps) {
|
||||
if (
|
||||
!fromFolder.x ||
|
||||
!fromFolder.y ||
|
||||
!fromFolder.width ||
|
||||
!fromFolder.height ||
|
||||
!toFolder.x ||
|
||||
!toFolder.y ||
|
||||
!toFolder.width ||
|
||||
!toFolder.height
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate connection points (center of each node)
|
||||
const x1 = fromFolder.x + fromFolder.width / 2;
|
||||
const y1 = fromFolder.y + fromFolder.height;
|
||||
const x2 = toFolder.x + toFolder.width / 2;
|
||||
const y2 = toFolder.y;
|
||||
|
||||
const strokeWidth = getStrokeWidth(connection.count);
|
||||
const opacity = getOpacity(connection.count);
|
||||
|
||||
// Get colors for gradient (source folder → target folder)
|
||||
const fromColor = getFolderColor(fromFolder.type);
|
||||
const toColor = getFolderColor(toFolder.type);
|
||||
|
||||
// Create unique gradient ID based on folder IDs
|
||||
const gradientId = `folder-edge-${fromFolder.id}-${toFolder.id}`;
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* Define gradient */}
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={fromColor} stopOpacity={opacity} />
|
||||
<stop offset="100%" stopColor={toColor} stopOpacity={opacity} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Render line with gradient */}
|
||||
<line
|
||||
className={css['FolderEdge']}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke={`url(#${gradientId})`}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* FolderNode Styles
|
||||
*
|
||||
* Color-coded styling for folder nodes based on type.
|
||||
*/
|
||||
|
||||
.FolderNode {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
.FolderNode__border {
|
||||
opacity: 1;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.FolderNode__background {
|
||||
filter: brightness(1.1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.FolderNode__background {
|
||||
fill: var(--theme-color-bg-4);
|
||||
opacity: 0.9;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.FolderNode__border {
|
||||
fill: none;
|
||||
stroke: var(--theme-color-border-default);
|
||||
stroke-width: 2;
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.FolderNode__path {
|
||||
fill: var(--theme-color-fg-default);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.FolderNode__nameWrapper {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.FolderNode__componentList {
|
||||
fill: var(--theme-color-fg-default);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.FolderNode__componentListWrapper {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
opacity: 0.8;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.FolderNode__connections {
|
||||
fill: var(--theme-color-fg-default);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.FolderNode__count {
|
||||
fill: var(--theme-color-fg-default-shy);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Folder Type Colors (from spec)
|
||||
// ============================================================================
|
||||
|
||||
// Page folders (blue)
|
||||
.FolderNode--page {
|
||||
.FolderNode__background {
|
||||
fill: #1e3a8a;
|
||||
}
|
||||
.FolderNode__border {
|
||||
stroke: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
// Feature folders (purple)
|
||||
.FolderNode--feature {
|
||||
.FolderNode__background {
|
||||
fill: #581c87;
|
||||
}
|
||||
.FolderNode__border {
|
||||
stroke: #a855f7;
|
||||
}
|
||||
}
|
||||
|
||||
// Integration folders (green)
|
||||
.FolderNode--integration {
|
||||
.FolderNode__background {
|
||||
fill: #064e3b;
|
||||
}
|
||||
.FolderNode__border {
|
||||
stroke: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
// UI folders (cyan)
|
||||
.FolderNode--ui {
|
||||
.FolderNode__background {
|
||||
fill: #164e63;
|
||||
}
|
||||
.FolderNode__border {
|
||||
stroke: #06b6d4;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility folders (gray)
|
||||
.FolderNode--utility {
|
||||
.FolderNode__background {
|
||||
fill: #374151;
|
||||
}
|
||||
.FolderNode__border {
|
||||
stroke: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
// Orphan folders (yellow with dashed border)
|
||||
.FolderNode--orphan {
|
||||
.FolderNode__background {
|
||||
fill: #422006;
|
||||
}
|
||||
.FolderNode__border {
|
||||
stroke: #ca8a04;
|
||||
stroke-dasharray: 4;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Selection State
|
||||
// ============================================================================
|
||||
|
||||
.FolderNode--selected {
|
||||
.FolderNode__border {
|
||||
stroke-width: 3;
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 10px currentColor);
|
||||
}
|
||||
|
||||
&.FolderNode--page .FolderNode__border {
|
||||
filter: drop-shadow(0 0 10px #3b82f6);
|
||||
}
|
||||
&.FolderNode--feature .FolderNode__border {
|
||||
filter: drop-shadow(0 0 10px #a855f7);
|
||||
}
|
||||
&.FolderNode--integration .FolderNode__border {
|
||||
filter: drop-shadow(0 0 10px #10b981);
|
||||
}
|
||||
&.FolderNode--ui .FolderNode__border {
|
||||
filter: drop-shadow(0 0 10px #06b6d4);
|
||||
}
|
||||
&.FolderNode--utility .FolderNode__border {
|
||||
filter: drop-shadow(0 0 10px #6b7280);
|
||||
}
|
||||
&.FolderNode--orphan .FolderNode__border {
|
||||
filter: drop-shadow(0 0 10px #ca8a04);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* FolderNode Component
|
||||
*
|
||||
* Renders a folder node in the topology map with color-coded styling.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Icon, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import { calculateFolderSectionPositions } from '../utils/folderCardHeight';
|
||||
import { getFolderIcon } from '../utils/folderColors';
|
||||
import { FolderNode as FolderNodeType } from '../utils/topologyTypes';
|
||||
import css from './FolderNode.module.scss';
|
||||
|
||||
interface FolderNodeProps {
|
||||
folder: FolderNodeType;
|
||||
isSelected?: boolean;
|
||||
onClick?: (folder: FolderNodeType) => void;
|
||||
onDoubleClick?: (folder: FolderNodeType) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a folder node with appropriate styling based on folder type.
|
||||
*/
|
||||
export function FolderNode({ folder, isSelected = false, onClick, onDoubleClick }: FolderNodeProps) {
|
||||
if (!folder.x || !folder.y || !folder.width || !folder.height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(folder);
|
||||
};
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDoubleClick?.(folder);
|
||||
};
|
||||
|
||||
const typeClassName = css[`FolderNode--${folder.type}`] || '';
|
||||
const selectedClassName = isSelected ? css['FolderNode--selected'] : '';
|
||||
|
||||
// Calculate dynamic positions for each section
|
||||
const positions = calculateFolderSectionPositions(folder);
|
||||
|
||||
return (
|
||||
<g
|
||||
className={`${css['FolderNode']} ${typeClassName} ${selectedClassName}`}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{/* Background rectangle */}
|
||||
<rect
|
||||
className={css['FolderNode__background']}
|
||||
x={folder.x}
|
||||
y={folder.y}
|
||||
width={folder.width}
|
||||
height={folder.height}
|
||||
rx={8}
|
||||
/>
|
||||
|
||||
{/* Border rectangle */}
|
||||
<rect
|
||||
className={css['FolderNode__border']}
|
||||
x={folder.x}
|
||||
y={folder.y}
|
||||
width={folder.width}
|
||||
height={folder.height}
|
||||
rx={8}
|
||||
/>
|
||||
|
||||
{/* Icon (SVG embedded via foreignObject) */}
|
||||
<foreignObject x={folder.x + 12} y={positions.iconY} width={20} height={20}>
|
||||
<Icon icon={getFolderIcon(folder.type)} size={IconSize.Default} />
|
||||
</foreignObject>
|
||||
|
||||
{/* Folder name - wrapped in foreignObject for proper text wrapping */}
|
||||
<foreignObject x={folder.x + 38} y={positions.titleY} width={folder.width - 50} height={positions.titleHeight}>
|
||||
<div className={css['FolderNode__nameWrapper']}>{folder.name}</div>
|
||||
</foreignObject>
|
||||
|
||||
{/* Component names preview - wrapped in foreignObject for text wrapping */}
|
||||
{folder.componentNames.length > 0 && (
|
||||
<foreignObject x={folder.x + 12} y={positions.componentListY} width={folder.width - 24} height={30}>
|
||||
<div className={css['FolderNode__componentListWrapper']}>
|
||||
{folder.componentNames.slice(0, 3).join(', ')}
|
||||
{folder.componentNames.length > 3 && `, +${folder.componentNames.length - 3} more`}
|
||||
</div>
|
||||
</foreignObject>
|
||||
)}
|
||||
|
||||
{/* Connection stats */}
|
||||
<text className={css['FolderNode__connections']} x={folder.x + 12} y={positions.statsY} fontSize="11">
|
||||
{folder.connectionCount.incoming} in • {folder.connectionCount.outgoing} out
|
||||
</text>
|
||||
|
||||
{/* Component count */}
|
||||
<text
|
||||
className={css['FolderNode__count']}
|
||||
x={folder.x + folder.width / 2}
|
||||
y={positions.countY}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{folder.componentCount} component{folder.componentCount !== 1 ? 's' : ''}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,36 @@
|
||||
/**
|
||||
* TopologyMapView Component
|
||||
*
|
||||
* Main SVG visualization container for the topology map.
|
||||
* Handles rendering nodes, edges, pan/zoom, and user interaction.
|
||||
* Main SVG visualization container for the folder-based topology map.
|
||||
* Handles rendering folder nodes, edges, pan/zoom, and user interaction.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
|
||||
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import { PositionedTopologyGraph, TopologyNode as TopologyNodeType } from '../utils/topologyTypes';
|
||||
import { TopologyEdge, TopologyEdgeMarkerDef } from './TopologyEdge';
|
||||
import { PositionedFolderGraph, FolderNode as FolderNodeType, TopologyViewState } from '../utils/topologyTypes';
|
||||
import { ComponentNode, calculateComponentNodeHeight } from './ComponentNode';
|
||||
import { FolderEdge } from './FolderEdge';
|
||||
import { FolderNode } from './FolderNode';
|
||||
import css from './TopologyMapView.module.scss';
|
||||
import { TopologyNode } from './TopologyNode';
|
||||
|
||||
export interface TopologyMapViewProps {
|
||||
graph: PositionedTopologyGraph;
|
||||
onNodeClick?: (node: TopologyNodeType) => void;
|
||||
onNodeHover?: (node: TopologyNodeType | null) => void;
|
||||
graph: PositionedFolderGraph;
|
||||
viewState: TopologyViewState;
|
||||
selectedFolderId: string | null;
|
||||
onFolderClick?: (folder: FolderNodeType) => void;
|
||||
onFolderDoubleClick?: (folder: FolderNodeType) => void;
|
||||
isLegendOpen?: boolean;
|
||||
onLegendToggle?: () => void;
|
||||
}
|
||||
|
||||
export function TopologyMapView({
|
||||
graph,
|
||||
onNodeClick,
|
||||
onNodeHover,
|
||||
viewState,
|
||||
selectedFolderId,
|
||||
onFolderClick,
|
||||
onFolderDoubleClick,
|
||||
isLegendOpen,
|
||||
onLegendToggle
|
||||
}: TopologyMapViewProps) {
|
||||
@@ -35,7 +40,7 @@ export function TopologyMapView({
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
// Handle mouse wheel for zoom (zoom to cursor position)
|
||||
// Handle mouse wheel for zoom
|
||||
const handleWheel = (e: React.WheelEvent<SVGSVGElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -53,7 +58,6 @@ export function TopologyMapView({
|
||||
const newScale = Math.max(0.1, Math.min(3, scale * delta));
|
||||
|
||||
// Calculate new pan to keep mouse position stable
|
||||
// Formula: new_pan = mouse_pos - (mouse_pos - old_pan) * (new_scale / old_scale)
|
||||
const scaleRatio = newScale / scale;
|
||||
const newPan = {
|
||||
x: mouseX - (mouseX - pan.x) * scaleRatio,
|
||||
@@ -67,7 +71,6 @@ export function TopologyMapView({
|
||||
// Handle panning
|
||||
const handleMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (e.button === 0 && e.target === svgRef.current) {
|
||||
// Only start panning if clicking on the background
|
||||
setIsPanning(true);
|
||||
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
|
||||
}
|
||||
@@ -114,12 +117,105 @@ export function TopologyMapView({
|
||||
setPan(newPan);
|
||||
};
|
||||
|
||||
// Node lookup map for edges
|
||||
const nodeMap = new Map<string, TopologyNodeType>();
|
||||
graph.nodes.forEach((node) => {
|
||||
nodeMap.set(node.fullName, node);
|
||||
// Folder lookup map for edges
|
||||
const folderMap = new Map<string, FolderNodeType>();
|
||||
graph.folders.forEach((folder) => {
|
||||
folderMap.set(folder.id, folder);
|
||||
});
|
||||
|
||||
// Get expanded folder if in expanded mode
|
||||
const expandedFolder =
|
||||
viewState.mode === 'expanded' ? graph.folders.find((f) => f.id === viewState.expandedFolderId) : null;
|
||||
|
||||
// Render expanded view (component-level)
|
||||
if (viewState.mode === 'expanded' && expandedFolder) {
|
||||
const components = expandedFolder.components;
|
||||
const componentsPerRow = 3;
|
||||
const nodeWidth = 180;
|
||||
const gap = 40;
|
||||
|
||||
// Calculate component positions with dynamic heights
|
||||
const componentLayouts = components.map((component, i) => {
|
||||
const dynamicHeight = calculateComponentNodeHeight(component.name);
|
||||
const row = Math.floor(i / componentsPerRow);
|
||||
const col = i % componentsPerRow;
|
||||
|
||||
// Calculate Y position based on previous rows' max heights
|
||||
let y = 50;
|
||||
for (let r = 0; r < row; r++) {
|
||||
const rowStart = r * componentsPerRow;
|
||||
const rowEnd = Math.min(rowStart + componentsPerRow, components.length);
|
||||
const rowComponents = components.slice(rowStart, rowEnd);
|
||||
const maxRowHeight = Math.max(...rowComponents.map((c) => calculateComponentNodeHeight(c.name)));
|
||||
y += maxRowHeight + gap;
|
||||
}
|
||||
|
||||
const x = 50 + col * (nodeWidth + gap);
|
||||
|
||||
return { component, x, y, height: dynamicHeight };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={css['TopologyMapView']}>
|
||||
{/* Controls */}
|
||||
<div className={css['TopologyMapView__controls']}>
|
||||
<button onClick={fitToView} className={css['TopologyMapView__button']} title="Fit to view">
|
||||
Fit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScale((prev) => Math.min(3, prev * 1.2))}
|
||||
className={css['TopologyMapView__button']}
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScale((prev) => Math.max(0.1, prev / 1.2))}
|
||||
className={css['TopologyMapView__button']}
|
||||
title="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className={css['TopologyMapView__zoom']}>{Math.round(scale * 100)}%</span>
|
||||
</div>
|
||||
|
||||
{/* SVG Canvas for expanded view */}
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className={css['TopologyMapView__svg']}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
cursor: isPanning ? 'grabbing' : 'grab'
|
||||
}}
|
||||
>
|
||||
<g transform={`translate(${pan.x}, ${pan.y}) scale(${scale})`}>
|
||||
{/* Render components in a grid with dynamic heights */}
|
||||
{componentLayouts.map((layout) => (
|
||||
<ComponentNode
|
||||
key={layout.component.fullName}
|
||||
component={layout.component}
|
||||
folderType={expandedFolder.type}
|
||||
x={layout.x}
|
||||
y={layout.y}
|
||||
width={nodeWidth}
|
||||
height={layout.height}
|
||||
isSelected={viewState.selectedComponentId === layout.component.fullName}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Stats footer */}
|
||||
<div className={css['TopologyMapView__footer']}>📦 {components.length} components in this folder</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render overview (folder-level) - default view
|
||||
return (
|
||||
<div className={css['TopologyMapView']}>
|
||||
{/* Controls */}
|
||||
@@ -165,33 +261,35 @@ export function TopologyMapView({
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendContent']}>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span
|
||||
className={css['TopologyMapView__legendColor']}
|
||||
style={{ borderColor: 'var(--theme-color-primary)', boxShadow: '0 0 8px var(--theme-color-primary)' }}
|
||||
></span>
|
||||
<span>Current Component (blue glow)</span>
|
||||
<span className={css['TopologyMapView__legendColor']} style={{ borderColor: '#3b82f6' }}></span>
|
||||
<span>📄 Pages (entry points)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span className={css['TopologyMapView__legendColor']} style={{ borderColor: '#a855f7' }}></span>
|
||||
<span>📝 Features (domain logic)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span className={css['TopologyMapView__legendColor']} style={{ borderColor: '#10b981' }}></span>
|
||||
<span>🔗 Integrations (external services)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span className={css['TopologyMapView__legendColor']} style={{ borderColor: '#06b6d4' }}></span>
|
||||
<span>🎨 UI (shared components)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span className={css['TopologyMapView__legendColor']} style={{ borderColor: '#6b7280' }}></span>
|
||||
<span>⚙️ Utilities (foundation)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span
|
||||
className={css['TopologyMapView__legendColor']}
|
||||
style={{ borderColor: 'var(--theme-color-primary)', borderWidth: '2.5px' }}
|
||||
style={{ borderColor: '#ca8a04', borderStyle: 'dashed' }}
|
||||
></span>
|
||||
<span>Page Component (blue border + shadow)</span>
|
||||
<span>⚠️ Orphans (unused)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span className={css['TopologyMapView__legendColor']} style={{ borderColor: '#f5a623' }}></span>
|
||||
<span>Shared Component (orange/gold border)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span
|
||||
className={css['TopologyMapView__legendColor']}
|
||||
style={{ borderColor: 'var(--theme-color-warning)', borderStyle: 'dashed' }}
|
||||
></span>
|
||||
<span>Orphan Component (yellow dashed - unused)</span>
|
||||
</div>
|
||||
<div className={css['TopologyMapView__legendItem']}>
|
||||
<span className={css['TopologyMapView__legendBadge']}>×3</span>
|
||||
<span>Usage count badge</span>
|
||||
<div className={css['TopologyMapView__legendDivider']} />
|
||||
<div className={css['TopologyMapView__legendHint']}>
|
||||
<strong>Tip:</strong> Double-click a folder to expand and see its components
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,36 +308,79 @@ export function TopologyMapView({
|
||||
cursor: isPanning ? 'grabbing' : 'grab'
|
||||
}}
|
||||
>
|
||||
<TopologyEdgeMarkerDef />
|
||||
|
||||
<g transform={`translate(${pan.x}, ${pan.y}) scale(${scale})`}>
|
||||
{/* Render edges first (behind nodes) */}
|
||||
{graph.edges.map((edge, i) => (
|
||||
<TopologyEdge
|
||||
key={`${edge.from}-${edge.to}-${i}`}
|
||||
edge={edge}
|
||||
fromNode={nodeMap.get(edge.from)}
|
||||
toNode={nodeMap.get(edge.to)}
|
||||
{graph.connections.map((connection, i) => {
|
||||
const fromFolder = folderMap.get(connection.from);
|
||||
const toFolder = folderMap.get(connection.to);
|
||||
if (!fromFolder || !toFolder) return null;
|
||||
|
||||
return (
|
||||
<FolderEdge
|
||||
key={`${connection.from}-${connection.to}-${i}`}
|
||||
connection={connection}
|
||||
fromFolder={fromFolder}
|
||||
toFolder={toFolder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Render top-level components (pages) */}
|
||||
{graph.topLevelComponents.map((topLevel) => (
|
||||
<ComponentNode
|
||||
key={topLevel.component.fullName}
|
||||
component={topLevel.component}
|
||||
folderType="page"
|
||||
x={topLevel.x!}
|
||||
y={topLevel.y!}
|
||||
width={topLevel.width!}
|
||||
height={topLevel.height!}
|
||||
isSelected={viewState.selectedComponentId === topLevel.component.fullName}
|
||||
isAppComponent={topLevel.isAppComponent}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Render nodes */}
|
||||
{graph.nodes.map((node) => (
|
||||
<TopologyNode
|
||||
key={node.fullName}
|
||||
node={node}
|
||||
onClick={(n) => onNodeClick?.(n)}
|
||||
onMouseEnter={(n) => onNodeHover?.(n)}
|
||||
onMouseLeave={() => onNodeHover?.(null)}
|
||||
{/* Render folder nodes */}
|
||||
{graph.folders.map((folder) => (
|
||||
<FolderNode
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
isSelected={folder.id === selectedFolderId}
|
||||
onClick={onFolderClick}
|
||||
onDoubleClick={onFolderDoubleClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Render orphan indicator if there are orphans */}
|
||||
{graph.orphanComponents.length > 0 && (
|
||||
<g>
|
||||
<rect
|
||||
x={50}
|
||||
y={700}
|
||||
width={140}
|
||||
height={50}
|
||||
rx={8}
|
||||
fill="#422006"
|
||||
stroke="#ca8a04"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4"
|
||||
opacity={0.6}
|
||||
/>
|
||||
<text x={120} y={720} textAnchor="middle" fill="#fcd34d" fontSize="13" fontWeight="600">
|
||||
⚠️ Orphans
|
||||
</text>
|
||||
<text x={120} y={738} textAnchor="middle" fill="#ca8a04" fontSize="11">
|
||||
{graph.orphanComponents.length} unused
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Stats footer */}
|
||||
<div className={css['TopologyMapView__footer']}>
|
||||
📊 {graph.totalNodes} components total | {graph.counts.pages} pages | {graph.counts.shared} shared
|
||||
{graph.counts.orphans > 0 && ` | ⚠️ ${graph.counts.orphans} orphans`}
|
||||
📊 {graph.totalFolders} folders • {graph.totalComponents} components
|
||||
{graph.orphanComponents.length > 0 && ` • ⚠️ ${graph.orphanComponents.length} orphans`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* useDraggable Hook
|
||||
*
|
||||
* Provides drag-and-drop functionality for SVG elements with snap-to-grid.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { snapPositionToGrid } from '../utils/snapToGrid';
|
||||
|
||||
export interface DraggableState {
|
||||
isDragging: boolean;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
}
|
||||
|
||||
export interface UseDraggableOptions {
|
||||
initialX: number;
|
||||
initialY: number;
|
||||
onDragEnd?: (x: number, y: number) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseDraggableResult {
|
||||
isDragging: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
handleMouseDown: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for making SVG elements draggable with snap-to-grid.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns Drag state and handlers
|
||||
*/
|
||||
export function useDraggable({
|
||||
initialX,
|
||||
initialY,
|
||||
onDragEnd,
|
||||
enabled = true
|
||||
}: UseDraggableOptions): UseDraggableResult {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [currentX, setCurrentX] = useState(initialX);
|
||||
const [currentY, setCurrentY] = useState(initialY);
|
||||
|
||||
const dragStartRef = useRef<{ x: number; y: number; mouseX: number; mouseY: number } | null>(null);
|
||||
|
||||
// Update position when initial position changes (from layout)
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setCurrentX(initialX);
|
||||
setCurrentY(initialY);
|
||||
}
|
||||
}, [initialX, initialY, isDragging]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!enabled) return;
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = {
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY
|
||||
};
|
||||
},
|
||||
[enabled, currentX, currentY]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging || !dragStartRef.current) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
|
||||
const dx = e.clientX - dragStartRef.current.mouseX;
|
||||
const dy = e.clientY - dragStartRef.current.mouseY;
|
||||
|
||||
setCurrentX(dragStartRef.current.x + dx);
|
||||
setCurrentY(dragStartRef.current.y + dy);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!dragStartRef.current) return;
|
||||
|
||||
// Snap to grid
|
||||
const snapped = snapPositionToGrid(currentX, currentY);
|
||||
|
||||
setCurrentX(snapped.x);
|
||||
setCurrentY(snapped.y);
|
||||
setIsDragging(false);
|
||||
|
||||
// Notify parent
|
||||
onDragEnd?.(snapped.x, snapped.y);
|
||||
|
||||
dragStartRef.current = null;
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, currentX, currentY, onDragEnd]);
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
handleMouseDown
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* useFolderGraph Hook
|
||||
*
|
||||
* Builds the folder-level topology graph from the current project.
|
||||
* This replaces useTopologyGraph for the folder-first architecture.
|
||||
*/
|
||||
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import { buildFolderGraph } from '../utils/folderAggregation';
|
||||
import { FolderGraph } from '../utils/topologyTypes';
|
||||
|
||||
/**
|
||||
* Hook that builds and returns the folder graph for the current project.
|
||||
*
|
||||
* @returns The complete folder graph with folders, connections, and metadata
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const folderGraph = useFolderGraph();
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <p>Total folders: {folderGraph.totalFolders}</p>
|
||||
* <p>Total components: {folderGraph.totalComponents}</p>
|
||||
* <p>Orphans: {folderGraph.orphanComponents.length}</p>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useFolderGraph(): FolderGraph {
|
||||
const project = ProjectModel.instance;
|
||||
const [updateTrigger, setUpdateTrigger] = useState(0);
|
||||
|
||||
// Rebuild graph when components change
|
||||
useEventListener(ProjectModel.instance, 'componentAdded', () => {
|
||||
setUpdateTrigger((prev) => prev + 1);
|
||||
});
|
||||
|
||||
useEventListener(ProjectModel.instance, 'componentRemoved', () => {
|
||||
setUpdateTrigger((prev) => prev + 1);
|
||||
});
|
||||
|
||||
useEventListener(ProjectModel.instance, 'componentRenamed', () => {
|
||||
setUpdateTrigger((prev) => prev + 1);
|
||||
});
|
||||
|
||||
const folderGraph = useMemo<FolderGraph>(() => {
|
||||
console.log('[useFolderGraph] Building folder graph...');
|
||||
const graph = buildFolderGraph(project);
|
||||
|
||||
// Log summary
|
||||
console.log(`[useFolderGraph] Summary:`);
|
||||
console.log(` - ${graph.totalFolders} folders`);
|
||||
console.log(` - ${graph.totalComponents} components`);
|
||||
console.log(` - ${graph.connections.length} folder connections`);
|
||||
console.log(` - ${graph.orphanComponents.length} orphans`);
|
||||
|
||||
// Log folder breakdown by type
|
||||
const typeBreakdown = graph.folders.reduce((acc, folder) => {
|
||||
acc[folder.type] = (acc[folder.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
console.log(`[useFolderGraph] Folder types:`, typeBreakdown);
|
||||
|
||||
return graph;
|
||||
}, [project, updateTrigger]);
|
||||
|
||||
return folderGraph;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* useFolderLayout Hook
|
||||
*
|
||||
* Applies tiered layout algorithm to folder nodes.
|
||||
* Replaces dagre auto-layout with semantic positioning.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { calculateFolderHeight } from '../utils/folderCardHeight';
|
||||
import { getTierYPosition } from '../utils/tierAssignment';
|
||||
import { FolderGraph, FolderNode, PositionedFolderGraph } from '../utils/topologyTypes';
|
||||
|
||||
/**
|
||||
* Layout configuration
|
||||
*/
|
||||
const LAYOUT_CONFIG = {
|
||||
NODE_WIDTH: 140,
|
||||
NODE_HEIGHT: 110,
|
||||
COMPONENT_WIDTH: 140,
|
||||
COMPONENT_HEIGHT: 110,
|
||||
HORIZONTAL_SPACING: 60,
|
||||
TIER_MARGIN: 150,
|
||||
ORPHAN_X: 50,
|
||||
ORPHAN_Y: 700
|
||||
};
|
||||
|
||||
/**
|
||||
* Positions folders in horizontal tiers based on their semantic tier.
|
||||
*
|
||||
* @param folderGraph The folder graph to layout
|
||||
* @returns Positioned folder graph with x,y coordinates
|
||||
*/
|
||||
function layoutFolderGraph(folderGraph: FolderGraph): PositionedFolderGraph {
|
||||
// Position top-level components on tier 0
|
||||
const tier0Y = getTierYPosition(0);
|
||||
let currentX = LAYOUT_CONFIG.TIER_MARGIN;
|
||||
|
||||
// Position top-level components first (they go on tier 0)
|
||||
folderGraph.topLevelComponents.forEach((topLevel) => {
|
||||
topLevel.x = currentX;
|
||||
topLevel.y = tier0Y;
|
||||
topLevel.width = LAYOUT_CONFIG.COMPONENT_WIDTH;
|
||||
topLevel.height = LAYOUT_CONFIG.COMPONENT_HEIGHT;
|
||||
currentX += LAYOUT_CONFIG.COMPONENT_WIDTH + LAYOUT_CONFIG.HORIZONTAL_SPACING;
|
||||
});
|
||||
|
||||
// Group folders by tier
|
||||
const foldersByTier = new Map<number, FolderNode[]>();
|
||||
for (const folder of folderGraph.folders) {
|
||||
if (!foldersByTier.has(folder.tier)) {
|
||||
foldersByTier.set(folder.tier, []);
|
||||
}
|
||||
foldersByTier.get(folder.tier)!.push(folder);
|
||||
}
|
||||
|
||||
// Position folders within each tier
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
// Track bounds from top-level components
|
||||
if (folderGraph.topLevelComponents.length > 0) {
|
||||
minX = LAYOUT_CONFIG.TIER_MARGIN;
|
||||
maxX = currentX - LAYOUT_CONFIG.HORIZONTAL_SPACING + LAYOUT_CONFIG.COMPONENT_WIDTH;
|
||||
minY = tier0Y;
|
||||
maxY = tier0Y + LAYOUT_CONFIG.COMPONENT_HEIGHT;
|
||||
}
|
||||
|
||||
for (const [tier, folders] of foldersByTier.entries()) {
|
||||
const y = getTierYPosition(tier);
|
||||
|
||||
// Start position for this tier
|
||||
// If tier 0, continue after top-level components
|
||||
const startX = tier === 0 ? currentX : LAYOUT_CONFIG.TIER_MARGIN;
|
||||
|
||||
folders.forEach((folder, index) => {
|
||||
folder.x = startX + index * (LAYOUT_CONFIG.NODE_WIDTH + LAYOUT_CONFIG.HORIZONTAL_SPACING);
|
||||
folder.y = y;
|
||||
folder.width = LAYOUT_CONFIG.NODE_WIDTH;
|
||||
// Calculate dynamic height based on content
|
||||
folder.height = calculateFolderHeight(folder);
|
||||
|
||||
// Track bounds
|
||||
minX = Math.min(minX, folder.x);
|
||||
maxX = Math.max(maxX, folder.x + folder.width);
|
||||
minY = Math.min(minY, folder.y);
|
||||
maxY = Math.max(maxY, folder.y + folder.height);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle orphans separately (bottom-left corner)
|
||||
if (folderGraph.orphanComponents.length > 0) {
|
||||
minX = Math.min(minX, LAYOUT_CONFIG.ORPHAN_X);
|
||||
maxX = Math.max(maxX, LAYOUT_CONFIG.ORPHAN_X + LAYOUT_CONFIG.NODE_WIDTH);
|
||||
minY = Math.min(minY, LAYOUT_CONFIG.ORPHAN_Y);
|
||||
maxY = Math.max(maxY, LAYOUT_CONFIG.ORPHAN_Y + LAYOUT_CONFIG.NODE_HEIGHT);
|
||||
}
|
||||
|
||||
// Add padding to bounds
|
||||
const padding = 50;
|
||||
const bounds = {
|
||||
x: minX - padding,
|
||||
y: minY - padding,
|
||||
width: maxX - minX + padding * 2,
|
||||
height: maxY - minY + padding * 2
|
||||
};
|
||||
|
||||
return {
|
||||
...folderGraph,
|
||||
folders: folderGraph.folders,
|
||||
bounds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that applies layout to a folder graph.
|
||||
*
|
||||
* @param folderGraph The folder graph to layout
|
||||
* @returns Positioned folder graph with coordinates
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const folderGraph = useFolderGraph();
|
||||
* const positionedGraph = useFolderLayout(folderGraph);
|
||||
*
|
||||
* return (
|
||||
* <svg viewBox={`0 0 ${positionedGraph.bounds.width} ${positionedGraph.bounds.height}`}>
|
||||
* {positionedGraph.folders.map(folder => (
|
||||
* <rect key={folder.id} x={folder.x} y={folder.y} width={folder.width} height={folder.height} />
|
||||
* ))}
|
||||
* </svg>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useFolderLayout(folderGraph: FolderGraph): PositionedFolderGraph {
|
||||
const positionedGraph = useMemo(() => {
|
||||
console.log('[useFolderLayout] Applying tiered layout...');
|
||||
const positioned = layoutFolderGraph(folderGraph);
|
||||
|
||||
console.log(`[useFolderLayout] Bounds: ${positioned.bounds.width}x${positioned.bounds.height}`);
|
||||
console.log(`[useFolderLayout] Positioned ${positioned.folders.length} folders`);
|
||||
|
||||
return positioned;
|
||||
}, [folderGraph]);
|
||||
|
||||
return positionedGraph;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Component Stats Utility
|
||||
*
|
||||
* Lightweight extraction of component statistics for display in Topology Map.
|
||||
* Optimized for performance - doesn't compute full X-Ray data.
|
||||
*/
|
||||
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
|
||||
|
||||
/**
|
||||
* Quick stats for a component
|
||||
*/
|
||||
export interface ComponentQuickStats {
|
||||
nodeCount: number;
|
||||
subcomponentCount: number;
|
||||
hasRestCalls: boolean;
|
||||
hasEvents: boolean;
|
||||
hasState: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract lightweight stats from a component.
|
||||
* Used for quick display in topology cards.
|
||||
*/
|
||||
export function getComponentQuickStats(component: ComponentModel): ComponentQuickStats {
|
||||
let nodeCount = 0;
|
||||
let subcomponentCount = 0;
|
||||
let hasRestCalls = false;
|
||||
let hasEvents = false;
|
||||
let hasState = false;
|
||||
|
||||
component.graph.forEachNode((node: NodeGraphNode) => {
|
||||
nodeCount++;
|
||||
|
||||
// Check for subcomponents
|
||||
if (node.type instanceof ComponentModel) {
|
||||
subcomponentCount++;
|
||||
}
|
||||
|
||||
// Check for REST calls
|
||||
if (node.typename === 'REST' || node.typename === 'REST2') {
|
||||
hasRestCalls = true;
|
||||
}
|
||||
|
||||
// Check for events
|
||||
if (node.typename === 'Send Event' || node.typename === 'Receive Event') {
|
||||
hasEvents = true;
|
||||
}
|
||||
|
||||
// Check for state
|
||||
if (
|
||||
node.typename === 'Variable' ||
|
||||
node.typename === 'Variable2' ||
|
||||
node.typename === 'Object' ||
|
||||
node.typename === 'States'
|
||||
) {
|
||||
hasState = true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
nodeCount,
|
||||
subcomponentCount,
|
||||
hasRestCalls,
|
||||
hasEvents,
|
||||
hasState
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format stats as a short display string.
|
||||
* Example: "24 nodes • 3 sub • REST • Events"
|
||||
*/
|
||||
export function formatStatsShort(stats: ComponentQuickStats): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`${stats.nodeCount} nodes`);
|
||||
|
||||
if (stats.subcomponentCount > 0) {
|
||||
parts.push(`${stats.subcomponentCount} sub`);
|
||||
}
|
||||
|
||||
if (stats.hasRestCalls) {
|
||||
parts.push('REST');
|
||||
}
|
||||
|
||||
if (stats.hasEvents) {
|
||||
parts.push('Events');
|
||||
}
|
||||
|
||||
if (stats.hasState) {
|
||||
parts.push('State');
|
||||
}
|
||||
|
||||
return parts.join(' • ');
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Folder Aggregation Utilities
|
||||
*
|
||||
* Functions to group components into folders and build folder-level connections.
|
||||
*/
|
||||
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
import { buildComponentDependencyGraph } from '@noodl-utils/graphAnalysis';
|
||||
|
||||
import { detectFolderType } from './folderTypeDetection';
|
||||
import { assignFolderTier } from './tierAssignment';
|
||||
import { FolderNode, FolderConnection, FolderGraph, TopLevelComponent } from './topologyTypes';
|
||||
|
||||
/**
|
||||
* Extracts the folder path from a component's full name.
|
||||
*
|
||||
* Examples:
|
||||
* - "/#Directus/Query" → "/#Directus"
|
||||
* - "/App" → "/" (root)
|
||||
* - "MyComponent" → "/" (root)
|
||||
*
|
||||
* @param componentFullName The full component path
|
||||
* @returns The folder path
|
||||
*/
|
||||
export function extractFolderPath(componentFullName: string): string {
|
||||
// Handle root-level components
|
||||
if (!componentFullName.includes('/')) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
// Find the last slash
|
||||
const lastSlashIndex = componentFullName.lastIndexOf('/');
|
||||
|
||||
if (lastSlashIndex === 0) {
|
||||
// Component is at root like "/App"
|
||||
return '/';
|
||||
}
|
||||
|
||||
// Return everything up to (and including) the last folder separator
|
||||
return componentFullName.substring(0, lastSlashIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a display name from a folder path.
|
||||
*
|
||||
* Examples:
|
||||
* - "/#Directus" → "Directus"
|
||||
* - "/#Directus Prefab/Components/Admin" → "Directus Prefab/Components/Admin"
|
||||
* - "/" → "Pages"
|
||||
*
|
||||
* @param folderPath The folder path
|
||||
* @returns A human-readable folder name (full breadcrumb path)
|
||||
*/
|
||||
export function getFolderDisplayName(folderPath: string): string {
|
||||
if (folderPath === '/') {
|
||||
return 'Pages';
|
||||
}
|
||||
|
||||
// Remove leading slash and any # prefix, return full path
|
||||
const cleaned = folderPath.replace(/^\/+/, '').replace(/^#/, '');
|
||||
|
||||
return cleaned || 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups components by their folder path.
|
||||
*
|
||||
* @param components Array of component models
|
||||
* @returns Map of folder path to components in that folder
|
||||
*/
|
||||
export function groupComponentsByFolder(components: ComponentModel[]): Map<string, ComponentModel[]> {
|
||||
const folderMap = new Map<string, ComponentModel[]>();
|
||||
|
||||
for (const component of components) {
|
||||
const folderPath = extractFolderPath(component.fullName);
|
||||
|
||||
if (!folderMap.has(folderPath)) {
|
||||
folderMap.set(folderPath, []);
|
||||
}
|
||||
|
||||
folderMap.get(folderPath)!.push(component);
|
||||
}
|
||||
|
||||
return folderMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds folder-level connections by aggregating component-to-component relationships.
|
||||
*
|
||||
* @param project The project model
|
||||
* @param folders Array of folder nodes
|
||||
* @returns Array of folder connections
|
||||
*/
|
||||
export function buildFolderConnections(project: ProjectModel, folders: FolderNode[]): FolderConnection[] {
|
||||
// Build component-level dependency graph
|
||||
const componentGraph = buildComponentDependencyGraph(project);
|
||||
|
||||
// Create a map from component fullName to folder id
|
||||
const componentToFolder = new Map<string, string>();
|
||||
for (const folder of folders) {
|
||||
for (const component of folder.components) {
|
||||
componentToFolder.set(component.fullName, folder.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate edges by folder-to-folder relationships
|
||||
const folderConnectionMap = new Map<string, FolderConnection>();
|
||||
|
||||
for (const edge of componentGraph.edges) {
|
||||
const fromFolder = componentToFolder.get(edge.from);
|
||||
const toFolder = componentToFolder.get(edge.to);
|
||||
|
||||
// Skip if either component isn't in a folder or if it's same-folder connection
|
||||
if (!fromFolder || !toFolder || fromFolder === toFolder) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create connection key
|
||||
const connectionKey = `${fromFolder}→${toFolder}`;
|
||||
|
||||
if (!folderConnectionMap.has(connectionKey)) {
|
||||
folderConnectionMap.set(connectionKey, {
|
||||
from: fromFolder,
|
||||
to: toFolder,
|
||||
count: 0,
|
||||
componentPairs: []
|
||||
});
|
||||
}
|
||||
|
||||
const connection = folderConnectionMap.get(connectionKey)!;
|
||||
connection.count += edge.count;
|
||||
connection.componentPairs.push({
|
||||
from: edge.from,
|
||||
to: edge.to
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(folderConnectionMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a component is a page component that should be displayed at the top level.
|
||||
*
|
||||
* @param component The component to check
|
||||
* @returns True if this is a page component
|
||||
*/
|
||||
export function isPageComponent(component: ComponentModel): boolean {
|
||||
// Must be at root level (path starts with "/" and has no subdirectories)
|
||||
const isRootLevel = component.fullName.startsWith('/') && component.fullName.lastIndexOf('/') === 0;
|
||||
if (!isRootLevel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = component.name.toLowerCase();
|
||||
|
||||
// App component is always a page
|
||||
if (name === 'app') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if name contains "page"
|
||||
if (name.includes('page')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if component contains Page Router nodes (indicating it's a page)
|
||||
let hasPageRouter = false;
|
||||
component.graph?.forEachNode((node) => {
|
||||
if (node.type?.fullName === 'Page Router' || node.type?.name === 'Page Router') {
|
||||
hasPageRouter = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasPageRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies orphaned components (not used by anything and at max depth).
|
||||
*
|
||||
* @param project The project model
|
||||
* @param allComponents All components in the project
|
||||
* @returns Array of orphaned components
|
||||
*/
|
||||
export function identifyOrphanComponents(project: ProjectModel, allComponents: ComponentModel[]): ComponentModel[] {
|
||||
const componentGraph = buildComponentDependencyGraph(project);
|
||||
|
||||
// Find components with no incoming edges
|
||||
const usedComponents = new Set(componentGraph.edges.map((edge) => edge.to));
|
||||
|
||||
return allComponents.filter((component) => {
|
||||
return !usedComponents.has(component.fullName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the complete folder graph from a project.
|
||||
* This is the main entry point for folder aggregation.
|
||||
*
|
||||
* @param project The project model
|
||||
* @returns Complete folder graph
|
||||
*/
|
||||
export function buildFolderGraph(project: ProjectModel): FolderGraph {
|
||||
console.log('[FolderAggregation] Building folder graph...');
|
||||
|
||||
// Get all components
|
||||
const allComponents = project.getComponents();
|
||||
console.log(`[FolderAggregation] Total components: ${allComponents.length}`);
|
||||
|
||||
// Identify orphans first
|
||||
const orphans = identifyOrphanComponents(project, allComponents);
|
||||
console.log(`[FolderAggregation] Orphaned components: ${orphans.length}`);
|
||||
|
||||
// Filter out orphans from folder grouping
|
||||
const activeComponents = allComponents.filter((c) => !orphans.includes(c));
|
||||
|
||||
// Group components by folder
|
||||
const folderMap = groupComponentsByFolder(activeComponents);
|
||||
console.log(`[FolderAggregation] Unique folders: ${folderMap.size}`);
|
||||
|
||||
// Extract page components from root folder
|
||||
const topLevelComponents: TopLevelComponent[] = [];
|
||||
const rootComponents = folderMap.get('/') || [];
|
||||
const pageComponents = rootComponents.filter((c) => isPageComponent(c));
|
||||
const nonPageRootComponents = rootComponents.filter((c) => !isPageComponent(c));
|
||||
|
||||
console.log(`[FolderAggregation] Page components: ${pageComponents.length}`);
|
||||
|
||||
// Create TopLevelComponent objects for page components
|
||||
for (const pageComp of pageComponents) {
|
||||
topLevelComponents.push({
|
||||
component: pageComp,
|
||||
isAppComponent: pageComp.name.toLowerCase() === 'app'
|
||||
});
|
||||
}
|
||||
|
||||
// Update folderMap to only include non-page root components
|
||||
if (nonPageRootComponents.length > 0) {
|
||||
folderMap.set('/', nonPageRootComponents);
|
||||
} else {
|
||||
folderMap.delete('/'); // Remove root folder if all components are pages
|
||||
}
|
||||
|
||||
// Build folder nodes
|
||||
const folders: FolderNode[] = [];
|
||||
let folderIdCounter = 0;
|
||||
|
||||
for (const [folderPath, components] of folderMap.entries()) {
|
||||
const folderId = `folder-${folderIdCounter++}`;
|
||||
const displayName = getFolderDisplayName(folderPath);
|
||||
const folderType = detectFolderType(folderPath, components);
|
||||
|
||||
const folderNode: FolderNode = {
|
||||
id: folderId,
|
||||
name: displayName,
|
||||
path: folderPath,
|
||||
type: folderType,
|
||||
componentCount: components.length,
|
||||
components: components,
|
||||
componentNames: components.slice(0, 5).map((c) => c.name), // First 5 for preview
|
||||
connectionCount: {
|
||||
incoming: 0, // Will be calculated after connections are built
|
||||
outgoing: 0
|
||||
},
|
||||
tier: 0 // Will be assigned later
|
||||
};
|
||||
|
||||
folders.push(folderNode);
|
||||
}
|
||||
|
||||
// Build folder-to-folder connections
|
||||
const connections = buildFolderConnections(project, folders);
|
||||
|
||||
// Calculate connection counts for each folder
|
||||
for (const connection of connections) {
|
||||
const sourceFolder = folders.find((f) => f.id === connection.from);
|
||||
const targetFolder = folders.find((f) => f.id === connection.to);
|
||||
|
||||
if (sourceFolder) {
|
||||
sourceFolder.connectionCount.outgoing += connection.count;
|
||||
}
|
||||
if (targetFolder) {
|
||||
targetFolder.connectionCount.incoming += connection.count;
|
||||
}
|
||||
}
|
||||
console.log(`[FolderAggregation] Folder connections: ${connections.length}`);
|
||||
|
||||
// Assign tiers for semantic layout
|
||||
for (const folder of folders) {
|
||||
folder.tier = assignFolderTier(folder, folders, connections);
|
||||
}
|
||||
|
||||
// Log tier distribution
|
||||
const tierCounts = folders.reduce((acc, f) => {
|
||||
acc[f.tier] = (acc[f.tier] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<number, number>);
|
||||
console.log('[FolderAggregation] Tier distribution:', tierCounts);
|
||||
|
||||
return {
|
||||
folders,
|
||||
topLevelComponents,
|
||||
connections,
|
||||
orphanComponents: orphans,
|
||||
totalFolders: folders.length,
|
||||
totalComponents: allComponents.length
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Folder Card Height Calculation
|
||||
*
|
||||
* Utilities to calculate dynamic heights for folder cards based on content.
|
||||
*/
|
||||
|
||||
import { FolderNode } from './topologyTypes';
|
||||
|
||||
/**
|
||||
* Configuration for height calculations
|
||||
*/
|
||||
const HEIGHT_CONFIG = {
|
||||
HEADER_HEIGHT: 40, // Icon + title area base
|
||||
LINE_HEIGHT: 18, // Height per text line
|
||||
COMPONENT_LIST_HEIGHT: 30, // Height when component list is shown
|
||||
FOOTER_HEIGHT: 50, // Stats + count area
|
||||
MIN_HEIGHT: 110 // Minimum card height
|
||||
};
|
||||
|
||||
/**
|
||||
* Estimates the number of lines a text will wrap to given a max width.
|
||||
*
|
||||
* @param text The text to measure
|
||||
* @param maxCharsPerLine Rough estimate of characters per line (default: 15)
|
||||
* @returns Estimated number of lines
|
||||
*/
|
||||
export function estimateTextLines(text: string, maxCharsPerLine: number = 15): number {
|
||||
if (!text) return 1;
|
||||
return Math.max(1, Math.ceil(text.length / maxCharsPerLine));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the dynamic height needed for a folder card.
|
||||
*
|
||||
* @param folder The folder node
|
||||
* @returns The calculated height in pixels
|
||||
*/
|
||||
export function calculateFolderHeight(folder: FolderNode): number {
|
||||
// Calculate title height (max 2 lines with ellipsis)
|
||||
const titleLines = Math.min(2, estimateTextLines(folder.name, 15));
|
||||
const titleHeight = titleLines * HEIGHT_CONFIG.LINE_HEIGHT;
|
||||
|
||||
// Component list height (if present)
|
||||
const componentListHeight = folder.componentNames.length > 0 ? HEIGHT_CONFIG.COMPONENT_LIST_HEIGHT : 0;
|
||||
|
||||
// Total height
|
||||
const totalHeight = HEIGHT_CONFIG.HEADER_HEIGHT + titleHeight + componentListHeight + HEIGHT_CONFIG.FOOTER_HEIGHT;
|
||||
|
||||
// Ensure minimum height
|
||||
return Math.max(HEIGHT_CONFIG.MIN_HEIGHT, totalHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates Y positions for each section of the folder card.
|
||||
*
|
||||
* @param folder The folder node with x, y, width, height set
|
||||
* @returns Object with Y positions for each section
|
||||
*/
|
||||
export function calculateFolderSectionPositions(folder: FolderNode) {
|
||||
const titleLines = Math.min(2, estimateTextLines(folder.name, 15));
|
||||
const titleHeight = titleLines * HEIGHT_CONFIG.LINE_HEIGHT;
|
||||
|
||||
return {
|
||||
iconY: folder.y! + 16,
|
||||
titleY: folder.y! + 14,
|
||||
titleHeight: titleHeight + 10, // Add gap after title
|
||||
componentListY: folder.y! + HEIGHT_CONFIG.HEADER_HEIGHT + titleHeight + 10,
|
||||
statsY: folder.y! + folder.height! - 50,
|
||||
countY: folder.y! + folder.height! - 15
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Folder Color Mappings
|
||||
*
|
||||
* Defines colors for folder types used in:
|
||||
* - Gradient edge coloring (source to target)
|
||||
* - Folder node styling accents
|
||||
*/
|
||||
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
|
||||
import { IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import { FolderType } from './topologyTypes';
|
||||
|
||||
/**
|
||||
* Maps folder types to their visual color.
|
||||
* Used for gradient edges and visual accents.
|
||||
*/
|
||||
export function getFolderColor(type: FolderType): string {
|
||||
switch (type) {
|
||||
case 'page':
|
||||
return '#4CAF50'; // Green - entry points
|
||||
case 'integration':
|
||||
return '#FF9800'; // Orange - external connections
|
||||
case 'ui':
|
||||
return '#2196F3'; // Blue - visual components
|
||||
case 'utility':
|
||||
return '#9C27B0'; // Purple - helper functions
|
||||
case 'feature':
|
||||
return '#FFC107'; // Amber - business logic
|
||||
case 'orphan':
|
||||
return '#F44336'; // Red - unused/isolated
|
||||
default:
|
||||
return '#757575'; // Grey - unknown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps folder types to their icon.
|
||||
* Used instead of emojis for professional appearance.
|
||||
*/
|
||||
export function getFolderIcon(type: FolderType): IconName {
|
||||
switch (type) {
|
||||
case 'page':
|
||||
return IconName.PageRouter;
|
||||
case 'integration':
|
||||
return IconName.RestApi;
|
||||
case 'ui':
|
||||
return IconName.UI;
|
||||
case 'utility':
|
||||
return IconName.Sliders;
|
||||
case 'feature':
|
||||
return IconName.ComponentWithChildren;
|
||||
case 'orphan':
|
||||
return IconName.WarningCircle;
|
||||
default:
|
||||
return IconName.FolderClosed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets icon for a component based on its type.
|
||||
* Uses runtime property checks to handle optional ComponentModel properties.
|
||||
*/
|
||||
export function getComponentIcon(component: ComponentModel): IconName {
|
||||
// Use runtime checks since ComponentModel properties may vary
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const comp = component as any;
|
||||
|
||||
if (comp.isPage === true) {
|
||||
return IconName.PageRouter;
|
||||
} else if (comp.isCloudFunction === true) {
|
||||
return IconName.CloudFunction;
|
||||
}
|
||||
return IconName.Component;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Folder Type Detection
|
||||
*
|
||||
* Logic to classify folders into semantic types based on path and component characteristics.
|
||||
*/
|
||||
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
|
||||
import { FolderType } from './topologyTypes';
|
||||
|
||||
/**
|
||||
* Determines if a component is a page based on naming conventions.
|
||||
*
|
||||
* @param component Component to check
|
||||
* @returns True if component appears to be a page
|
||||
*/
|
||||
function isPageComponent(component: ComponentModel): boolean {
|
||||
const name = component.name.toLowerCase();
|
||||
const fullName = component.fullName.toLowerCase();
|
||||
|
||||
return (
|
||||
name.includes('page') ||
|
||||
name.includes('screen') ||
|
||||
name.includes('route') ||
|
||||
name === 'app' ||
|
||||
name === 'root' ||
|
||||
fullName.startsWith('/app') ||
|
||||
fullName.startsWith('/page')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the type of a folder based on its path and components.
|
||||
*
|
||||
* Classification rules:
|
||||
* - **page**: Root-level or contains page/screen components
|
||||
* - **integration**: Starts with # and matches known service patterns (Directus, Supabase, etc.)
|
||||
* - **ui**: Starts with # and matches UI patterns (#UI, #Components, #Design)
|
||||
* - **utility**: Starts with # and matches utility patterns (#Global, #Utils, #Shared, #Helpers)
|
||||
* - **feature**: Starts with # and represents a feature domain (#Forms, #Auth, etc.)
|
||||
* - **orphan**: Special case for components without connections (handled separately)
|
||||
*
|
||||
* @param folderPath The folder path
|
||||
* @param components Components in the folder
|
||||
* @returns The detected folder type
|
||||
*/
|
||||
export function detectFolderType(folderPath: string, components: ComponentModel[]): FolderType {
|
||||
const normalizedPath = folderPath.toLowerCase();
|
||||
|
||||
// Root folder (/) - treat as pages
|
||||
if (folderPath === '/') {
|
||||
return 'page';
|
||||
}
|
||||
|
||||
// Check if folder contains page components
|
||||
const hasPageComponents = components.some((c) => isPageComponent(c));
|
||||
if (hasPageComponents) {
|
||||
return 'page';
|
||||
}
|
||||
|
||||
// Extract folder name (remove leading /, #, etc.)
|
||||
const folderName = folderPath.replace(/^\/+/, '').replace(/^#/, '').toLowerCase();
|
||||
|
||||
// Integration patterns - external services
|
||||
const integrationPatterns = [
|
||||
'directus',
|
||||
'supabase',
|
||||
'firebase',
|
||||
'airtable',
|
||||
'stripe',
|
||||
'auth0',
|
||||
'swapcard',
|
||||
'api',
|
||||
'rest',
|
||||
'graphql',
|
||||
'backend'
|
||||
];
|
||||
|
||||
if (integrationPatterns.some((pattern) => folderName.includes(pattern))) {
|
||||
return 'integration';
|
||||
}
|
||||
|
||||
// UI patterns - visual components
|
||||
const uiPatterns = ['ui', 'component', 'design', 'layout', 'widget', 'button', 'card', 'modal', 'dialog'];
|
||||
|
||||
if (uiPatterns.some((pattern) => folderName.includes(pattern))) {
|
||||
return 'ui';
|
||||
}
|
||||
|
||||
// Utility patterns - foundational utilities
|
||||
const utilityPatterns = ['global', 'util', 'helper', 'shared', 'common', 'core', 'lib', 'tool', 'function'];
|
||||
|
||||
if (utilityPatterns.some((pattern) => folderName.includes(pattern))) {
|
||||
return 'utility';
|
||||
}
|
||||
|
||||
// Feature patterns - domain-specific features
|
||||
const featurePatterns = ['form', 'auth', 'user', 'profile', 'dashboard', 'admin', 'setting', 'search', 'filter'];
|
||||
|
||||
if (featurePatterns.some((pattern) => folderName.includes(pattern))) {
|
||||
return 'feature';
|
||||
}
|
||||
|
||||
// Default to feature for any other # prefixed folder
|
||||
if (normalizedPath.startsWith('/#')) {
|
||||
return 'feature';
|
||||
}
|
||||
|
||||
// Fallback to feature type
|
||||
return 'feature';
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Snap-to-Grid Utility
|
||||
*
|
||||
* Snaps coordinates to a grid for clean alignment of draggable elements.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Snaps a coordinate to the nearest grid point.
|
||||
*
|
||||
* @param value - The coordinate value to snap
|
||||
* @param gridSize - The size of the grid (default: 20px)
|
||||
* @returns The snapped coordinate
|
||||
*/
|
||||
export function snapToGrid(value: number, gridSize: number = 20): number {
|
||||
return Math.round(value / gridSize) * gridSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaps an x,y position to the nearest grid point.
|
||||
*
|
||||
* @param x - The x coordinate
|
||||
* @param y - The y coordinate
|
||||
* @param gridSize - The size of the grid (default: 20px)
|
||||
* @returns Object with snapped x and y coordinates
|
||||
*/
|
||||
export function snapPositionToGrid(x: number, y: number, gridSize: number = 20): { x: number; y: number } {
|
||||
return {
|
||||
x: snapToGrid(x, gridSize),
|
||||
y: snapToGrid(y, gridSize)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Tier Assignment
|
||||
*
|
||||
* Logic to assign semantic tiers to folders for hierarchical layout.
|
||||
* Tiers represent vertical positioning in the topology view.
|
||||
*/
|
||||
|
||||
import { FolderNode, FolderConnection } from './topologyTypes';
|
||||
|
||||
/**
|
||||
* Assigns a semantic tier to a folder for hierarchical layout.
|
||||
*
|
||||
* Tier system:
|
||||
* - **Tier 0**: Pages (entry points, top of hierarchy)
|
||||
* - **Tier 1**: Features used directly by pages
|
||||
* - **Tier 2**: Shared libraries (integrations, UI components)
|
||||
* - **Tier 3**: Utilities (foundation, bottom of hierarchy)
|
||||
* - **Tier -1**: Orphans (separate, not in main flow)
|
||||
*
|
||||
* @param folder The folder to assign a tier to
|
||||
* @param allFolders All folders in the graph
|
||||
* @param connections All folder connections
|
||||
* @returns The assigned tier (0-3, or -1 for orphans)
|
||||
*/
|
||||
export function assignFolderTier(
|
||||
folder: FolderNode,
|
||||
allFolders: FolderNode[],
|
||||
connections: FolderConnection[]
|
||||
): number {
|
||||
// Orphans get special tier
|
||||
if (folder.type === 'orphan') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Pages always at top
|
||||
if (folder.type === 'page') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Utilities always at bottom
|
||||
if (folder.type === 'utility') {
|
||||
return 3;
|
||||
}
|
||||
|
||||
// Check what uses this folder
|
||||
const usedByConnections = connections.filter((c) => c.to === folder.id);
|
||||
|
||||
// Find the types of folders that use this one
|
||||
const usedByTypes = new Set<string>();
|
||||
for (const conn of usedByConnections) {
|
||||
const sourceFolder = allFolders.find((f) => f.id === conn.from);
|
||||
if (sourceFolder) {
|
||||
usedByTypes.add(sourceFolder.type);
|
||||
}
|
||||
}
|
||||
|
||||
// If used by pages, place in tier 1 (features layer)
|
||||
if (usedByTypes.has('page')) {
|
||||
// Features used by pages go in tier 1
|
||||
if (folder.type === 'feature') {
|
||||
return 1;
|
||||
}
|
||||
// Integrations/UI used by pages go in tier 2
|
||||
return 2;
|
||||
}
|
||||
|
||||
// If used by tier 1 folders, place in tier 2
|
||||
const usedByTier1 = usedByConnections.some((conn) => {
|
||||
const sourceFolder = allFolders.find((f) => f.id === conn.from);
|
||||
return sourceFolder && sourceFolder.type === 'feature';
|
||||
});
|
||||
|
||||
if (usedByTier1) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Default tier based on type
|
||||
switch (folder.type) {
|
||||
case 'integration':
|
||||
return 2; // Shared layer
|
||||
case 'ui':
|
||||
return 2; // Shared layer
|
||||
case 'feature':
|
||||
return 1; // Feature layer
|
||||
default:
|
||||
return 2; // Default to shared layer
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Y position for a given tier.
|
||||
*
|
||||
* @param tier The tier number
|
||||
* @returns Y coordinate in the layout space
|
||||
*/
|
||||
export function getTierYPosition(tier: number): number {
|
||||
switch (tier) {
|
||||
case -1:
|
||||
return 600; // Orphans at bottom
|
||||
case 0:
|
||||
return 100; // Pages at top
|
||||
case 1:
|
||||
return 250; // Features
|
||||
case 2:
|
||||
return 400; // Shared (integrations, UI)
|
||||
case 3:
|
||||
return 550; // Utilities
|
||||
default:
|
||||
return 400; // Fallback to middle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a display label for a tier.
|
||||
*
|
||||
* @param tier The tier number
|
||||
* @returns Human-readable tier label
|
||||
*/
|
||||
export function getTierLabel(tier: number): string {
|
||||
switch (tier) {
|
||||
case -1:
|
||||
return 'Orphaned';
|
||||
case 0:
|
||||
return 'Pages';
|
||||
case 1:
|
||||
return 'Features';
|
||||
case 2:
|
||||
return 'Shared';
|
||||
case 3:
|
||||
return 'Utilities';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Topology Map Persistence
|
||||
*
|
||||
* Handles saving and loading custom positions and sticky notes from project metadata.
|
||||
* Stored in project.json under "topologyMap" key.
|
||||
*/
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
import { UndoActionGroup, UndoQueue } from '@noodl-models/undo-queue-model';
|
||||
|
||||
import { CustomPosition, StickyNote, TopologyMapMetadata } from './topologyTypes';
|
||||
|
||||
const METADATA_KEY = 'topologyMap';
|
||||
|
||||
/**
|
||||
* Get topology map metadata from project.
|
||||
* Returns default empty metadata if not found.
|
||||
*/
|
||||
export function getTopologyMapMetadata(project: ProjectModel): TopologyMapMetadata {
|
||||
const metadata = project.getMetaData(METADATA_KEY);
|
||||
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return {
|
||||
version: 1,
|
||||
customPositions: {},
|
||||
stickyNotes: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
version: (metadata as TopologyMapMetadata).version || 1,
|
||||
customPositions: (metadata as TopologyMapMetadata).customPositions || {},
|
||||
stickyNotes: (metadata as TopologyMapMetadata).stickyNotes || []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save topology map metadata to project.
|
||||
* Uses undo queue for proper undo/redo support.
|
||||
*/
|
||||
export function saveTopologyMapMetadata(project: ProjectModel, metadata: TopologyMapMetadata): void {
|
||||
// Capture previous state before modification
|
||||
const previousMetadata = getTopologyMapMetadata(project);
|
||||
|
||||
// Use the correct UndoQueue pattern (see UNDO-QUEUE-PATTERNS.md)
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: 'Update Topology Map',
|
||||
do: () => {
|
||||
project.setMetaData(METADATA_KEY, metadata);
|
||||
},
|
||||
undo: () => {
|
||||
project.setMetaData(METADATA_KEY, previousMetadata);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update custom position for a folder or component.
|
||||
*/
|
||||
export function updateCustomPosition(project: ProjectModel, nodeId: string, position: CustomPosition): void {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
|
||||
metadata.customPositions[nodeId] = position;
|
||||
|
||||
saveTopologyMapMetadata(project, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom position for a node (if exists).
|
||||
*/
|
||||
export function getCustomPosition(project: ProjectModel, nodeId: string): CustomPosition | undefined {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
return metadata.customPositions[nodeId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear custom position for a node.
|
||||
*/
|
||||
export function clearCustomPosition(project: ProjectModel, nodeId: string): void {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
|
||||
delete metadata.customPositions[nodeId];
|
||||
|
||||
saveTopologyMapMetadata(project, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new sticky note.
|
||||
*/
|
||||
export function addStickyNote(project: ProjectModel, note: StickyNote): void {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
|
||||
metadata.stickyNotes.push(note);
|
||||
|
||||
saveTopologyMapMetadata(project, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing sticky note.
|
||||
*/
|
||||
export function updateStickyNote(project: ProjectModel, noteId: string, updates: Partial<StickyNote>): void {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
|
||||
const noteIndex = metadata.stickyNotes.findIndex((n) => n.id === noteId);
|
||||
|
||||
if (noteIndex !== -1) {
|
||||
metadata.stickyNotes[noteIndex] = {
|
||||
...metadata.stickyNotes[noteIndex],
|
||||
...updates
|
||||
};
|
||||
|
||||
saveTopologyMapMetadata(project, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a sticky note.
|
||||
*/
|
||||
export function deleteStickyNote(project: ProjectModel, noteId: string): void {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
|
||||
metadata.stickyNotes = metadata.stickyNotes.filter((n) => n.id !== noteId);
|
||||
|
||||
saveTopologyMapMetadata(project, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sticky notes.
|
||||
*/
|
||||
export function getStickyNotes(project: ProjectModel): StickyNote[] {
|
||||
const metadata = getTopologyMapMetadata(project);
|
||||
return metadata.stickyNotes;
|
||||
}
|
||||
@@ -39,15 +39,43 @@ export interface TopologyNode {
|
||||
}
|
||||
|
||||
/**
|
||||
* An edge in the topology graph representing component usage.
|
||||
* Positioned folder graph with layout coordinates
|
||||
*/
|
||||
export interface PositionedFolderGraph extends FolderGraph {
|
||||
bounds: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* View mode for topology map
|
||||
*/
|
||||
export type TopologyViewMode = 'overview' | 'expanded';
|
||||
|
||||
/**
|
||||
* View state for topology map
|
||||
*/
|
||||
export interface TopologyViewState {
|
||||
mode: TopologyViewMode;
|
||||
expandedFolderId: string | null;
|
||||
selectedComponentId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection between two topology nodes (components).
|
||||
*/
|
||||
export interface TopologyEdge {
|
||||
/** Source component fullName */
|
||||
/** Source component full name */
|
||||
from: string;
|
||||
/** Target component fullName */
|
||||
/** Target component full name */
|
||||
to: string;
|
||||
/** Number of instances of this relationship */
|
||||
count: number;
|
||||
/** Connection type (e.g., 'children', 'component') */
|
||||
type?: string;
|
||||
/** Number of connections between these components (for aggregated edges) */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,3 +129,158 @@ export interface PositionedTopologyGraph extends TopologyGraph {
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder type classification for semantic grouping.
|
||||
*/
|
||||
export type FolderType = 'page' | 'feature' | 'integration' | 'ui' | 'utility' | 'orphan';
|
||||
|
||||
/**
|
||||
* A folder node representing a group of components.
|
||||
* This is the primary unit in the folder-first topology view.
|
||||
*/
|
||||
export interface FolderNode {
|
||||
/** Unique folder identifier */
|
||||
id: string;
|
||||
/** Display name (e.g., "Directus", "Forms") */
|
||||
name: string;
|
||||
/** Full folder path (e.g., "/#Directus") */
|
||||
path: string;
|
||||
/** Folder type classification */
|
||||
type: FolderType;
|
||||
/** Number of components in this folder */
|
||||
componentCount: number;
|
||||
/** Component models in this folder */
|
||||
components: ComponentModel[];
|
||||
/** Component names for preview (first few names) */
|
||||
componentNames: string[];
|
||||
/** Connection statistics */
|
||||
connectionCount: {
|
||||
incoming: number;
|
||||
outgoing: number;
|
||||
};
|
||||
/** Semantic tier for layout (0=pages, 1=features, 2=shared, 3=utilities, -1=orphans) */
|
||||
tier: number;
|
||||
/** X position (set by layout engine or custom position) */
|
||||
x?: number;
|
||||
/** Y position (set by layout engine or custom position) */
|
||||
y?: number;
|
||||
/** Node width (set by layout engine) */
|
||||
width?: number;
|
||||
/** Node height (set by layout engine) */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A top-level component to display as an individual card (e.g., pages).
|
||||
*/
|
||||
export interface TopLevelComponent {
|
||||
/** The component model */
|
||||
component: ComponentModel;
|
||||
/** Whether this is the App component (for special styling) */
|
||||
isAppComponent: boolean;
|
||||
/** X position (set by layout engine) */
|
||||
x?: number;
|
||||
/** Y position (set by layout engine) */
|
||||
y?: number;
|
||||
/** Node width (set by layout engine) */
|
||||
width?: number;
|
||||
/** Node height (set by layout engine) */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection between two folders, aggregating component-level connections.
|
||||
*/
|
||||
export interface FolderConnection {
|
||||
/** Source folder id */
|
||||
from: string;
|
||||
/** Target folder id */
|
||||
to: string;
|
||||
/** Number of component-to-component connections between these folders */
|
||||
count: number;
|
||||
/** Individual component pairs that create this connection */
|
||||
componentPairs: Array<{ from: string; to: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The complete folder-level topology graph.
|
||||
*/
|
||||
export interface FolderGraph {
|
||||
/** All folder nodes */
|
||||
folders: FolderNode[];
|
||||
/** Top-level components to display as individual cards (e.g., pages) */
|
||||
topLevelComponents: TopLevelComponent[];
|
||||
/** All folder-to-folder connections */
|
||||
connections: FolderConnection[];
|
||||
/** Components that don't belong to any folder or are unused */
|
||||
orphanComponents: ComponentModel[];
|
||||
/** Total folder count */
|
||||
totalFolders: number;
|
||||
/** Total component count across all folders */
|
||||
totalComponents: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positioned folder graph ready for rendering.
|
||||
*/
|
||||
export interface PositionedFolderGraph extends FolderGraph {
|
||||
/** Folders with layout positions */
|
||||
folders: FolderNode[];
|
||||
/** Bounding box of the entire graph */
|
||||
bounds: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky note color options (fixed palette)
|
||||
*/
|
||||
export type StickyNoteColor = 'yellow' | 'blue' | 'pink' | 'green';
|
||||
|
||||
/**
|
||||
* A sticky note annotation on the topology map
|
||||
*/
|
||||
export interface StickyNote {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** X position */
|
||||
x: number;
|
||||
/** Y position */
|
||||
y: number;
|
||||
/** Note width */
|
||||
width: number;
|
||||
/** Note height */
|
||||
height: number;
|
||||
/** Note text content */
|
||||
text: string;
|
||||
/** Note color */
|
||||
color: StickyNoteColor;
|
||||
/** Creation timestamp */
|
||||
createdAt: number;
|
||||
/** Last update timestamp */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom position override for a node
|
||||
*/
|
||||
export interface CustomPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Topology map metadata stored in project.json
|
||||
*/
|
||||
export interface TopologyMapMetadata {
|
||||
/** Schema version for migrations */
|
||||
version: number;
|
||||
/** Custom positions by node ID */
|
||||
customPositions: Record<string, CustomPosition>;
|
||||
/** Sticky notes */
|
||||
stickyNotes: StickyNote[];
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function PropertyEditor(props: PropertyEditorProps) {
|
||||
return function () {
|
||||
SidebarModel.instance.off(group);
|
||||
};
|
||||
}, []);
|
||||
}, [props.model]); // FIX: Update when model changes!
|
||||
|
||||
const aiAssistant = props.model?.metadata?.AiAssistant;
|
||||
if (aiAssistant) {
|
||||
|
||||
Reference in New Issue
Block a user