feat: add Obsidian-style graph visualization
Implement force-directed graph view for visualizing note connections:
- Add graph data model parsing [[wiki-style links]]
- Implement Fruchterman-Reingold layout algorithm
- Create character-based canvas renderer with highlights
- Add interactive filtering by tag/folder
- Support navigation (h/j/k/l), zoom (+/-), and node selection
- New commands: :IdeaGraph, :IdeaGraphFilter
New files:
- lua/ideaDrop/ui/graph/{init,types,data,layout,renderer}.lua
Updated documentation in README.md, CHANGELOG.md, and llms.txt
This commit is contained in:
80
CHANGELOG.md
80
CHANGELOG.md
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
#### 🕸️ Graph Visualization (Obsidian-style)
|
||||
|
||||
A new force-directed graph view that visualizes connections between your notes:
|
||||
|
||||
- **Graph Data Model**: Parses `[[Note Name]]` wiki-style links from markdown files
|
||||
- Supports `[[link|alias]]` format
|
||||
- Builds bidirectional edges (undirected graph)
|
||||
- Extracts tags and folder metadata for filtering
|
||||
|
||||
- **Force-Directed Layout**: Implements Fruchterman-Reingold algorithm
|
||||
- Spring forces attract connected nodes
|
||||
- Repulsion forces prevent node overlap
|
||||
- Gravity pulls high-degree nodes toward center
|
||||
- Inverse gravity pushes orphan nodes to periphery
|
||||
- Temperature-based cooling for stable convergence
|
||||
- Supports both synchronous and animated layout modes
|
||||
|
||||
- **Visual Rendering**:
|
||||
- Dark background canvas for visual clarity
|
||||
- Node size scales with degree (number of connections)
|
||||
- Color-coded nodes: blue (default), purple (hubs), gray (orphans), red (selected)
|
||||
- Semi-transparent edge lines showing connections
|
||||
- Labels for selected and high-degree nodes
|
||||
|
||||
- **Interactive Features**:
|
||||
- `h/j/k/l` navigation between nodes
|
||||
- `Enter` to open selected note in right-side buffer
|
||||
- `t` filter by tag, `f` filter by folder, `r` reset filter
|
||||
- `+/-` zoom in/out, `c` center graph
|
||||
- `L` toggle labels, `?` toggle help overlay
|
||||
- `q/Esc` close graph, `R` refresh graph data
|
||||
- Smooth layout reflow when nodes are filtered
|
||||
|
||||
- **New Commands**:
|
||||
- `:IdeaGraph` - Opens the graph visualization
|
||||
- `:IdeaGraph animate` - Opens with animated layout
|
||||
- `:IdeaGraph refresh` - Refreshes graph data
|
||||
- `:IdeaGraph close` - Closes the graph window
|
||||
- `:IdeaGraphFilter tag <name>` - Filter graph by tag
|
||||
- `:IdeaGraphFilter folder <name>` - Filter graph by folder
|
||||
|
||||
- **New Configuration Options**:
|
||||
- `graph.animate` - Enable animated layout (default: false)
|
||||
- `graph.show_orphans` - Show nodes without connections (default: true)
|
||||
- `graph.show_labels` - Show node labels by default (default: true)
|
||||
- `graph.node_colors` - Custom colors by folder/tag
|
||||
|
||||
- **New Files**:
|
||||
- `lua/ideaDrop/ui/graph/types.lua` - Type definitions
|
||||
- `lua/ideaDrop/ui/graph/data.lua` - Graph data model
|
||||
- `lua/ideaDrop/ui/graph/layout.lua` - Force-directed layout algorithm
|
||||
- `lua/ideaDrop/ui/graph/renderer.lua` - Character-based canvas renderer
|
||||
- `lua/ideaDrop/ui/graph/init.lua` - Main graph module
|
||||
|
||||
#### Other Additions
|
||||
|
||||
- Added `CHANGELOG.md` to track project changes
|
||||
- Added `llms.txt` for AI/LLM context about the project
|
||||
- Added graph-related constants and settings in `constants.lua`
|
||||
- Added graph-related notification messages
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated help documentation (`doc/ideaDrop.txt`) to include all commands: `IdeaBuffer`, `IdeaRight`, `IdeaTree`, tag commands, and search commands
|
||||
- Improved nvim-tree integration to preserve user's existing nvim-tree configuration
|
||||
- Updated `README.md` with comprehensive graph visualization documentation
|
||||
- Extended configuration options to include graph settings
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Critical**: Fixed glob pattern bug where files were not being found due to missing path separator (`/`) between directory and pattern in `list.lua`, `tags.lua`, and `search.lua`
|
||||
@@ -15,16 +85,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Fixed missing arguments in `sidebar.open()` call in `list.lua` which could cause unexpected behavior
|
||||
- Removed unused variable in `tags.lua` (`filename` in `show_files_with_tag` function)
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated help documentation (`doc/ideaDrop.txt`) to include all commands: `IdeaBuffer`, `IdeaRight`, `IdeaTree`, tag commands, and search commands
|
||||
- Improved nvim-tree integration to preserve user's existing nvim-tree configuration
|
||||
|
||||
### Added
|
||||
|
||||
- Added `CHANGELOG.md` to track project changes
|
||||
- Added `llms.txt` for AI/LLM context about the project
|
||||
|
||||
## [1.0.0] - Initial Release
|
||||
|
||||
### Added
|
||||
|
||||
91
README.md
91
README.md
@@ -8,6 +8,7 @@
|
||||
- 🏷️ **Smart Tagging System**: Add, remove, and filter ideas by tags
|
||||
- 🔍 **Advanced Search**: Fuzzy search through titles and content
|
||||
- 📁 **File Tree Browser**: Integrated nvim-tree for easy file navigation
|
||||
- 🕸️ **Graph Visualization**: Obsidian-style force-directed graph view of your notes
|
||||
- 📝 **Markdown Support**: Full markdown editing with syntax highlighting
|
||||
- 💾 **Auto-save**: Changes saved automatically
|
||||
- 📅 **Date-based Organization**: Automatic date-based file naming
|
||||
@@ -56,12 +57,21 @@ use {
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `idea_dir` | string | `vim.fn.stdpath("data") .. "/ideaDrop"` | Directory where your idea files will be stored |
|
||||
| `graph.animate` | boolean | `false` | Enable animated graph layout |
|
||||
| `graph.show_orphans` | boolean | `true` | Show nodes without connections |
|
||||
| `graph.show_labels` | boolean | `true` | Show node labels by default |
|
||||
| `graph.node_colors` | table | `nil` | Custom colors by folder/tag |
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```lua
|
||||
require("ideaDrop").setup({
|
||||
idea_dir = "/Users/carlos/Nextcloud/ObsidianVault",
|
||||
graph = {
|
||||
animate = false, -- Set true for animated layout
|
||||
show_orphans = true, -- Show unconnected notes
|
||||
show_labels = true, -- Show note names
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -96,6 +106,17 @@ require("ideaDrop").setup({
|
||||
| `:IdeaSearchContent query` | Search only in idea content |
|
||||
| `:IdeaSearchTitle query` | Search only in idea titles |
|
||||
|
||||
### Graph Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `:IdeaGraph` | Opens the Obsidian-style graph visualization |
|
||||
| `:IdeaGraph animate` | Opens graph with animated layout |
|
||||
| `:IdeaGraph refresh` | Refreshes the graph data |
|
||||
| `:IdeaGraph close` | Closes the graph window |
|
||||
| `:IdeaGraphFilter tag tagname` | Opens graph filtered by tag |
|
||||
| `:IdeaGraphFilter folder foldername` | Opens graph filtered by folder |
|
||||
|
||||
## ⌨️ Keymaps
|
||||
|
||||
The plugin automatically sets up convenient keymaps:
|
||||
@@ -108,6 +129,7 @@ The plugin automatically sets up convenient keymaps:
|
||||
| `<leader>is` | `:IdeaSearch ` | Search ideas |
|
||||
| `<leader>ig` | `:IdeaTags` | Browse tags |
|
||||
| `<leader>if` | `:Idea` | Open today's idea in float |
|
||||
| `<leader>iG` | `:IdeaGraph` | Open graph visualization |
|
||||
|
||||
## 🗂️ Usage Examples
|
||||
|
||||
@@ -143,6 +165,15 @@ The plugin automatically sets up convenient keymaps:
|
||||
:IdeaRight projects/app/features " Organize by project and feature
|
||||
```
|
||||
|
||||
### Graph Visualization
|
||||
|
||||
```vim
|
||||
:IdeaGraph " Open the graph view
|
||||
:IdeaGraph animate " Open with animated layout
|
||||
:IdeaGraphFilter tag work " Show only notes tagged #work
|
||||
:IdeaGraphFilter folder projects " Show only notes in projects folder
|
||||
```
|
||||
|
||||
## 🏷️ Tagging System
|
||||
|
||||
The plugin includes a powerful tagging system:
|
||||
@@ -162,6 +193,57 @@ This is my idea content.
|
||||
#work #project-x #feature #todo
|
||||
```
|
||||
|
||||
## 🕸️ Graph Visualization
|
||||
|
||||
The plugin includes an Obsidian-style graph view that visualizes the connections between your notes.
|
||||
|
||||
### How It Works
|
||||
|
||||
- **Nodes**: Each markdown file appears as a node
|
||||
- **Edges**: Internal links using `[[Note Name]]` syntax create connections
|
||||
- **Layout**: Uses Fruchterman-Reingold force-directed algorithm
|
||||
- **Positioning**: Highly connected nodes drift to center, orphans to periphery
|
||||
|
||||
### Graph Keymaps (inside graph window)
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `h/j/k/l` | Navigate between nodes |
|
||||
| `Enter` | Open selected note |
|
||||
| `t` | Filter by tag |
|
||||
| `f` | Filter by folder |
|
||||
| `r` | Reset filter |
|
||||
| `L` | Toggle labels |
|
||||
| `c` | Center graph |
|
||||
| `+/-` | Zoom in/out |
|
||||
| `?` | Toggle help |
|
||||
| `q/Esc` | Close graph |
|
||||
| `R` | Refresh graph |
|
||||
|
||||
### Visual Encoding
|
||||
|
||||
- **Node Size**: Scales with degree (number of connections)
|
||||
- **Node Color**:
|
||||
- Blue: Normal nodes
|
||||
- Purple: High-connectivity nodes (hubs)
|
||||
- Gray: Orphan nodes (no connections)
|
||||
- Red: Selected node
|
||||
- **Edges**: Thin, semi-transparent lines showing connections
|
||||
|
||||
### Linking Notes
|
||||
|
||||
To create links between notes, use wiki-style links in your markdown:
|
||||
|
||||
```markdown
|
||||
# My Note
|
||||
|
||||
This relates to [[Another Note]] and also to [[Projects/My Project]].
|
||||
|
||||
Check out [[2024-01-15]] for more context.
|
||||
```
|
||||
|
||||
The graph will automatically detect these links and create visual connections.
|
||||
|
||||
## 🔍 Search Features
|
||||
|
||||
### Fuzzy Search
|
||||
@@ -209,6 +291,12 @@ The plugin integrates with nvim-tree for seamless file browsing:
|
||||
- Integrated with nvim-tree
|
||||
- Command: `:IdeaTree`
|
||||
|
||||
### 5. Graph View
|
||||
- Obsidian-style force-directed graph
|
||||
- Visualizes note connections via `[[links]]`
|
||||
- Interactive filtering and navigation
|
||||
- Command: `:IdeaGraph`
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
This plugin is built with:
|
||||
@@ -245,6 +333,9 @@ Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
2. **Tree not opening**: Make sure nvim-tree is properly configured
|
||||
3. **Search not working**: Verify your idea directory path is correct
|
||||
4. **Tags not showing**: Check that your idea directory exists and contains markdown files
|
||||
5. **Graph showing no connections**: Make sure you're using `[[Note Name]]` syntax for links
|
||||
6. **Graph layout looks cramped**: Try zooming out with `-` or use `:IdeaGraph animate` for better initial layout
|
||||
7. **Graph is slow**: Large vaults (500+ notes) may take a moment to compute layout
|
||||
|
||||
### Getting Help
|
||||
|
||||
|
||||
61
llms.txt
61
llms.txt
@@ -23,7 +23,13 @@ ideaDrop.nvim/
|
||||
│ │ └── config.lua # Configuration management
|
||||
│ ├── ui/
|
||||
│ │ ├── sidebar.lua # Floating/buffer/right-side window management
|
||||
│ │ └── tree.lua # nvim-tree integration
|
||||
│ │ ├── tree.lua # nvim-tree integration
|
||||
│ │ └── graph/ # Graph visualization module
|
||||
│ │ ├── init.lua # Main graph module (window, keymaps, state)
|
||||
│ │ ├── types.lua # Type definitions (GraphNode, GraphEdge, etc.)
|
||||
│ │ ├── data.lua # Graph data model (link parsing, filtering)
|
||||
│ │ ├── layout.lua # Force-directed layout (Fruchterman-Reingold)
|
||||
│ │ └── renderer.lua # Character-based canvas renderer
|
||||
│ ├── features/
|
||||
│ │ ├── list.lua # File listing functionality
|
||||
│ │ ├── tags.lua # Tag extraction and management
|
||||
@@ -47,6 +53,7 @@ ideaDrop.nvim/
|
||||
- Current buffer (`:IdeaBuffer`)
|
||||
- Right-side persistent buffer (`:IdeaRight`)
|
||||
- Tree browser (`:IdeaTree`)
|
||||
- Graph visualization (`:IdeaGraph`)
|
||||
|
||||
2. **Tagging System**
|
||||
- Uses `#tag` format in markdown files
|
||||
@@ -61,11 +68,24 @@ ideaDrop.nvim/
|
||||
- Automatic saving on window close
|
||||
- Uses `BufWriteCmd` autocmd for custom save handling
|
||||
|
||||
5. **Graph Visualization** (Obsidian-style)
|
||||
- Force-directed layout using Fruchterman-Reingold algorithm
|
||||
- Parses `[[Note Name]]` wiki-style links to build graph
|
||||
- Visual encoding: node size = degree, colors = connectivity level
|
||||
- Interactive filtering by tag/folder
|
||||
- Commands: `:IdeaGraph`, `:IdeaGraphFilter`
|
||||
|
||||
## Configuration
|
||||
|
||||
```lua
|
||||
require("ideaDrop").setup({
|
||||
idea_dir = "/path/to/your/ideas", -- Directory for storing idea files
|
||||
graph = {
|
||||
animate = false, -- Enable animated layout
|
||||
show_orphans = true, -- Show nodes without connections
|
||||
show_labels = true, -- Show node labels by default
|
||||
node_colors = nil, -- Custom colors by folder/tag
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -102,6 +122,42 @@ require("ideaDrop").setup({
|
||||
| `:IdeaSearch {query}` | Fuzzy search all |
|
||||
| `:IdeaSearchContent {query}` | Search content only |
|
||||
| `:IdeaSearchTitle {query}` | Search titles only |
|
||||
| `:IdeaGraph [animate]` | Open graph visualization |
|
||||
| `:IdeaGraphFilter {type} {value}` | Filter graph by tag/folder |
|
||||
|
||||
## Graph Implementation Details
|
||||
|
||||
### Force-Directed Layout Algorithm
|
||||
|
||||
The graph uses the Fruchterman-Reingold algorithm with these components:
|
||||
|
||||
1. **Repulsion**: All nodes repel each other (`REPULSION_STRENGTH / distance²`)
|
||||
2. **Attraction**: Connected nodes attract via spring force (`ATTRACTION_STRENGTH * (distance - ideal_length)`)
|
||||
3. **Gravity**: Pulls toward center, inversely proportional to degree
|
||||
4. **Cooling**: Temperature decreases each iteration (`temp * COOLING_RATE`)
|
||||
5. **Convergence**: Stops when max displacement < `MIN_VELOCITY` or max iterations reached
|
||||
|
||||
### Graph Data Model
|
||||
|
||||
- **Nodes**: Created from each `.md` file, stores: id, name, file_path, folder, tags, degree, position (x,y), velocity
|
||||
- **Edges**: Created from `[[link]]` patterns, undirected (stored once per pair)
|
||||
- **Link Resolution**: Matches links to existing files using normalized names (case-insensitive, spaces→dashes)
|
||||
|
||||
### Visual Encoding
|
||||
|
||||
- Node size: `●` for high-degree, `•` for low-degree
|
||||
- Node colors: Blue (default), Purple (hubs, degree > 5), Gray (orphans), Red (selected)
|
||||
- Edges: `·` character with dim highlight
|
||||
|
||||
### Key Constants (in `constants.lua`)
|
||||
|
||||
```lua
|
||||
GRAPH_SETTINGS = {
|
||||
LAYOUT = { REPULSION_STRENGTH = 5000, ATTRACTION_STRENGTH = 0.01, ... },
|
||||
VISUAL = { NODE_CHAR = "●", EDGE_CHAR_SIMPLE = "·", ... },
|
||||
WINDOW = { WIDTH_RATIO = 0.8, HEIGHT_RATIO = 0.8, ... },
|
||||
}
|
||||
```
|
||||
|
||||
## Development Notes
|
||||
|
||||
@@ -109,3 +165,6 @@ require("ideaDrop").setup({
|
||||
- Uses `vim.ui.select()` for picker interfaces
|
||||
- Tag cache invalidation via `tag_cache_dirty` flag
|
||||
- Markdown files default template includes title and bullet point
|
||||
- Graph uses character-based canvas with highlight groups for colors
|
||||
- Graph layout runs synchronously by default, optionally animated with `vim.defer_fn`
|
||||
- Graph filtering re-runs partial layout (fewer iterations) for smooth transitions
|
||||
|
||||
@@ -4,21 +4,34 @@
|
||||
---@field options IdeaDropOptions
|
||||
---@field setup fun(user_opts: IdeaDropOptions|nil): nil
|
||||
|
||||
---@class GraphOptions
|
||||
---@field animate boolean Whether to animate layout (default: false)
|
||||
---@field show_orphans boolean Whether to show orphan nodes (default: true)
|
||||
---@field show_labels boolean Whether to show node labels by default (default: true)
|
||||
---@field node_colors table<string, string>|nil Custom colors for folders/tags
|
||||
|
||||
---@class IdeaDropOptions
|
||||
---@field idea_dir string Directory where idea files will be stored
|
||||
---@field graph GraphOptions|nil Graph visualization options
|
||||
|
||||
local M = {}
|
||||
|
||||
---Default configuration options
|
||||
M.options = {
|
||||
idea_dir = vim.fn.stdpath("data") .. "/ideaDrop" -- default path
|
||||
idea_dir = vim.fn.stdpath("data") .. "/ideaDrop", -- default path
|
||||
graph = {
|
||||
animate = false, -- Set to true for animated layout
|
||||
show_orphans = true, -- Show nodes with no connections
|
||||
show_labels = true, -- Show node labels by default
|
||||
node_colors = nil, -- Custom node colors by folder/tag
|
||||
},
|
||||
}
|
||||
|
||||
---Setup function to merge user options with defaults
|
||||
---@param user_opts IdeaDropOptions|nil User configuration options
|
||||
---@return nil
|
||||
function M.setup(user_opts)
|
||||
M.options = vim.tbl_deep_extend("force", M.options, user_opts or {})
|
||||
M.options = vim.tbl_deep_extend("force", M.options, user_opts or {})
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -5,6 +5,7 @@ local config = require("ideaDrop.core.config")
|
||||
-- UI modules
|
||||
local sidebar = require("ideaDrop.ui.sidebar")
|
||||
local tree = require("ideaDrop.ui.tree")
|
||||
local graph = require("ideaDrop.ui.graph")
|
||||
|
||||
-- Feature modules
|
||||
local list = require("ideaDrop.features.list")
|
||||
@@ -239,6 +240,75 @@ function M.setup(user_opts)
|
||||
desc = "Search only in idea titles",
|
||||
})
|
||||
|
||||
-- Graph visualization commands
|
||||
vim.api.nvim_create_user_command("IdeaGraph", function(opts)
|
||||
local arg = opts.args
|
||||
|
||||
if arg == "close" then
|
||||
graph.close()
|
||||
elseif arg == "refresh" then
|
||||
graph.refresh()
|
||||
elseif arg == "animate" then
|
||||
graph.open({ animate = true })
|
||||
else
|
||||
graph.open()
|
||||
end
|
||||
end, {
|
||||
nargs = "?",
|
||||
complete = function()
|
||||
return { "close", "refresh", "animate" }
|
||||
end,
|
||||
desc = "Open Obsidian-style graph visualization of notes and links",
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command("IdeaGraphFilter", function(opts)
|
||||
local args = vim.split(opts.args, " ", { trimempty = true })
|
||||
|
||||
if #args < 2 then
|
||||
vim.notify("Usage: :IdeaGraphFilter <tag|folder> <value>", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local filter_type = args[1]
|
||||
local filter_value = args[2]
|
||||
|
||||
if filter_type ~= "tag" and filter_type ~= "folder" then
|
||||
vim.notify("Filter type must be 'tag' or 'folder'", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
-- If graph is open, apply filter
|
||||
if graph.is_open() then
|
||||
local graph_data = graph.get_graph()
|
||||
if graph_data then
|
||||
local data_module = require("ideaDrop.ui.graph.data")
|
||||
data_module.apply_filter(graph_data, filter_type, filter_value)
|
||||
graph.refresh()
|
||||
end
|
||||
else
|
||||
-- Open graph with filter
|
||||
graph.open()
|
||||
vim.defer_fn(function()
|
||||
local graph_data = graph.get_graph()
|
||||
if graph_data then
|
||||
local data_module = require("ideaDrop.ui.graph.data")
|
||||
data_module.apply_filter(graph_data, filter_type, filter_value)
|
||||
graph.refresh()
|
||||
end
|
||||
end, 100)
|
||||
end
|
||||
end, {
|
||||
nargs = "+",
|
||||
complete = function(_, cmd_line, _)
|
||||
local args = vim.split(cmd_line, " ", { trimempty = true })
|
||||
if #args <= 2 then
|
||||
return { "tag", "folder" }
|
||||
end
|
||||
return {}
|
||||
end,
|
||||
desc = "Filter graph by tag or folder",
|
||||
})
|
||||
|
||||
-- Set up keymaps
|
||||
keymaps.setup()
|
||||
|
||||
|
||||
339
lua/ideaDrop/ui/graph/data.lua
Normal file
339
lua/ideaDrop/ui/graph/data.lua
Normal file
@@ -0,0 +1,339 @@
|
||||
-- ideaDrop/ui/graph/data.lua
|
||||
-- Graph data model: parses markdown files and builds node/edge structures
|
||||
|
||||
local config = require("ideaDrop.core.config")
|
||||
local tags_module = require("ideaDrop.features.tags")
|
||||
local types = require("ideaDrop.ui.graph.types")
|
||||
|
||||
---@class GraphDataModule
|
||||
---@field build_graph fun(): GraphData
|
||||
---@field extract_links fun(content: string): string[]
|
||||
---@field resolve_link fun(link_text: string, base_dir: string): string|nil
|
||||
---@field get_node_by_position fun(graph: GraphData, x: number, y: number, threshold: number): GraphNode|nil
|
||||
local M = {}
|
||||
|
||||
---Extracts [[wiki-style links]] from markdown content
|
||||
---@param content string The markdown content
|
||||
---@return string[] Array of link targets (without brackets)
|
||||
function M.extract_links(content)
|
||||
local links = {}
|
||||
local seen = {}
|
||||
|
||||
-- Match [[link]] pattern
|
||||
for link in content:gmatch("%[%[([^%]]+)%]%]") do
|
||||
-- Handle [[link|alias]] format - take the link part
|
||||
local actual_link = link:match("^([^|]+)") or link
|
||||
-- Trim whitespace
|
||||
actual_link = actual_link:gsub("^%s*(.-)%s*$", "%1")
|
||||
|
||||
if not seen[actual_link] and actual_link ~= "" then
|
||||
table.insert(links, actual_link)
|
||||
seen[actual_link] = true
|
||||
end
|
||||
end
|
||||
|
||||
return links
|
||||
end
|
||||
|
||||
---Resolves a link text to a file path
|
||||
---@param link_text string The link text (without brackets)
|
||||
---@param idea_dir string The idea directory path
|
||||
---@param existing_files table<string, string> Map of normalized names to file paths
|
||||
---@return string|nil Resolved file path or nil if not found
|
||||
function M.resolve_link(link_text, idea_dir, existing_files)
|
||||
-- Normalize the link text
|
||||
local normalized = link_text:lower():gsub("%s+", "-")
|
||||
|
||||
-- Try direct match first
|
||||
if existing_files[normalized] then
|
||||
return existing_files[normalized]
|
||||
end
|
||||
|
||||
-- Try with .md extension
|
||||
if existing_files[normalized .. ".md"] then
|
||||
return existing_files[normalized .. ".md"]
|
||||
end
|
||||
|
||||
-- Try fuzzy matching - match just the filename part
|
||||
local link_basename = vim.fn.fnamemodify(link_text, ":t"):lower():gsub("%s+", "-")
|
||||
for name, path in pairs(existing_files) do
|
||||
local file_basename = vim.fn.fnamemodify(name, ":t"):gsub("%.md$", "")
|
||||
if file_basename == link_basename or file_basename == normalized then
|
||||
return path
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
---Builds a normalized name for a file (used as node ID)
|
||||
---@param file_path string Full file path
|
||||
---@param idea_dir string The idea directory
|
||||
---@return string Normalized name (relative path without extension)
|
||||
function M.normalize_file_name(file_path, idea_dir)
|
||||
local relative = file_path
|
||||
if file_path:sub(1, #idea_dir) == idea_dir then
|
||||
relative = file_path:sub(#idea_dir + 2) -- Remove idea_dir + "/"
|
||||
end
|
||||
-- Remove .md extension
|
||||
return relative:gsub("%.md$", "")
|
||||
end
|
||||
|
||||
---Gets display name from a node ID
|
||||
---@param node_id string The node ID
|
||||
---@return string Display name
|
||||
function M.get_display_name(node_id)
|
||||
-- Get just the filename part without path
|
||||
local name = vim.fn.fnamemodify(node_id, ":t")
|
||||
-- Capitalize and clean up
|
||||
return name:gsub("-", " "):gsub("^%l", string.upper)
|
||||
end
|
||||
|
||||
---Builds the complete graph from markdown files
|
||||
---@return GraphData
|
||||
function M.build_graph()
|
||||
local idea_dir = config.options.idea_dir
|
||||
local graph = types.create_graph_data()
|
||||
|
||||
-- Find all markdown files
|
||||
local files = vim.fn.glob(idea_dir .. "/**/*.md", false, true)
|
||||
|
||||
if #files == 0 then
|
||||
return graph
|
||||
end
|
||||
|
||||
-- Build a map of normalized names to file paths for link resolution
|
||||
local file_map = {}
|
||||
for _, file_path in ipairs(files) do
|
||||
local normalized = M.normalize_file_name(file_path, idea_dir):lower()
|
||||
file_map[normalized] = file_path
|
||||
|
||||
-- Also map just the filename
|
||||
local basename = vim.fn.fnamemodify(file_path, ":t:r"):lower()
|
||||
if not file_map[basename] then
|
||||
file_map[basename] = file_path
|
||||
end
|
||||
end
|
||||
|
||||
-- First pass: create all nodes
|
||||
for _, file_path in ipairs(files) do
|
||||
local node_id = M.normalize_file_name(file_path, idea_dir)
|
||||
local display_name = M.get_display_name(node_id)
|
||||
|
||||
local node = types.create_node(node_id, display_name, file_path)
|
||||
|
||||
-- Extract tags from file
|
||||
if vim.fn.filereadable(file_path) == 1 then
|
||||
local content = vim.fn.readfile(file_path)
|
||||
local content_str = table.concat(content, "\n")
|
||||
node.tags = tags_module.extract_tags(content_str)
|
||||
end
|
||||
|
||||
graph.nodes[node_id] = node
|
||||
table.insert(graph.node_list, node)
|
||||
end
|
||||
|
||||
-- Second pass: create edges from links
|
||||
local edge_set = {} -- Track unique edges (undirected)
|
||||
|
||||
for _, file_path in ipairs(files) do
|
||||
if vim.fn.filereadable(file_path) == 1 then
|
||||
local content = vim.fn.readfile(file_path)
|
||||
local content_str = table.concat(content, "\n")
|
||||
local links = M.extract_links(content_str)
|
||||
|
||||
local source_id = M.normalize_file_name(file_path, idea_dir)
|
||||
|
||||
for _, link_text in ipairs(links) do
|
||||
local target_path = M.resolve_link(link_text, idea_dir, file_map)
|
||||
|
||||
if target_path then
|
||||
local target_id = M.normalize_file_name(target_path, idea_dir)
|
||||
|
||||
-- Skip self-links
|
||||
if source_id ~= target_id then
|
||||
-- Create undirected edge key (sorted)
|
||||
local edge_key
|
||||
if source_id < target_id then
|
||||
edge_key = source_id .. "|||" .. target_id
|
||||
else
|
||||
edge_key = target_id .. "|||" .. source_id
|
||||
end
|
||||
|
||||
-- Only add if not already exists
|
||||
if not edge_set[edge_key] then
|
||||
edge_set[edge_key] = true
|
||||
|
||||
local edge = types.create_edge(source_id, target_id)
|
||||
table.insert(graph.edges, edge)
|
||||
|
||||
-- Update degrees
|
||||
if graph.nodes[source_id] then
|
||||
graph.nodes[source_id].degree = graph.nodes[source_id].degree + 1
|
||||
end
|
||||
if graph.nodes[target_id] then
|
||||
graph.nodes[target_id].degree = graph.nodes[target_id].degree + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return graph
|
||||
end
|
||||
|
||||
---Finds a node at a given position (for mouse/cursor interaction)
|
||||
---@param graph GraphData The graph data
|
||||
---@param x number X coordinate
|
||||
---@param y number Y coordinate
|
||||
---@param threshold number Distance threshold for hit detection
|
||||
---@return GraphNode|nil Node at position or nil
|
||||
function M.get_node_by_position(graph, x, y, threshold)
|
||||
local closest_node = nil
|
||||
local closest_dist = threshold + 1
|
||||
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
if node.visible then
|
||||
local dx = node.x - x
|
||||
local dy = node.y - y
|
||||
local dist = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if dist < closest_dist then
|
||||
closest_dist = dist
|
||||
closest_node = node
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return closest_node
|
||||
end
|
||||
|
||||
---Gets all unique folders from the graph
|
||||
---@param graph GraphData The graph data
|
||||
---@return string[] Array of folder names
|
||||
function M.get_folders(graph)
|
||||
local folders = {}
|
||||
local seen = {}
|
||||
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
if not seen[node.folder] then
|
||||
table.insert(folders, node.folder)
|
||||
seen[node.folder] = true
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(folders)
|
||||
return folders
|
||||
end
|
||||
|
||||
---Gets all unique tags from the graph
|
||||
---@param graph GraphData The graph data
|
||||
---@return string[] Array of tag names
|
||||
function M.get_tags(graph)
|
||||
local tags = {}
|
||||
local seen = {}
|
||||
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
for _, tag in ipairs(node.tags) do
|
||||
if not seen[tag] then
|
||||
table.insert(tags, tag)
|
||||
seen[tag] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(tags)
|
||||
return tags
|
||||
end
|
||||
|
||||
---Applies a filter to the graph
|
||||
---@param graph GraphData The graph data
|
||||
---@param filter_type string|nil "tag", "folder", or nil to clear
|
||||
---@param filter_value string|nil The filter value
|
||||
function M.apply_filter(graph, filter_type, filter_value)
|
||||
-- Reset all visibility first
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
node.visible = true
|
||||
end
|
||||
for _, edge in ipairs(graph.edges) do
|
||||
edge.visible = true
|
||||
end
|
||||
|
||||
-- Apply filter if specified
|
||||
if filter_type and filter_value then
|
||||
-- First pass: hide nodes that don't match
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
local matches = false
|
||||
|
||||
if filter_type == "tag" then
|
||||
for _, tag in ipairs(node.tags) do
|
||||
if tag == filter_value then
|
||||
matches = true
|
||||
break
|
||||
end
|
||||
end
|
||||
elseif filter_type == "folder" then
|
||||
matches = node.folder == filter_value
|
||||
elseif filter_type == "search" then
|
||||
local search_lower = filter_value:lower()
|
||||
matches = node.name:lower():find(search_lower, 1, true) ~= nil
|
||||
or node.id:lower():find(search_lower, 1, true) ~= nil
|
||||
end
|
||||
|
||||
node.visible = matches
|
||||
end
|
||||
|
||||
-- Second pass: hide edges where either endpoint is hidden
|
||||
for _, edge in ipairs(graph.edges) do
|
||||
local source_visible = graph.nodes[edge.source] and graph.nodes[edge.source].visible
|
||||
local target_visible = graph.nodes[edge.target] and graph.nodes[edge.target].visible
|
||||
edge.visible = source_visible and target_visible
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Gets graph statistics
|
||||
---@param graph GraphData The graph data
|
||||
---@return table Statistics
|
||||
function M.get_statistics(graph)
|
||||
local total_nodes = #graph.node_list
|
||||
local visible_nodes = 0
|
||||
local orphan_nodes = 0
|
||||
local total_edges = #graph.edges
|
||||
local visible_edges = 0
|
||||
local max_degree = 0
|
||||
local total_degree = 0
|
||||
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
if node.visible then
|
||||
visible_nodes = visible_nodes + 1
|
||||
end
|
||||
if node.degree == 0 then
|
||||
orphan_nodes = orphan_nodes + 1
|
||||
end
|
||||
if node.degree > max_degree then
|
||||
max_degree = node.degree
|
||||
end
|
||||
total_degree = total_degree + node.degree
|
||||
end
|
||||
|
||||
for _, edge in ipairs(graph.edges) do
|
||||
if edge.visible then
|
||||
visible_edges = visible_edges + 1
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
total_nodes = total_nodes,
|
||||
visible_nodes = visible_nodes,
|
||||
orphan_nodes = orphan_nodes,
|
||||
total_edges = total_edges,
|
||||
visible_edges = visible_edges,
|
||||
max_degree = max_degree,
|
||||
avg_degree = total_nodes > 0 and (total_degree / total_nodes) or 0,
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
574
lua/ideaDrop/ui/graph/init.lua
Normal file
574
lua/ideaDrop/ui/graph/init.lua
Normal file
@@ -0,0 +1,574 @@
|
||||
-- ideaDrop/ui/graph/init.lua
|
||||
-- Main graph visualization module - ties together data, layout, and rendering
|
||||
|
||||
local constants = require("ideaDrop.utils.constants")
|
||||
local types = require("ideaDrop.ui.graph.types")
|
||||
local data = require("ideaDrop.ui.graph.data")
|
||||
local layout = require("ideaDrop.ui.graph.layout")
|
||||
local renderer = require("ideaDrop.ui.graph.renderer")
|
||||
|
||||
---@class GraphModule
|
||||
---@field open fun(opts: table|nil): nil
|
||||
---@field close fun(): nil
|
||||
---@field refresh fun(): nil
|
||||
local M = {}
|
||||
|
||||
-- Module state
|
||||
local state = {
|
||||
buf = nil, ---@type number|nil
|
||||
win = nil, ---@type number|nil
|
||||
ns_id = nil, ---@type number|nil
|
||||
graph = nil, ---@type GraphData|nil
|
||||
view = nil, ---@type GraphViewState|nil
|
||||
layout_state = nil, ---@type GraphLayoutState|nil
|
||||
canvas_width = 0,
|
||||
canvas_height = 0,
|
||||
show_help = false,
|
||||
node_positions = {}, -- Maps screen positions to node IDs
|
||||
}
|
||||
|
||||
local SETTINGS = constants.GRAPH_SETTINGS
|
||||
|
||||
---Calculates window dimensions
|
||||
---@return number width, number height, number row, number col
|
||||
local function get_window_dimensions()
|
||||
local width = math.floor(vim.o.columns * SETTINGS.WINDOW.WIDTH_RATIO)
|
||||
local height = math.floor(vim.o.lines * SETTINGS.WINDOW.HEIGHT_RATIO)
|
||||
local row = math.floor((vim.o.lines - height) / 2)
|
||||
local col = math.floor((vim.o.columns - width) / 2)
|
||||
|
||||
return width, height, row, col
|
||||
end
|
||||
|
||||
---Updates the buffer content with the current graph state
|
||||
local function update_display()
|
||||
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
|
||||
return
|
||||
end
|
||||
|
||||
if not state.graph or not state.view then
|
||||
return
|
||||
end
|
||||
|
||||
-- Render the graph
|
||||
local canvas = renderer.render(state.graph, state.view, state.canvas_width, state.canvas_height - 2) -- Reserve 2 lines for status
|
||||
|
||||
-- Convert to lines
|
||||
local lines = renderer.canvas_to_lines(canvas)
|
||||
|
||||
-- Add status line
|
||||
local status = renderer.get_status_line(state.graph, state.view)
|
||||
table.insert(lines, string.rep("─", state.canvas_width))
|
||||
table.insert(lines, status)
|
||||
|
||||
-- Show help overlay if enabled
|
||||
if state.show_help then
|
||||
local help_lines = renderer.render_help()
|
||||
local help_start_y = math.floor((state.canvas_height - #help_lines) / 2)
|
||||
local help_start_x = math.floor((state.canvas_width - 42) / 2) -- Help box is ~42 chars wide
|
||||
|
||||
for i, help_line in ipairs(help_lines) do
|
||||
local y = help_start_y + i
|
||||
if y >= 1 and y <= #lines then
|
||||
-- Overlay help on top of graph
|
||||
local current_line = lines[y]
|
||||
local new_line = current_line:sub(1, help_start_x - 1)
|
||||
.. help_line
|
||||
.. current_line:sub(help_start_x + #help_line + 1)
|
||||
lines[y] = new_line
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Update buffer
|
||||
vim.api.nvim_buf_set_option(state.buf, "modifiable", true)
|
||||
vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, lines)
|
||||
vim.api.nvim_buf_set_option(state.buf, "modifiable", false)
|
||||
|
||||
-- Apply highlights
|
||||
renderer.apply_highlights(state.buf, canvas, state.ns_id)
|
||||
|
||||
-- Build node position map for cursor navigation
|
||||
state.node_positions = {}
|
||||
for _, node in ipairs(state.graph.node_list) do
|
||||
if node.visible then
|
||||
local x = math.floor((node.x - state.canvas_width / 2) * state.view.zoom + state.canvas_width / 2 + state.view.offset_x + 0.5)
|
||||
local y = math.floor((node.y - state.canvas_height / 2) * state.view.zoom + state.canvas_height / 2 + state.view.offset_y + 0.5)
|
||||
|
||||
if x >= 1 and x <= state.canvas_width and y >= 1 and y <= state.canvas_height - 2 then
|
||||
local key = string.format("%d,%d", y, x)
|
||||
state.node_positions[key] = node.id
|
||||
|
||||
-- Also register nearby positions for easier selection
|
||||
for dy = -1, 1 do
|
||||
for dx = -1, 1 do
|
||||
local nkey = string.format("%d,%d", y + dy, x + dx)
|
||||
if not state.node_positions[nkey] then
|
||||
state.node_positions[nkey] = node.id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Finds the node nearest to the cursor
|
||||
---@return GraphNode|nil
|
||||
local function get_node_at_cursor()
|
||||
if not state.win or not vim.api.nvim_win_is_valid(state.win) then
|
||||
return nil
|
||||
end
|
||||
|
||||
local cursor = vim.api.nvim_win_get_cursor(state.win)
|
||||
local row, col = cursor[1], cursor[2] + 1 -- Convert to 1-indexed
|
||||
|
||||
local key = string.format("%d,%d", row, col)
|
||||
local node_id = state.node_positions[key]
|
||||
|
||||
if node_id and state.graph then
|
||||
return state.graph.nodes[node_id]
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
---Moves selection to the nearest node in a direction
|
||||
---@param direction string "up", "down", "left", "right"
|
||||
local function move_selection(direction)
|
||||
if not state.graph or not state.view then
|
||||
return
|
||||
end
|
||||
|
||||
local current_node = nil
|
||||
if state.view.selected_node then
|
||||
current_node = state.graph.nodes[state.view.selected_node]
|
||||
end
|
||||
|
||||
-- If no selection, select the first visible node
|
||||
if not current_node then
|
||||
for _, node in ipairs(state.graph.node_list) do
|
||||
if node.visible then
|
||||
state.view.selected_node = node.id
|
||||
update_display()
|
||||
return
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Find the nearest node in the given direction
|
||||
local best_node = nil
|
||||
local best_score = math.huge
|
||||
|
||||
for _, node in ipairs(state.graph.node_list) do
|
||||
if node.visible and node.id ~= current_node.id then
|
||||
local dx = node.x - current_node.x
|
||||
local dy = node.y - current_node.y
|
||||
|
||||
local valid = false
|
||||
local score = 0
|
||||
|
||||
if direction == "up" and dy < 0 then
|
||||
valid = true
|
||||
score = math.abs(dy) + math.abs(dx) * 2 -- Prefer vertical alignment
|
||||
elseif direction == "down" and dy > 0 then
|
||||
valid = true
|
||||
score = math.abs(dy) + math.abs(dx) * 2
|
||||
elseif direction == "left" and dx < 0 then
|
||||
valid = true
|
||||
score = math.abs(dx) + math.abs(dy) * 2
|
||||
elseif direction == "right" and dx > 0 then
|
||||
valid = true
|
||||
score = math.abs(dx) + math.abs(dy) * 2
|
||||
end
|
||||
|
||||
if valid and score < best_score then
|
||||
best_score = score
|
||||
best_node = node
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if best_node then
|
||||
state.view.selected_node = best_node.id
|
||||
update_display()
|
||||
end
|
||||
end
|
||||
|
||||
---Opens the selected node's file
|
||||
local function open_selected_node()
|
||||
if not state.view or not state.view.selected_node or not state.graph then
|
||||
-- Try to get node at cursor
|
||||
local node = get_node_at_cursor()
|
||||
if node then
|
||||
state.view.selected_node = node.id
|
||||
else
|
||||
vim.notify("No node selected", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local node = state.graph.nodes[state.view.selected_node]
|
||||
if not node then
|
||||
return
|
||||
end
|
||||
|
||||
-- Close graph window
|
||||
M.close()
|
||||
|
||||
-- Open the file in right-side buffer
|
||||
local sidebar = require("ideaDrop.ui.sidebar")
|
||||
local filename = vim.fn.fnamemodify(node.file_path, ":t")
|
||||
sidebar.open_right_side(node.file_path, filename)
|
||||
end
|
||||
|
||||
---Shows tag filter picker
|
||||
local function show_tag_filter()
|
||||
if not state.graph then
|
||||
return
|
||||
end
|
||||
|
||||
local tags = data.get_tags(state.graph)
|
||||
|
||||
if #tags == 0 then
|
||||
vim.notify("No tags found in graph", vim.log.levels.INFO)
|
||||
return
|
||||
end
|
||||
|
||||
-- Add "Clear filter" option
|
||||
table.insert(tags, 1, "(Clear filter)")
|
||||
|
||||
vim.ui.select(tags, { prompt = "🏷️ Filter by tag:" }, function(choice)
|
||||
if choice then
|
||||
if choice == "(Clear filter)" then
|
||||
data.apply_filter(state.graph, nil, nil)
|
||||
state.view.filter.active = false
|
||||
else
|
||||
data.apply_filter(state.graph, "tag", choice)
|
||||
state.view.filter = { type = "tag", value = choice, active = true }
|
||||
|
||||
-- Re-run layout for filtered graph
|
||||
layout.adjust_after_filter(state.graph, state.canvas_width, state.canvas_height - 2, 100)
|
||||
layout.center_graph(state.graph, state.canvas_width, state.canvas_height - 2)
|
||||
end
|
||||
update_display()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---Shows folder filter picker
|
||||
local function show_folder_filter()
|
||||
if not state.graph then
|
||||
return
|
||||
end
|
||||
|
||||
local folders = data.get_folders(state.graph)
|
||||
|
||||
if #folders == 0 then
|
||||
vim.notify("No folders found in graph", vim.log.levels.INFO)
|
||||
return
|
||||
end
|
||||
|
||||
-- Add "Clear filter" option
|
||||
table.insert(folders, 1, "(Clear filter)")
|
||||
|
||||
vim.ui.select(folders, { prompt = "📁 Filter by folder:" }, function(choice)
|
||||
if choice then
|
||||
if choice == "(Clear filter)" then
|
||||
data.apply_filter(state.graph, nil, nil)
|
||||
state.view.filter.active = false
|
||||
else
|
||||
data.apply_filter(state.graph, "folder", choice)
|
||||
state.view.filter = { type = "folder", value = choice, active = true }
|
||||
|
||||
-- Re-run layout for filtered graph
|
||||
layout.adjust_after_filter(state.graph, state.canvas_width, state.canvas_height - 2, 100)
|
||||
layout.center_graph(state.graph, state.canvas_width, state.canvas_height - 2)
|
||||
end
|
||||
update_display()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---Resets the filter
|
||||
local function reset_filter()
|
||||
if not state.graph or not state.view then
|
||||
return
|
||||
end
|
||||
|
||||
data.apply_filter(state.graph, nil, nil)
|
||||
state.view.filter = { type = nil, value = nil, active = false }
|
||||
|
||||
-- Re-run layout
|
||||
layout.adjust_after_filter(state.graph, state.canvas_width, state.canvas_height - 2, 50)
|
||||
update_display()
|
||||
end
|
||||
|
||||
---Toggles label display
|
||||
local function toggle_labels()
|
||||
if state.view then
|
||||
state.view.show_labels = not state.view.show_labels
|
||||
update_display()
|
||||
end
|
||||
end
|
||||
|
||||
---Centers the graph in the view
|
||||
local function center_graph()
|
||||
if state.graph then
|
||||
layout.center_graph(state.graph, state.canvas_width, state.canvas_height - 2)
|
||||
state.view.offset_x = 0
|
||||
state.view.offset_y = 0
|
||||
update_display()
|
||||
end
|
||||
end
|
||||
|
||||
---Zooms in
|
||||
local function zoom_in()
|
||||
if state.view then
|
||||
state.view.zoom = math.min(state.view.zoom * 1.2, 3.0)
|
||||
update_display()
|
||||
end
|
||||
end
|
||||
|
||||
---Zooms out
|
||||
local function zoom_out()
|
||||
if state.view then
|
||||
state.view.zoom = math.max(state.view.zoom / 1.2, 0.3)
|
||||
update_display()
|
||||
end
|
||||
end
|
||||
|
||||
---Toggles help display
|
||||
local function toggle_help()
|
||||
state.show_help = not state.show_help
|
||||
update_display()
|
||||
end
|
||||
|
||||
---Sets up keymaps for the graph buffer
|
||||
local function setup_keymaps()
|
||||
if not state.buf then
|
||||
return
|
||||
end
|
||||
|
||||
local opts = { noremap = true, silent = true, buffer = state.buf }
|
||||
|
||||
-- Navigation
|
||||
vim.keymap.set("n", "k", function()
|
||||
move_selection("up")
|
||||
end, opts)
|
||||
vim.keymap.set("n", "j", function()
|
||||
move_selection("down")
|
||||
end, opts)
|
||||
vim.keymap.set("n", "h", function()
|
||||
move_selection("left")
|
||||
end, opts)
|
||||
vim.keymap.set("n", "l", function()
|
||||
move_selection("right")
|
||||
end, opts)
|
||||
|
||||
-- Actions
|
||||
vim.keymap.set("n", "<CR>", open_selected_node, opts)
|
||||
vim.keymap.set("n", "o", open_selected_node, opts)
|
||||
|
||||
-- Filtering
|
||||
vim.keymap.set("n", "t", show_tag_filter, opts)
|
||||
vim.keymap.set("n", "f", show_folder_filter, opts)
|
||||
vim.keymap.set("n", "r", reset_filter, opts)
|
||||
|
||||
-- Display
|
||||
vim.keymap.set("n", "L", toggle_labels, opts) -- Changed to uppercase to avoid conflict
|
||||
vim.keymap.set("n", "c", center_graph, opts)
|
||||
vim.keymap.set("n", "+", zoom_in, opts)
|
||||
vim.keymap.set("n", "=", zoom_in, opts) -- Also = for convenience
|
||||
vim.keymap.set("n", "-", zoom_out, opts)
|
||||
vim.keymap.set("n", "?", toggle_help, opts)
|
||||
|
||||
-- Close
|
||||
vim.keymap.set("n", "q", M.close, opts)
|
||||
vim.keymap.set("n", "<Esc>", M.close, opts)
|
||||
|
||||
-- Refresh
|
||||
vim.keymap.set("n", "R", M.refresh, opts) -- Uppercase R for refresh
|
||||
end
|
||||
|
||||
---Opens the graph visualization window
|
||||
---@param opts table|nil Options
|
||||
function M.open(opts)
|
||||
opts = opts or {}
|
||||
|
||||
-- Close existing window if open
|
||||
if state.win and vim.api.nvim_win_is_valid(state.win) then
|
||||
M.close()
|
||||
end
|
||||
|
||||
-- Setup highlight groups
|
||||
renderer.setup_highlights()
|
||||
|
||||
-- Create namespace for highlights
|
||||
state.ns_id = vim.api.nvim_create_namespace("ideadrop_graph")
|
||||
|
||||
-- Calculate dimensions
|
||||
local width, height, row, col = get_window_dimensions()
|
||||
state.canvas_width = width - 2 -- Account for border
|
||||
state.canvas_height = height - 2
|
||||
|
||||
-- Create buffer
|
||||
state.buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_option(state.buf, "buftype", "nofile")
|
||||
vim.api.nvim_buf_set_option(state.buf, "bufhidden", "wipe")
|
||||
vim.api.nvim_buf_set_option(state.buf, "filetype", "ideadrop-graph")
|
||||
|
||||
-- Create window
|
||||
state.win = vim.api.nvim_open_win(state.buf, true, {
|
||||
relative = "editor",
|
||||
width = width,
|
||||
height = height,
|
||||
row = row,
|
||||
col = col,
|
||||
border = SETTINGS.WINDOW.BORDER,
|
||||
title = SETTINGS.WINDOW.TITLE,
|
||||
title_pos = "center",
|
||||
style = "minimal",
|
||||
})
|
||||
|
||||
-- Set window options for dark background
|
||||
vim.api.nvim_win_set_option(state.win, "winhl", "Normal:" .. SETTINGS.COLORS.BACKGROUND)
|
||||
vim.api.nvim_win_set_option(state.win, "cursorline", false)
|
||||
|
||||
-- Build graph data
|
||||
vim.notify("Building graph...", vim.log.levels.INFO)
|
||||
state.graph = data.build_graph()
|
||||
|
||||
if #state.graph.node_list == 0 then
|
||||
vim.notify("No notes found to visualize", vim.log.levels.WARN)
|
||||
M.close()
|
||||
return
|
||||
end
|
||||
|
||||
-- Initialize view state
|
||||
state.view = types.create_view_state()
|
||||
|
||||
-- Run layout algorithm
|
||||
vim.notify(string.format("Laying out %d nodes...", #state.graph.node_list), vim.log.levels.INFO)
|
||||
|
||||
if opts.animate then
|
||||
-- Animated layout
|
||||
state.layout_state = layout.start_animated_layout(
|
||||
state.graph,
|
||||
state.canvas_width,
|
||||
state.canvas_height - 2,
|
||||
function(converged)
|
||||
update_display()
|
||||
if converged then
|
||||
vim.notify("Graph layout complete", vim.log.levels.INFO)
|
||||
end
|
||||
end,
|
||||
32 -- ~30fps for smoother animation
|
||||
)
|
||||
else
|
||||
-- Synchronous layout
|
||||
layout.run_layout(state.graph, state.canvas_width, state.canvas_height - 2)
|
||||
layout.center_graph(state.graph, state.canvas_width, state.canvas_height - 2)
|
||||
end
|
||||
|
||||
-- Setup keymaps
|
||||
setup_keymaps()
|
||||
|
||||
-- Initial render
|
||||
update_display()
|
||||
|
||||
-- Auto-close when window loses focus (optional)
|
||||
vim.api.nvim_create_autocmd("WinLeave", {
|
||||
buffer = state.buf,
|
||||
once = true,
|
||||
callback = function()
|
||||
-- Don't close if a picker is open
|
||||
vim.defer_fn(function()
|
||||
local current_win = vim.api.nvim_get_current_win()
|
||||
if current_win ~= state.win and state.win and vim.api.nvim_win_is_valid(state.win) then
|
||||
-- Check if we're in a picker/popup
|
||||
local win_config = vim.api.nvim_win_get_config(current_win)
|
||||
if not win_config.relative or win_config.relative == "" then
|
||||
-- Not in a floating window, might want to close
|
||||
-- But let's keep it open for better UX
|
||||
end
|
||||
end
|
||||
end, 100)
|
||||
end,
|
||||
})
|
||||
|
||||
-- Show stats
|
||||
local stats = data.get_statistics(state.graph)
|
||||
vim.notify(
|
||||
string.format("Graph: %d nodes, %d edges, %d orphans", stats.total_nodes, stats.total_edges, stats.orphan_nodes),
|
||||
vim.log.levels.INFO
|
||||
)
|
||||
end
|
||||
|
||||
---Closes the graph visualization window
|
||||
function M.close()
|
||||
-- Stop animated layout if running
|
||||
if state.layout_state then
|
||||
layout.stop_animated_layout(state.layout_state)
|
||||
state.layout_state = nil
|
||||
end
|
||||
|
||||
-- Close window
|
||||
if state.win and vim.api.nvim_win_is_valid(state.win) then
|
||||
vim.api.nvim_win_close(state.win, true)
|
||||
end
|
||||
|
||||
-- Clean up state
|
||||
state.win = nil
|
||||
state.buf = nil
|
||||
state.graph = nil
|
||||
state.view = nil
|
||||
state.node_positions = {}
|
||||
state.show_help = false
|
||||
end
|
||||
|
||||
---Refreshes the graph data and re-renders
|
||||
function M.refresh()
|
||||
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
|
||||
return
|
||||
end
|
||||
|
||||
vim.notify("Refreshing graph...", vim.log.levels.INFO)
|
||||
|
||||
-- Rebuild graph
|
||||
state.graph = data.build_graph()
|
||||
|
||||
if #state.graph.node_list == 0 then
|
||||
vim.notify("No notes found to visualize", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
-- Re-apply filter if active
|
||||
if state.view and state.view.filter.active then
|
||||
data.apply_filter(state.graph, state.view.filter.type, state.view.filter.value)
|
||||
end
|
||||
|
||||
-- Re-run layout
|
||||
layout.run_layout(state.graph, state.canvas_width, state.canvas_height - 2)
|
||||
layout.center_graph(state.graph, state.canvas_width, state.canvas_height - 2)
|
||||
|
||||
-- Update display
|
||||
update_display()
|
||||
|
||||
vim.notify("Graph refreshed", vim.log.levels.INFO)
|
||||
end
|
||||
|
||||
---Gets the current graph data (for external use)
|
||||
---@return GraphData|nil
|
||||
function M.get_graph()
|
||||
return state.graph
|
||||
end
|
||||
|
||||
---Checks if the graph window is open
|
||||
---@return boolean
|
||||
function M.is_open()
|
||||
return state.win ~= nil and vim.api.nvim_win_is_valid(state.win)
|
||||
end
|
||||
|
||||
return M
|
||||
370
lua/ideaDrop/ui/graph/layout.lua
Normal file
370
lua/ideaDrop/ui/graph/layout.lua
Normal file
@@ -0,0 +1,370 @@
|
||||
-- ideaDrop/ui/graph/layout.lua
|
||||
-- Force-directed graph layout using Fruchterman-Reingold algorithm
|
||||
|
||||
local constants = require("ideaDrop.utils.constants")
|
||||
local types = require("ideaDrop.ui.graph.types")
|
||||
|
||||
---@class GraphLayoutModule
|
||||
---@field initialize_positions fun(graph: GraphData, width: number, height: number): nil
|
||||
---@field step fun(graph: GraphData, state: GraphLayoutState, width: number, height: number): boolean
|
||||
---@field run_layout fun(graph: GraphData, width: number, height: number, max_iterations: number|nil): nil
|
||||
local M = {}
|
||||
|
||||
local SETTINGS = constants.GRAPH_SETTINGS.LAYOUT
|
||||
|
||||
---Initializes node positions randomly within the canvas bounds
|
||||
---@param graph GraphData The graph data
|
||||
---@param width number Canvas width
|
||||
---@param height number Canvas height
|
||||
function M.initialize_positions(graph, width, height)
|
||||
local padding = constants.GRAPH_SETTINGS.VISUAL.PADDING
|
||||
local effective_width = width - 2 * padding
|
||||
local effective_height = height - 2 * padding
|
||||
local center_x = width / 2
|
||||
local center_y = height / 2
|
||||
|
||||
-- Seed random for reproducible layouts (based on node count)
|
||||
math.randomseed(#graph.node_list * 12345)
|
||||
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
-- Initialize in a circular pattern with some randomness
|
||||
local angle = math.random() * 2 * math.pi
|
||||
local radius = math.random() * math.min(effective_width, effective_height) / 3
|
||||
|
||||
node.x = center_x + radius * math.cos(angle)
|
||||
node.y = center_y + radius * math.sin(angle)
|
||||
node.vx = 0
|
||||
node.vy = 0
|
||||
end
|
||||
|
||||
-- Special handling: place high-degree nodes closer to center initially
|
||||
local max_degree = 0
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
if node.degree > max_degree then
|
||||
max_degree = node.degree
|
||||
end
|
||||
end
|
||||
|
||||
if max_degree > 0 then
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
local centrality = node.degree / max_degree
|
||||
-- Move high-degree nodes toward center
|
||||
node.x = center_x + (node.x - center_x) * (1 - centrality * 0.5)
|
||||
node.y = center_y + (node.y - center_y) * (1 - centrality * 0.5)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Calculates the repulsive force between two nodes
|
||||
---@param dx number X distance
|
||||
---@param dy number Y distance
|
||||
---@param distance number Euclidean distance
|
||||
---@return number, number Force components (fx, fy)
|
||||
local function repulsive_force(dx, dy, distance)
|
||||
if distance < 0.1 then
|
||||
distance = 0.1 -- Prevent division by zero
|
||||
end
|
||||
|
||||
local force = SETTINGS.REPULSION_STRENGTH / (distance * distance)
|
||||
|
||||
return (dx / distance) * force, (dy / distance) * force
|
||||
end
|
||||
|
||||
---Calculates the attractive force between connected nodes
|
||||
---@param dx number X distance
|
||||
---@param dy number Y distance
|
||||
---@param distance number Euclidean distance
|
||||
---@return number, number Force components (fx, fy)
|
||||
local function attractive_force(dx, dy, distance)
|
||||
if distance < 0.1 then
|
||||
distance = 0.1
|
||||
end
|
||||
|
||||
local force = SETTINGS.ATTRACTION_STRENGTH * (distance - SETTINGS.IDEAL_EDGE_LENGTH)
|
||||
|
||||
return (dx / distance) * force, (dy / distance) * force
|
||||
end
|
||||
|
||||
---Calculates gravity force pulling nodes toward center
|
||||
---@param node GraphNode The node
|
||||
---@param center_x number Center X coordinate
|
||||
---@param center_y number Center Y coordinate
|
||||
---@return number, number Force components (fx, fy)
|
||||
local function gravity_force(node, center_x, center_y)
|
||||
local dx = center_x - node.x
|
||||
local dy = center_y - node.y
|
||||
local distance = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if distance < 0.1 then
|
||||
return 0, 0
|
||||
end
|
||||
|
||||
-- Gravity is stronger for orphan/low-degree nodes (pushes them to periphery)
|
||||
-- and weaker for high-degree nodes (lets them stay in center)
|
||||
local degree_factor = 1 / (1 + node.degree * 0.5)
|
||||
local force = SETTINGS.GRAVITY * distance * degree_factor
|
||||
|
||||
-- Invert for orphans - push them away from center
|
||||
if node.degree == 0 then
|
||||
force = -force * 0.5
|
||||
end
|
||||
|
||||
return (dx / distance) * force, (dy / distance) * force
|
||||
end
|
||||
|
||||
---Performs one iteration of the force-directed layout
|
||||
---@param graph GraphData The graph data
|
||||
---@param state GraphLayoutState The layout state
|
||||
---@param width number Canvas width
|
||||
---@param height number Canvas height
|
||||
---@return boolean True if layout has converged
|
||||
function M.step(graph, state, width, height)
|
||||
local padding = constants.GRAPH_SETTINGS.VISUAL.PADDING
|
||||
local center_x = width / 2
|
||||
local center_y = height / 2
|
||||
|
||||
-- Count visible nodes
|
||||
local visible_nodes = {}
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
if node.visible then
|
||||
table.insert(visible_nodes, node)
|
||||
end
|
||||
end
|
||||
|
||||
if #visible_nodes == 0 then
|
||||
state.converged = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- Reset forces
|
||||
for _, node in ipairs(visible_nodes) do
|
||||
node.vx = 0
|
||||
node.vy = 0
|
||||
end
|
||||
|
||||
-- Calculate repulsive forces between all pairs of visible nodes
|
||||
for i = 1, #visible_nodes do
|
||||
local node1 = visible_nodes[i]
|
||||
for j = i + 1, #visible_nodes do
|
||||
local node2 = visible_nodes[j]
|
||||
|
||||
local dx = node1.x - node2.x
|
||||
local dy = node1.y - node2.y
|
||||
local distance = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
local fx, fy = repulsive_force(dx, dy, distance)
|
||||
|
||||
node1.vx = node1.vx + fx
|
||||
node1.vy = node1.vy + fy
|
||||
node2.vx = node2.vx - fx
|
||||
node2.vy = node2.vy - fy
|
||||
end
|
||||
end
|
||||
|
||||
-- Calculate attractive forces for visible edges
|
||||
for _, edge in ipairs(graph.edges) do
|
||||
if edge.visible then
|
||||
local source = graph.nodes[edge.source]
|
||||
local target = graph.nodes[edge.target]
|
||||
|
||||
if source and target and source.visible and target.visible then
|
||||
local dx = target.x - source.x
|
||||
local dy = target.y - source.y
|
||||
local distance = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
local fx, fy = attractive_force(dx, dy, distance)
|
||||
|
||||
source.vx = source.vx + fx
|
||||
source.vy = source.vy + fy
|
||||
target.vx = target.vx - fx
|
||||
target.vy = target.vy - fy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply gravity force
|
||||
for _, node in ipairs(visible_nodes) do
|
||||
local gx, gy = gravity_force(node, center_x, center_y)
|
||||
node.vx = node.vx + gx
|
||||
node.vy = node.vy + gy
|
||||
end
|
||||
|
||||
-- Apply forces with temperature-limited displacement
|
||||
local max_displacement = 0
|
||||
|
||||
for _, node in ipairs(visible_nodes) do
|
||||
-- Skip fixed nodes
|
||||
if node.fx then
|
||||
node.x = node.fx
|
||||
else
|
||||
local displacement = math.sqrt(node.vx * node.vx + node.vy * node.vy)
|
||||
|
||||
if displacement > 0 then
|
||||
-- Limit displacement by temperature
|
||||
local limited_displacement = math.min(displacement, state.temperature)
|
||||
local factor = limited_displacement / displacement
|
||||
|
||||
local dx = node.vx * factor
|
||||
local dy = node.vy * factor
|
||||
|
||||
node.x = node.x + dx
|
||||
node.y = node.y + dy
|
||||
|
||||
if math.abs(dx) > max_displacement then
|
||||
max_displacement = math.abs(dx)
|
||||
end
|
||||
if math.abs(dy) > max_displacement then
|
||||
max_displacement = math.abs(dy)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if node.fy then
|
||||
node.y = node.fy
|
||||
end
|
||||
|
||||
-- Keep nodes within bounds
|
||||
node.x = math.max(padding, math.min(width - padding, node.x))
|
||||
node.y = math.max(padding, math.min(height - padding, node.y))
|
||||
end
|
||||
|
||||
-- Cool down temperature
|
||||
state.temperature = state.temperature * SETTINGS.COOLING_RATE
|
||||
state.iteration = state.iteration + 1
|
||||
|
||||
-- Check convergence
|
||||
state.converged = max_displacement < SETTINGS.MIN_VELOCITY
|
||||
or state.iteration >= SETTINGS.MAX_ITERATIONS
|
||||
|
||||
return state.converged
|
||||
end
|
||||
|
||||
---Runs the complete layout algorithm synchronously
|
||||
---@param graph GraphData The graph data
|
||||
---@param width number Canvas width
|
||||
---@param height number Canvas height
|
||||
---@param max_iterations number|nil Maximum iterations (defaults to SETTINGS.MAX_ITERATIONS)
|
||||
function M.run_layout(graph, width, height, max_iterations)
|
||||
max_iterations = max_iterations or SETTINGS.MAX_ITERATIONS
|
||||
|
||||
-- Initialize positions
|
||||
M.initialize_positions(graph, width, height)
|
||||
|
||||
-- Create layout state
|
||||
local state = types.create_layout_state(SETTINGS.INITIAL_TEMPERATURE)
|
||||
|
||||
-- Run until convergence
|
||||
while not state.converged and state.iteration < max_iterations do
|
||||
M.step(graph, state, width, height)
|
||||
end
|
||||
end
|
||||
|
||||
---Creates an animated layout that updates incrementally
|
||||
---@param graph GraphData The graph data
|
||||
---@param width number Canvas width
|
||||
---@param height number Canvas height
|
||||
---@param on_step fun(converged: boolean): nil Callback after each step
|
||||
---@param frame_delay number|nil Delay between frames in ms (default 16ms ~60fps)
|
||||
---@return GraphLayoutState The layout state (can be used to stop animation)
|
||||
function M.start_animated_layout(graph, width, height, on_step, frame_delay)
|
||||
frame_delay = frame_delay or 16
|
||||
|
||||
-- Initialize positions
|
||||
M.initialize_positions(graph, width, height)
|
||||
|
||||
-- Create layout state
|
||||
local state = types.create_layout_state(SETTINGS.INITIAL_TEMPERATURE)
|
||||
state.running = true
|
||||
|
||||
-- Animation function
|
||||
local function animate()
|
||||
if not state.running then
|
||||
return
|
||||
end
|
||||
|
||||
local converged = M.step(graph, state, width, height)
|
||||
|
||||
if on_step then
|
||||
on_step(converged)
|
||||
end
|
||||
|
||||
if not converged and state.running then
|
||||
state.timer = vim.defer_fn(animate, frame_delay)
|
||||
else
|
||||
state.running = false
|
||||
end
|
||||
end
|
||||
|
||||
-- Start animation
|
||||
vim.defer_fn(animate, 0)
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
---Stops an animated layout
|
||||
---@param state GraphLayoutState The layout state
|
||||
function M.stop_animated_layout(state)
|
||||
state.running = false
|
||||
if state.timer then
|
||||
-- Timer will naturally stop on next check
|
||||
state.timer = nil
|
||||
end
|
||||
end
|
||||
|
||||
---Adjusts layout after filter changes (re-runs partial layout)
|
||||
---@param graph GraphData The graph data
|
||||
---@param width number Canvas width
|
||||
---@param height number Canvas height
|
||||
---@param iterations number|nil Number of adjustment iterations
|
||||
function M.adjust_after_filter(graph, width, height, iterations)
|
||||
iterations = iterations or 50
|
||||
|
||||
local state = types.create_layout_state(SETTINGS.INITIAL_TEMPERATURE * 0.3)
|
||||
|
||||
for _ = 1, iterations do
|
||||
if M.step(graph, state, width, height) then
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Centers the visible graph within the canvas
|
||||
---@param graph GraphData The graph data
|
||||
---@param width number Canvas width
|
||||
---@param height number Canvas height
|
||||
function M.center_graph(graph, width, height)
|
||||
local min_x, max_x = math.huge, -math.huge
|
||||
local min_y, max_y = math.huge, -math.huge
|
||||
local visible_count = 0
|
||||
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
if node.visible then
|
||||
min_x = math.min(min_x, node.x)
|
||||
max_x = math.max(max_x, node.x)
|
||||
min_y = math.min(min_y, node.y)
|
||||
max_y = math.max(max_y, node.y)
|
||||
visible_count = visible_count + 1
|
||||
end
|
||||
end
|
||||
|
||||
if visible_count == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
local graph_center_x = (min_x + max_x) / 2
|
||||
local graph_center_y = (min_y + max_y) / 2
|
||||
local canvas_center_x = width / 2
|
||||
local canvas_center_y = height / 2
|
||||
|
||||
local offset_x = canvas_center_x - graph_center_x
|
||||
local offset_y = canvas_center_y - graph_center_y
|
||||
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
if node.visible then
|
||||
node.x = node.x + offset_x
|
||||
node.y = node.y + offset_y
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
424
lua/ideaDrop/ui/graph/renderer.lua
Normal file
424
lua/ideaDrop/ui/graph/renderer.lua
Normal file
@@ -0,0 +1,424 @@
|
||||
-- ideaDrop/ui/graph/renderer.lua
|
||||
-- Character-based canvas renderer for graph visualization
|
||||
|
||||
local constants = require("ideaDrop.utils.constants")
|
||||
local types = require("ideaDrop.ui.graph.types")
|
||||
|
||||
---@class GraphRendererModule
|
||||
---@field render fun(graph: GraphData, view: GraphViewState, width: number, height: number): GraphCanvas
|
||||
---@field canvas_to_lines fun(canvas: GraphCanvas): string[]
|
||||
local M = {}
|
||||
|
||||
local VISUAL = constants.GRAPH_SETTINGS.VISUAL
|
||||
local COLORS = constants.GRAPH_SETTINGS.COLORS
|
||||
local THRESHOLDS = constants.GRAPH_SETTINGS.NODE_DEGREE_THRESHOLDS
|
||||
|
||||
---Gets the visual size of a node based on its degree
|
||||
---@param degree number Node degree
|
||||
---@return number Size (1-3)
|
||||
local function get_node_size(degree)
|
||||
if degree <= THRESHOLDS.SMALL then
|
||||
return VISUAL.MIN_NODE_SIZE
|
||||
elseif degree <= THRESHOLDS.MEDIUM then
|
||||
return 2
|
||||
else
|
||||
return VISUAL.MAX_NODE_SIZE
|
||||
end
|
||||
end
|
||||
|
||||
---Gets the character for a node based on its size
|
||||
---@param size number Node size
|
||||
---@return string Character
|
||||
local function get_node_char(size)
|
||||
if size <= 1 then
|
||||
return VISUAL.NODE_CHAR_SMALL
|
||||
else
|
||||
return VISUAL.NODE_CHAR
|
||||
end
|
||||
end
|
||||
|
||||
---Gets the highlight group for a node
|
||||
---@param node GraphNode The node
|
||||
---@param view GraphViewState The view state
|
||||
---@return string Highlight group name
|
||||
local function get_node_highlight(node, view)
|
||||
if node.selected or node.id == view.selected_node then
|
||||
return COLORS.NODE_SELECTED
|
||||
elseif node.degree == 0 then
|
||||
return COLORS.NODE_ORPHAN
|
||||
elseif node.degree > THRESHOLDS.MEDIUM then
|
||||
return COLORS.NODE_HIGH_DEGREE
|
||||
else
|
||||
return COLORS.NODE_DEFAULT
|
||||
end
|
||||
end
|
||||
|
||||
---Draws a line between two points using Bresenham's algorithm
|
||||
---@param canvas GraphCanvas The canvas
|
||||
---@param x1 number Start X
|
||||
---@param y1 number Start Y
|
||||
---@param x2 number End X
|
||||
---@param y2 number End Y
|
||||
---@param char string|nil Character to use (default: edge char)
|
||||
local function draw_line(canvas, x1, y1, x2, y2, char)
|
||||
char = char or VISUAL.EDGE_CHAR_SIMPLE
|
||||
|
||||
-- Round coordinates
|
||||
x1 = math.floor(x1 + 0.5)
|
||||
y1 = math.floor(y1 + 0.5)
|
||||
x2 = math.floor(x2 + 0.5)
|
||||
y2 = math.floor(y2 + 0.5)
|
||||
|
||||
local dx = math.abs(x2 - x1)
|
||||
local dy = math.abs(y2 - y1)
|
||||
local sx = x1 < x2 and 1 or -1
|
||||
local sy = y1 < y2 and 1 or -1
|
||||
local err = dx - dy
|
||||
|
||||
local max_iterations = math.max(dx, dy) * 2 + 10
|
||||
local iterations = 0
|
||||
|
||||
while true do
|
||||
iterations = iterations + 1
|
||||
if iterations > max_iterations then
|
||||
break
|
||||
end
|
||||
|
||||
-- Draw point if within bounds
|
||||
if x1 >= 1 and x1 <= canvas.width and y1 >= 1 and y1 <= canvas.height then
|
||||
-- Don't overwrite nodes (marked with special characters)
|
||||
local current = canvas.buffer[y1][x1]
|
||||
if current == " " or current == VISUAL.EDGE_CHAR_SIMPLE then
|
||||
canvas.buffer[y1][x1] = char
|
||||
|
||||
-- Add highlight
|
||||
table.insert(canvas.highlights, {
|
||||
group = COLORS.EDGE,
|
||||
line = y1 - 1, -- 0-indexed
|
||||
col_start = x1 - 1,
|
||||
col_end = x1,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
if x1 == x2 and y1 == y2 then
|
||||
break
|
||||
end
|
||||
|
||||
local e2 = 2 * err
|
||||
if e2 > -dy then
|
||||
err = err - dy
|
||||
x1 = x1 + sx
|
||||
end
|
||||
if e2 < dx then
|
||||
err = err + dx
|
||||
y1 = y1 + sy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Draws a node on the canvas
|
||||
---@param canvas GraphCanvas The canvas
|
||||
---@param node GraphNode The node
|
||||
---@param view GraphViewState The view state
|
||||
local function draw_node(canvas, node, view)
|
||||
if not node.visible then
|
||||
return
|
||||
end
|
||||
|
||||
local x = math.floor(node.x + 0.5)
|
||||
local y = math.floor(node.y + 0.5)
|
||||
|
||||
-- Check bounds
|
||||
if x < 1 or x > canvas.width or y < 1 or y > canvas.height then
|
||||
return
|
||||
end
|
||||
|
||||
local size = get_node_size(node.degree)
|
||||
local char = get_node_char(size)
|
||||
local highlight = get_node_highlight(node, view)
|
||||
|
||||
-- Draw the node
|
||||
canvas.buffer[y][x] = char
|
||||
|
||||
-- Add highlight for the node
|
||||
table.insert(canvas.highlights, {
|
||||
group = highlight,
|
||||
line = y - 1, -- 0-indexed
|
||||
col_start = x - 1,
|
||||
col_end = x + #char - 1,
|
||||
})
|
||||
|
||||
-- Draw larger nodes as multiple characters
|
||||
if size >= 2 then
|
||||
-- Draw adjacent characters for larger nodes
|
||||
local offsets = { { -1, 0 }, { 1, 0 } }
|
||||
if size >= 3 then
|
||||
table.insert(offsets, { 0, -1 })
|
||||
table.insert(offsets, { 0, 1 })
|
||||
end
|
||||
|
||||
for _, offset in ipairs(offsets) do
|
||||
local ox, oy = x + offset[1], y + offset[2]
|
||||
if ox >= 1 and ox <= canvas.width and oy >= 1 and oy <= canvas.height then
|
||||
canvas.buffer[oy][ox] = VISUAL.NODE_CHAR_SMALL
|
||||
table.insert(canvas.highlights, {
|
||||
group = highlight,
|
||||
line = oy - 1,
|
||||
col_start = ox - 1,
|
||||
col_end = ox,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Draws a label for a node
|
||||
---@param canvas GraphCanvas The canvas
|
||||
---@param node GraphNode The node
|
||||
---@param view GraphViewState The view state
|
||||
local function draw_label(canvas, node, view)
|
||||
if not node.visible or not view.show_labels then
|
||||
return
|
||||
end
|
||||
|
||||
local x = math.floor(node.x + 0.5)
|
||||
local y = math.floor(node.y + 0.5)
|
||||
|
||||
-- Only show labels for selected node or high-degree nodes
|
||||
local show_this_label = node.id == view.selected_node
|
||||
or node.id == view.hovered_node
|
||||
or node.degree > THRESHOLDS.MEDIUM
|
||||
|
||||
if not show_this_label then
|
||||
return
|
||||
end
|
||||
|
||||
-- Truncate label if too long
|
||||
local label = node.name
|
||||
if #label > VISUAL.LABEL_MAX_LENGTH then
|
||||
label = label:sub(1, VISUAL.LABEL_MAX_LENGTH - 3) .. "..."
|
||||
end
|
||||
|
||||
-- Position label to the right of the node
|
||||
local label_x = x + 2
|
||||
local label_y = y
|
||||
|
||||
-- Adjust if label would go off canvas
|
||||
if label_x + #label > canvas.width then
|
||||
label_x = x - #label - 1
|
||||
end
|
||||
|
||||
-- Draw label characters
|
||||
if label_y >= 1 and label_y <= canvas.height then
|
||||
for i = 1, #label do
|
||||
local char_x = label_x + i - 1
|
||||
if char_x >= 1 and char_x <= canvas.width then
|
||||
local current = canvas.buffer[label_y][char_x]
|
||||
-- Only draw if space is empty or has edge
|
||||
if current == " " or current == VISUAL.EDGE_CHAR_SIMPLE then
|
||||
canvas.buffer[label_y][char_x] = label:sub(i, i)
|
||||
|
||||
table.insert(canvas.highlights, {
|
||||
group = COLORS.LABEL,
|
||||
line = label_y - 1,
|
||||
col_start = char_x - 1,
|
||||
col_end = char_x,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Renders the graph to a canvas
|
||||
---@param graph GraphData The graph data
|
||||
---@param view GraphViewState The view state
|
||||
---@param width number Canvas width
|
||||
---@param height number Canvas height
|
||||
---@return GraphCanvas The rendered canvas
|
||||
function M.render(graph, view, width, height)
|
||||
local canvas = types.create_canvas(width, height)
|
||||
|
||||
-- Apply zoom and offset transformations
|
||||
local function transform_x(x)
|
||||
return (x - width / 2) * view.zoom + width / 2 + view.offset_x
|
||||
end
|
||||
|
||||
local function transform_y(y)
|
||||
return (y - height / 2) * view.zoom + height / 2 + view.offset_y
|
||||
end
|
||||
|
||||
-- First pass: draw edges (so they appear behind nodes)
|
||||
for _, edge in ipairs(graph.edges) do
|
||||
if edge.visible then
|
||||
local source = graph.nodes[edge.source]
|
||||
local target = graph.nodes[edge.target]
|
||||
|
||||
if source and target and source.visible and target.visible then
|
||||
local x1 = transform_x(source.x)
|
||||
local y1 = transform_y(source.y)
|
||||
local x2 = transform_x(target.x)
|
||||
local y2 = transform_y(target.y)
|
||||
|
||||
draw_line(canvas, x1, y1, x2, y2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Second pass: draw nodes
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
if node.visible then
|
||||
-- Create a temporary node with transformed coordinates
|
||||
local transformed_node = {
|
||||
id = node.id,
|
||||
name = node.name,
|
||||
degree = node.degree,
|
||||
visible = node.visible,
|
||||
selected = node.selected,
|
||||
x = transform_x(node.x),
|
||||
y = transform_y(node.y),
|
||||
}
|
||||
draw_node(canvas, transformed_node, view)
|
||||
end
|
||||
end
|
||||
|
||||
-- Third pass: draw labels
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
if node.visible then
|
||||
local transformed_node = {
|
||||
id = node.id,
|
||||
name = node.name,
|
||||
degree = node.degree,
|
||||
visible = node.visible,
|
||||
x = transform_x(node.x),
|
||||
y = transform_y(node.y),
|
||||
}
|
||||
draw_label(canvas, transformed_node, view)
|
||||
end
|
||||
end
|
||||
|
||||
return canvas
|
||||
end
|
||||
|
||||
---Converts a canvas to an array of strings for buffer display
|
||||
---@param canvas GraphCanvas The canvas
|
||||
---@return string[] Array of lines
|
||||
function M.canvas_to_lines(canvas)
|
||||
local lines = {}
|
||||
|
||||
for y = 1, canvas.height do
|
||||
local line = table.concat(canvas.buffer[y], "")
|
||||
table.insert(lines, line)
|
||||
end
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
---Applies highlights to a buffer
|
||||
---@param buf number Buffer handle
|
||||
---@param canvas GraphCanvas The canvas with highlights
|
||||
---@param ns_id number Namespace ID for highlights
|
||||
function M.apply_highlights(buf, canvas, ns_id)
|
||||
-- Clear existing highlights
|
||||
vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1)
|
||||
|
||||
-- Apply new highlights
|
||||
for _, hl in ipairs(canvas.highlights) do
|
||||
pcall(vim.api.nvim_buf_add_highlight, buf, ns_id, hl.group, hl.line, hl.col_start, hl.col_end)
|
||||
end
|
||||
end
|
||||
|
||||
---Creates the highlight groups for the graph
|
||||
function M.setup_highlights()
|
||||
-- Node colors
|
||||
vim.api.nvim_set_hl(0, COLORS.NODE_DEFAULT, { fg = "#7aa2f7", bold = true })
|
||||
vim.api.nvim_set_hl(0, COLORS.NODE_SELECTED, { fg = "#f7768e", bold = true, underline = true })
|
||||
vim.api.nvim_set_hl(0, COLORS.NODE_ORPHAN, { fg = "#565f89" })
|
||||
vim.api.nvim_set_hl(0, COLORS.NODE_HIGH_DEGREE, { fg = "#bb9af7", bold = true })
|
||||
|
||||
-- Edge color (semi-transparent effect via dimmed color)
|
||||
vim.api.nvim_set_hl(0, COLORS.EDGE, { fg = "#3b4261" })
|
||||
|
||||
-- Label color
|
||||
vim.api.nvim_set_hl(0, COLORS.LABEL, { fg = "#9ece6a", italic = true })
|
||||
|
||||
-- Background (for the window)
|
||||
vim.api.nvim_set_hl(0, COLORS.BACKGROUND, { bg = "#1a1b26" })
|
||||
|
||||
-- Filter indicator
|
||||
vim.api.nvim_set_hl(0, COLORS.FILTER_ACTIVE, { fg = "#e0af68", bold = true })
|
||||
end
|
||||
|
||||
---Generates a status line string showing graph stats and controls
|
||||
---@param graph GraphData The graph data
|
||||
---@param view GraphViewState The view state
|
||||
---@return string Status line
|
||||
function M.get_status_line(graph, view)
|
||||
local visible_nodes = 0
|
||||
local visible_edges = 0
|
||||
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
if node.visible then
|
||||
visible_nodes = visible_nodes + 1
|
||||
end
|
||||
end
|
||||
|
||||
for _, edge in ipairs(graph.edges) do
|
||||
if edge.visible then
|
||||
visible_edges = visible_edges + 1
|
||||
end
|
||||
end
|
||||
|
||||
local status = string.format(" Nodes: %d | Edges: %d", visible_nodes, visible_edges)
|
||||
|
||||
if view.filter.active then
|
||||
status = status .. string.format(" | Filter: %s=%s", view.filter.type, view.filter.value)
|
||||
end
|
||||
|
||||
if view.selected_node then
|
||||
local node = graph.nodes[view.selected_node]
|
||||
if node then
|
||||
status = status .. string.format(" | Selected: %s (deg: %d)", node.name, node.degree)
|
||||
end
|
||||
end
|
||||
|
||||
status = status .. " | [q]uit [t]ag [f]older [r]eset [l]abels [c]enter [Enter]open"
|
||||
|
||||
return status
|
||||
end
|
||||
|
||||
---Renders help overlay
|
||||
---@param width number Canvas width
|
||||
---@param height number Canvas height
|
||||
---@return string[] Help lines
|
||||
function M.render_help()
|
||||
return {
|
||||
"╭──────────────────────────────────────╮",
|
||||
"│ 🕸️ Graph View Help │",
|
||||
"├──────────────────────────────────────┤",
|
||||
"│ Navigation: │",
|
||||
"│ h/j/k/l - Move selection │",
|
||||
"│ Enter - Open selected note │",
|
||||
"│ c - Center graph │",
|
||||
"│ +/- - Zoom in/out │",
|
||||
"│ │",
|
||||
"│ Filtering: │",
|
||||
"│ t - Filter by tag │",
|
||||
"│ f - Filter by folder │",
|
||||
"│ r - Reset filter │",
|
||||
"│ │",
|
||||
"│ Display: │",
|
||||
"│ l - Toggle labels │",
|
||||
"│ ? - Toggle this help │",
|
||||
"│ q - Close graph view │",
|
||||
"│ │",
|
||||
"│ Legend: │",
|
||||
"│ ● Large - High connectivity │",
|
||||
"│ • Small - Low connectivity │",
|
||||
"│ · Dots - Edge connections │",
|
||||
"╰──────────────────────────────────────╯",
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
163
lua/ideaDrop/ui/graph/types.lua
Normal file
163
lua/ideaDrop/ui/graph/types.lua
Normal file
@@ -0,0 +1,163 @@
|
||||
-- ideaDrop/ui/graph/types.lua
|
||||
-- Type definitions for the graph visualization system
|
||||
|
||||
---@class GraphNode
|
||||
---@field id string Unique identifier (file path without extension)
|
||||
---@field name string Display name
|
||||
---@field file_path string Full file path
|
||||
---@field folder string Parent folder name
|
||||
---@field tags string[] Tags from the file
|
||||
---@field degree number Number of connections
|
||||
---@field x number X position in layout
|
||||
---@field y number Y position in layout
|
||||
---@field vx number X velocity
|
||||
---@field vy number Y velocity
|
||||
---@field fx number|nil Fixed X position (for pinning)
|
||||
---@field fy number|nil Fixed Y position (for pinning)
|
||||
---@field visible boolean Whether node is visible (filtering)
|
||||
---@field selected boolean Whether node is selected
|
||||
|
||||
---@class GraphEdge
|
||||
---@field source string Source node ID
|
||||
---@field target string Target node ID
|
||||
---@field visible boolean Whether edge is visible (filtering)
|
||||
|
||||
---@class GraphData
|
||||
---@field nodes table<string, GraphNode> Map of node ID to node
|
||||
---@field edges GraphEdge[] Array of edges
|
||||
---@field node_list GraphNode[] Array of nodes for iteration
|
||||
|
||||
---@class GraphLayoutState
|
||||
---@field temperature number Current temperature for simulated annealing
|
||||
---@field iteration number Current iteration
|
||||
---@field converged boolean Whether layout has converged
|
||||
---@field running boolean Whether simulation is running
|
||||
---@field timer any Timer handle for animation
|
||||
|
||||
---@class GraphFilter
|
||||
---@field type string|nil Filter type: "tag", "folder", "search", or nil
|
||||
---@field value string|nil Filter value
|
||||
---@field active boolean Whether filter is active
|
||||
|
||||
---@class GraphViewState
|
||||
---@field zoom number Zoom level (1.0 = default)
|
||||
---@field offset_x number X offset for panning
|
||||
---@field offset_y number Y offset for panning
|
||||
---@field selected_node string|nil Currently selected node ID
|
||||
---@field hovered_node string|nil Currently hovered node ID
|
||||
---@field show_labels boolean Whether to show node labels
|
||||
---@field filter GraphFilter Current filter state
|
||||
|
||||
---@class GraphCanvas
|
||||
---@field width number Canvas width in characters
|
||||
---@field height number Canvas height in characters
|
||||
---@field buffer string[][] 2D buffer of characters
|
||||
---@field highlights table[] Array of highlight regions
|
||||
|
||||
---@class GraphConfig
|
||||
---@field node_colors table<string, string> Map of folder/tag to color
|
||||
---@field default_node_color string Default node color
|
||||
---@field show_orphans boolean Whether to show orphan nodes
|
||||
---@field animate boolean Whether to animate layout
|
||||
---@field animation_speed number Animation speed (ms per frame)
|
||||
|
||||
local M = {}
|
||||
|
||||
---Creates a new GraphNode
|
||||
---@param id string Node ID
|
||||
---@param name string Display name
|
||||
---@param file_path string Full file path
|
||||
---@return GraphNode
|
||||
function M.create_node(id, name, file_path)
|
||||
return {
|
||||
id = id,
|
||||
name = name,
|
||||
file_path = file_path,
|
||||
folder = vim.fn.fnamemodify(file_path, ":h:t"),
|
||||
tags = {},
|
||||
degree = 0,
|
||||
x = 0,
|
||||
y = 0,
|
||||
vx = 0,
|
||||
vy = 0,
|
||||
fx = nil,
|
||||
fy = nil,
|
||||
visible = true,
|
||||
selected = false,
|
||||
}
|
||||
end
|
||||
|
||||
---Creates a new GraphEdge
|
||||
---@param source string Source node ID
|
||||
---@param target string Target node ID
|
||||
---@return GraphEdge
|
||||
function M.create_edge(source, target)
|
||||
return {
|
||||
source = source,
|
||||
target = target,
|
||||
visible = true,
|
||||
}
|
||||
end
|
||||
|
||||
---Creates empty GraphData
|
||||
---@return GraphData
|
||||
function M.create_graph_data()
|
||||
return {
|
||||
nodes = {},
|
||||
edges = {},
|
||||
node_list = {},
|
||||
}
|
||||
end
|
||||
|
||||
---Creates initial GraphLayoutState
|
||||
---@param initial_temperature number
|
||||
---@return GraphLayoutState
|
||||
function M.create_layout_state(initial_temperature)
|
||||
return {
|
||||
temperature = initial_temperature,
|
||||
iteration = 0,
|
||||
converged = false,
|
||||
running = false,
|
||||
timer = nil,
|
||||
}
|
||||
end
|
||||
|
||||
---Creates initial GraphViewState
|
||||
---@return GraphViewState
|
||||
function M.create_view_state()
|
||||
return {
|
||||
zoom = 1.0,
|
||||
offset_x = 0,
|
||||
offset_y = 0,
|
||||
selected_node = nil,
|
||||
hovered_node = nil,
|
||||
show_labels = true,
|
||||
filter = {
|
||||
type = nil,
|
||||
value = nil,
|
||||
active = false,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
---Creates empty GraphCanvas
|
||||
---@param width number
|
||||
---@param height number
|
||||
---@return GraphCanvas
|
||||
function M.create_canvas(width, height)
|
||||
local buffer = {}
|
||||
for y = 1, height do
|
||||
buffer[y] = {}
|
||||
for x = 1, width do
|
||||
buffer[y][x] = " "
|
||||
end
|
||||
end
|
||||
return {
|
||||
width = width,
|
||||
height = height,
|
||||
buffer = buffer,
|
||||
highlights = {},
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -115,12 +115,15 @@ M.ICONS = {
|
||||
SEARCH = "🔍",
|
||||
TAG = "🏷️",
|
||||
TREE = "🌳",
|
||||
GRAPH = "🕸️",
|
||||
SUCCESS = "✅",
|
||||
ERROR = "❌",
|
||||
WARNING = "⚠️",
|
||||
INFO = "ℹ️",
|
||||
SAVE = "💾",
|
||||
REFRESH = "🔄",
|
||||
NODE = "●",
|
||||
LINK = "─",
|
||||
}
|
||||
|
||||
-- Key mappings (default)
|
||||
@@ -129,6 +132,78 @@ M.DEFAULT_KEYMAPS = {
|
||||
REFRESH_FILE = "<C-r>",
|
||||
CLOSE_WINDOW = "q",
|
||||
SELECT_ITEM = "<CR>",
|
||||
-- Graph keymaps
|
||||
GRAPH_CLOSE = "q",
|
||||
GRAPH_SELECT = "<CR>",
|
||||
GRAPH_FILTER_TAG = "t",
|
||||
GRAPH_FILTER_FOLDER = "f",
|
||||
GRAPH_RESET_FILTER = "r",
|
||||
GRAPH_TOGGLE_LABELS = "l",
|
||||
GRAPH_CENTER = "c",
|
||||
GRAPH_ZOOM_IN = "+",
|
||||
GRAPH_ZOOM_OUT = "-",
|
||||
}
|
||||
|
||||
-- Graph visualization settings
|
||||
M.GRAPH_SETTINGS = {
|
||||
-- Layout algorithm parameters
|
||||
LAYOUT = {
|
||||
-- Fruchterman-Reingold parameters
|
||||
REPULSION_STRENGTH = 5000, -- How strongly nodes repel each other
|
||||
ATTRACTION_STRENGTH = 0.01, -- Spring constant for connected nodes
|
||||
IDEAL_EDGE_LENGTH = 50, -- Ideal distance between connected nodes
|
||||
GRAVITY = 0.1, -- Pull toward center
|
||||
DAMPING = 0.85, -- Velocity damping per iteration
|
||||
MIN_VELOCITY = 0.01, -- Stop threshold
|
||||
MAX_ITERATIONS = 300, -- Maximum layout iterations
|
||||
COOLING_RATE = 0.95, -- Temperature cooling per iteration
|
||||
INITIAL_TEMPERATURE = 100, -- Initial movement freedom
|
||||
},
|
||||
|
||||
-- Visual settings
|
||||
VISUAL = {
|
||||
NODE_CHAR = "●", -- Character for nodes
|
||||
NODE_CHAR_SMALL = "•", -- Character for small nodes
|
||||
EDGE_CHAR_H = "─", -- Horizontal edge
|
||||
EDGE_CHAR_V = "│", -- Vertical edge
|
||||
EDGE_CHAR_DR = "┌", -- Down-right corner
|
||||
EDGE_CHAR_DL = "┐", -- Down-left corner
|
||||
EDGE_CHAR_UR = "└", -- Up-right corner
|
||||
EDGE_CHAR_UL = "┘", -- Up-left corner
|
||||
EDGE_CHAR_CROSS = "┼", -- Crossing edges
|
||||
EDGE_CHAR_SIMPLE = "·", -- Simple edge dot
|
||||
MIN_NODE_SIZE = 1, -- Minimum node visual size
|
||||
MAX_NODE_SIZE = 3, -- Maximum node visual size (based on degree)
|
||||
LABEL_MAX_LENGTH = 20, -- Maximum label length
|
||||
PADDING = 2, -- Canvas padding
|
||||
},
|
||||
|
||||
-- Window settings
|
||||
WINDOW = {
|
||||
WIDTH_RATIO = 0.8, -- Window width as ratio of editor
|
||||
HEIGHT_RATIO = 0.8, -- Window height as ratio of editor
|
||||
BORDER = "rounded",
|
||||
TITLE = " 🕸️ Graph View ",
|
||||
},
|
||||
|
||||
-- Colors (highlight group names)
|
||||
COLORS = {
|
||||
NODE_DEFAULT = "IdeaDropGraphNode",
|
||||
NODE_SELECTED = "IdeaDropGraphNodeSelected",
|
||||
NODE_ORPHAN = "IdeaDropGraphNodeOrphan",
|
||||
NODE_HIGH_DEGREE = "IdeaDropGraphNodeHighDegree",
|
||||
EDGE = "IdeaDropGraphEdge",
|
||||
LABEL = "IdeaDropGraphLabel",
|
||||
BACKGROUND = "IdeaDropGraphBackground",
|
||||
FILTER_ACTIVE = "IdeaDropGraphFilterActive",
|
||||
},
|
||||
|
||||
-- Node size thresholds (degree-based)
|
||||
NODE_DEGREE_THRESHOLDS = {
|
||||
SMALL = 2, -- 0-2 connections = small
|
||||
MEDIUM = 5, -- 3-5 connections = medium
|
||||
-- > 5 = large
|
||||
},
|
||||
}
|
||||
|
||||
-- Notification messages
|
||||
@@ -146,6 +221,12 @@ M.MESSAGES = {
|
||||
NO_ACTIVE_FILE = "❌ No active idea file. Open an idea first.",
|
||||
PROVIDE_TAG = "❌ Please provide a tag name",
|
||||
PROVIDE_QUERY = "❌ Please provide a search query",
|
||||
GRAPH_BUILDING = "🕸️ Building graph...",
|
||||
GRAPH_LAYOUT = "🕸️ Computing layout for %d nodes...",
|
||||
GRAPH_COMPLETE = "🕸️ Graph ready: %d nodes, %d edges",
|
||||
GRAPH_REFRESHED = "🕸️ Graph refreshed",
|
||||
GRAPH_NO_NODES = "🕸️ No notes found to visualize",
|
||||
GRAPH_NO_SELECTION = "🕸️ No node selected",
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -9,9 +9,12 @@ function M.setup()
|
||||
|
||||
-- Example: Quick access to ideaDrop commands
|
||||
-- vim.keymap.set("n", "<leader>id", ":IdeaRight<CR>", { desc = "Open today's idea" })
|
||||
-- vim.keymap.set("n", "<leader>in", ":IdeaRight ", { desc = "Open named idea" })
|
||||
-- vim.keymap.set("n", "<leader>it", ":IdeaTree<CR>", { desc = "Open idea tree" })
|
||||
-- vim.keymap.set("n", "<leader>is", ":IdeaSearch ", { desc = "Search ideas" })
|
||||
-- vim.keymap.set("n", "<leader>it", ":IdeaTags<CR>", { desc = "Browse tags" })
|
||||
-- vim.keymap.set("n", "<leader>ig", ":IdeaTags<CR>", { desc = "Browse tags" })
|
||||
-- vim.keymap.set("n", "<leader>if", ":Idea<CR>", { desc = "Open today's idea in float" })
|
||||
-- vim.keymap.set("n", "<leader>iG", ":IdeaGraph<CR>", { desc = "Open graph visualization" })
|
||||
|
||||
-- Note: Keymaps are commented out by default to avoid conflicts
|
||||
-- Users can uncomment and customize these in their config
|
||||
|
||||
Reference in New Issue
Block a user