From 937f20b8929d7d0ade5923d27f2d370406dd7801 Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Sat, 10 Jan 2026 23:02:40 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 80 +++- README.md | 91 +++++ llms.txt | 61 ++- lua/ideaDrop/core/config.lua | 17 +- lua/ideaDrop/core/init.lua | 70 ++++ lua/ideaDrop/ui/graph/data.lua | 339 +++++++++++++++++ lua/ideaDrop/ui/graph/init.lua | 574 +++++++++++++++++++++++++++++ lua/ideaDrop/ui/graph/layout.lua | 370 +++++++++++++++++++ lua/ideaDrop/ui/graph/renderer.lua | 424 +++++++++++++++++++++ lua/ideaDrop/ui/graph/types.lua | 163 ++++++++ lua/ideaDrop/utils/constants.lua | 81 ++++ lua/ideaDrop/utils/keymaps.lua | 5 +- 12 files changed, 2261 insertions(+), 14 deletions(-) create mode 100644 lua/ideaDrop/ui/graph/data.lua create mode 100644 lua/ideaDrop/ui/graph/init.lua create mode 100644 lua/ideaDrop/ui/graph/layout.lua create mode 100644 lua/ideaDrop/ui/graph/renderer.lua create mode 100644 lua/ideaDrop/ui/graph/types.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f555fc..2a4ffc6 100644 --- a/CHANGELOG.md +++ b/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 ` - Filter graph by tag + - `:IdeaGraphFilter folder ` - 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 diff --git a/README.md b/README.md index 134ce2d..eb61abe 100644 --- a/README.md +++ b/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: | `is` | `:IdeaSearch ` | Search ideas | | `ig` | `:IdeaTags` | Browse tags | | `if` | `:Idea` | Open today's idea in float | +| `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 diff --git a/llms.txt b/llms.txt index b10a68d..ce1ebaa 100644 --- a/llms.txt +++ b/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 diff --git a/lua/ideaDrop/core/config.lua b/lua/ideaDrop/core/config.lua index 38c348c..e59a658 100644 --- a/lua/ideaDrop/core/config.lua +++ b/lua/ideaDrop/core/config.lua @@ -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|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 diff --git a/lua/ideaDrop/core/init.lua b/lua/ideaDrop/core/init.lua index f5f033a..a560c22 100644 --- a/lua/ideaDrop/core/init.lua +++ b/lua/ideaDrop/core/init.lua @@ -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 ", 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() diff --git a/lua/ideaDrop/ui/graph/data.lua b/lua/ideaDrop/ui/graph/data.lua new file mode 100644 index 0000000..1247d59 --- /dev/null +++ b/lua/ideaDrop/ui/graph/data.lua @@ -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 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 diff --git a/lua/ideaDrop/ui/graph/init.lua b/lua/ideaDrop/ui/graph/init.lua new file mode 100644 index 0000000..0ea56f1 --- /dev/null +++ b/lua/ideaDrop/ui/graph/init.lua @@ -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", "", 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", "", 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 diff --git a/lua/ideaDrop/ui/graph/layout.lua b/lua/ideaDrop/ui/graph/layout.lua new file mode 100644 index 0000000..068d5fc --- /dev/null +++ b/lua/ideaDrop/ui/graph/layout.lua @@ -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 diff --git a/lua/ideaDrop/ui/graph/renderer.lua b/lua/ideaDrop/ui/graph/renderer.lua new file mode 100644 index 0000000..e311094 --- /dev/null +++ b/lua/ideaDrop/ui/graph/renderer.lua @@ -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 diff --git a/lua/ideaDrop/ui/graph/types.lua b/lua/ideaDrop/ui/graph/types.lua new file mode 100644 index 0000000..1820415 --- /dev/null +++ b/lua/ideaDrop/ui/graph/types.lua @@ -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 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 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 diff --git a/lua/ideaDrop/utils/constants.lua b/lua/ideaDrop/utils/constants.lua index efa61d0..7e09822 100644 --- a/lua/ideaDrop/utils/constants.lua +++ b/lua/ideaDrop/utils/constants.lua @@ -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 = "", CLOSE_WINDOW = "q", SELECT_ITEM = "", + -- Graph keymaps + GRAPH_CLOSE = "q", + GRAPH_SELECT = "", + 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 \ No newline at end of file diff --git a/lua/ideaDrop/utils/keymaps.lua b/lua/ideaDrop/utils/keymaps.lua index 5f82e23..824c1aa 100644 --- a/lua/ideaDrop/utils/keymaps.lua +++ b/lua/ideaDrop/utils/keymaps.lua @@ -9,9 +9,12 @@ function M.setup() -- Example: Quick access to ideaDrop commands -- vim.keymap.set("n", "id", ":IdeaRight", { desc = "Open today's idea" }) + -- vim.keymap.set("n", "in", ":IdeaRight ", { desc = "Open named idea" }) -- vim.keymap.set("n", "it", ":IdeaTree", { desc = "Open idea tree" }) -- vim.keymap.set("n", "is", ":IdeaSearch ", { desc = "Search ideas" }) - -- vim.keymap.set("n", "it", ":IdeaTags", { desc = "Browse tags" }) + -- vim.keymap.set("n", "ig", ":IdeaTags", { desc = "Browse tags" }) + -- vim.keymap.set("n", "if", ":Idea", { desc = "Open today's idea in float" }) + -- vim.keymap.set("n", "iG", ":IdeaGraph", { desc = "Open graph visualization" }) -- Note: Keymaps are commented out by default to avoid conflicts -- Users can uncomment and customize these in their config