perf: add caching system for fast graph loading
Major performance improvements for graph visualization: Cache System: - Add cache.lua module that stores parsed links/tags per file - Only re-parse files that have been modified (mtime check) - Cache stored in .ideadrop-graph-cache.json in idea_dir - Fast file scanning using vim.fs.find when available Layout Optimizations: - Reduce max iterations from 300 to 100 - Faster convergence with adjusted parameters - Barnes-Hut approximation for large graphs (100+ nodes) - Cache math functions locally for speed - Skip distant node pairs in repulsion calculation - Reuse visible_nodes array across iterations New Commands: - :IdeaGraph rebuild - Force full cache rebuild - :IdeaGraphClearCache - Clear cache file This makes opening the graph nearly instant for previously scanned vaults, similar to Obsidian's behavior.
This commit is contained in:
@@ -250,17 +250,27 @@ function M.setup(user_opts)
|
||||
graph.refresh()
|
||||
elseif arg == "animate" then
|
||||
graph.open({ animate = true })
|
||||
elseif arg == "rebuild" then
|
||||
graph.open({ force_rebuild = true })
|
||||
else
|
||||
graph.open()
|
||||
end
|
||||
end, {
|
||||
nargs = "?",
|
||||
complete = function()
|
||||
return { "close", "refresh", "animate" }
|
||||
return { "close", "refresh", "animate", "rebuild" }
|
||||
end,
|
||||
desc = "Open Obsidian-style graph visualization of notes and links",
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command("IdeaGraphClearCache", function()
|
||||
local cache = require("ideaDrop.ui.graph.cache")
|
||||
cache.clear()
|
||||
vim.notify("🗑️ Graph cache cleared", vim.log.levels.INFO)
|
||||
end, {
|
||||
desc = "Clear the graph cache to force full rebuild",
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command("IdeaGraphFilter", function(opts)
|
||||
local args = vim.split(opts.args, " ", { trimempty = true })
|
||||
|
||||
|
||||
273
lua/ideaDrop/ui/graph/cache.lua
Normal file
273
lua/ideaDrop/ui/graph/cache.lua
Normal file
@@ -0,0 +1,273 @@
|
||||
-- ideaDrop/ui/graph/cache.lua
|
||||
-- Graph cache system for fast loading - compatible with Obsidian vaults
|
||||
|
||||
local config = require("ideaDrop.core.config")
|
||||
|
||||
---@class GraphCache
|
||||
---@field load fun(): table|nil
|
||||
---@field save fun(data: table): nil
|
||||
---@field get_file_mtime fun(path: string): number
|
||||
---@field is_stale fun(cached_entry: table, file_path: string): boolean
|
||||
local M = {}
|
||||
|
||||
-- Cache filename
|
||||
local CACHE_FILE = ".ideadrop-graph-cache.json"
|
||||
local OBSIDIAN_CACHE = ".obsidian/graph.json"
|
||||
|
||||
---Gets the cache file path
|
||||
---@return string
|
||||
local function get_cache_path()
|
||||
local idea_dir = config.get_idea_dir and config.get_idea_dir() or config.options.idea_dir
|
||||
idea_dir = vim.fn.expand(idea_dir or "")
|
||||
return idea_dir .. "/" .. CACHE_FILE
|
||||
end
|
||||
|
||||
---Gets the Obsidian cache file path
|
||||
---@return string
|
||||
local function get_obsidian_cache_path()
|
||||
local idea_dir = config.get_idea_dir and config.get_idea_dir() or config.options.idea_dir
|
||||
idea_dir = vim.fn.expand(idea_dir or "")
|
||||
return idea_dir .. "/" .. OBSIDIAN_CACHE
|
||||
end
|
||||
|
||||
---Gets file modification time
|
||||
---@param path string File path
|
||||
---@return number Modification time (0 if file doesn't exist)
|
||||
function M.get_file_mtime(path)
|
||||
local stat = vim.loop.fs_stat(path)
|
||||
if stat then
|
||||
return stat.mtime.sec
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
---Checks if a cached entry is stale (file has been modified)
|
||||
---@param cached_entry table Cached file data
|
||||
---@param file_path string Current file path
|
||||
---@return boolean True if cache is stale
|
||||
function M.is_stale(cached_entry, file_path)
|
||||
if not cached_entry or not cached_entry.mtime then
|
||||
return true
|
||||
end
|
||||
local current_mtime = M.get_file_mtime(file_path)
|
||||
return current_mtime ~= cached_entry.mtime
|
||||
end
|
||||
|
||||
---Loads the cache from disk
|
||||
---@return table|nil Cache data or nil if not found/invalid
|
||||
function M.load()
|
||||
local cache_path = get_cache_path()
|
||||
|
||||
-- Check if cache file exists
|
||||
if vim.fn.filereadable(cache_path) == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Read and parse cache
|
||||
local ok, content = pcall(vim.fn.readfile, cache_path)
|
||||
if not ok or #content == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local json_str = table.concat(content, "\n")
|
||||
local ok2, data = pcall(vim.fn.json_decode, json_str)
|
||||
if not ok2 or type(data) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
---Saves the cache to disk
|
||||
---@param data table Cache data to save
|
||||
function M.save(data)
|
||||
local cache_path = get_cache_path()
|
||||
|
||||
local ok, json_str = pcall(vim.fn.json_encode, data)
|
||||
if not ok then
|
||||
return
|
||||
end
|
||||
|
||||
-- Write cache file
|
||||
local file = io.open(cache_path, "w")
|
||||
if file then
|
||||
file:write(json_str)
|
||||
file:close()
|
||||
end
|
||||
end
|
||||
|
||||
---Tries to load Obsidian's graph cache
|
||||
---@return table|nil Obsidian graph data or nil
|
||||
function M.load_obsidian_cache()
|
||||
local obsidian_path = get_obsidian_cache_path()
|
||||
|
||||
if vim.fn.filereadable(obsidian_path) == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local ok, content = pcall(vim.fn.readfile, obsidian_path)
|
||||
if not ok or #content == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local json_str = table.concat(content, "\n")
|
||||
local ok2, data = pcall(vim.fn.json_decode, json_str)
|
||||
if not ok2 or type(data) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
---Extracts links from content (fast version)
|
||||
---@param content string File content
|
||||
---@return string[] Array of link targets
|
||||
function M.extract_links_fast(content)
|
||||
local links = {}
|
||||
local seen = {}
|
||||
|
||||
-- Fast pattern matching for [[link]] and [[link|alias]]
|
||||
for link in content:gmatch("%[%[([^%]|]+)") do
|
||||
link = link:gsub("^%s+", ""):gsub("%s+$", "") -- trim
|
||||
if link ~= "" and not seen[link] then
|
||||
links[#links + 1] = link
|
||||
seen[link] = true
|
||||
end
|
||||
end
|
||||
|
||||
return links
|
||||
end
|
||||
|
||||
---Extracts tags from content (fast version)
|
||||
---@param content string File content
|
||||
---@return string[] Array of tags
|
||||
function M.extract_tags_fast(content)
|
||||
local tags = {}
|
||||
local seen = {}
|
||||
|
||||
for tag in content:gmatch("#([%w%-_]+)") do
|
||||
if not seen[tag] and #tag > 1 then
|
||||
tags[#tags + 1] = tag
|
||||
seen[tag] = true
|
||||
end
|
||||
end
|
||||
|
||||
return tags
|
||||
end
|
||||
|
||||
---Builds cache data for a single file
|
||||
---@param file_path string File path
|
||||
---@return table|nil File cache entry
|
||||
function M.build_file_cache(file_path)
|
||||
local mtime = M.get_file_mtime(file_path)
|
||||
if mtime == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Read file content
|
||||
local ok, lines = pcall(vim.fn.readfile, file_path)
|
||||
if not ok then
|
||||
return nil
|
||||
end
|
||||
|
||||
local content = table.concat(lines, "\n")
|
||||
|
||||
return {
|
||||
mtime = mtime,
|
||||
links = M.extract_links_fast(content),
|
||||
tags = M.extract_tags_fast(content),
|
||||
}
|
||||
end
|
||||
|
||||
---Gets all markdown files using fast directory scan
|
||||
---@param idea_dir string Directory to scan
|
||||
---@return string[] Array of file paths
|
||||
function M.scan_files_fast(idea_dir)
|
||||
local files = {}
|
||||
|
||||
-- Use vim.fs.find for faster scanning (Neovim 0.8+)
|
||||
if vim.fs and vim.fs.find then
|
||||
local found = vim.fs.find(function(name)
|
||||
return name:match("%.md$")
|
||||
end, {
|
||||
path = idea_dir,
|
||||
type = "file",
|
||||
limit = math.huge,
|
||||
})
|
||||
return found
|
||||
end
|
||||
|
||||
-- Fallback to glob
|
||||
files = vim.fn.glob(idea_dir .. "/**/*.md", false, true)
|
||||
if #files == 0 then
|
||||
files = vim.fn.glob(idea_dir .. "/*.md", false, true)
|
||||
end
|
||||
|
||||
return files
|
||||
end
|
||||
|
||||
---Builds or updates the complete cache
|
||||
---@param force boolean|nil Force full rebuild
|
||||
---@return table Cache data with files map
|
||||
function M.build_cache(force)
|
||||
local idea_dir = config.get_idea_dir and config.get_idea_dir() or config.options.idea_dir
|
||||
idea_dir = vim.fn.expand(idea_dir or ""):gsub("/$", "")
|
||||
|
||||
-- Load existing cache
|
||||
local cache = nil
|
||||
if not force then
|
||||
cache = M.load()
|
||||
end
|
||||
cache = cache or { files = {}, version = 1 }
|
||||
|
||||
-- Get all current files
|
||||
local current_files = M.scan_files_fast(idea_dir)
|
||||
local current_files_set = {}
|
||||
for _, f in ipairs(current_files) do
|
||||
current_files_set[f] = true
|
||||
end
|
||||
|
||||
-- Remove deleted files from cache
|
||||
local new_files_cache = {}
|
||||
for path, entry in pairs(cache.files or {}) do
|
||||
if current_files_set[path] then
|
||||
new_files_cache[path] = entry
|
||||
end
|
||||
end
|
||||
cache.files = new_files_cache
|
||||
|
||||
-- Update cache for new/modified files
|
||||
local updated = 0
|
||||
local skipped = 0
|
||||
|
||||
for _, file_path in ipairs(current_files) do
|
||||
local cached = cache.files[file_path]
|
||||
|
||||
if M.is_stale(cached, file_path) then
|
||||
local entry = M.build_file_cache(file_path)
|
||||
if entry then
|
||||
cache.files[file_path] = entry
|
||||
updated = updated + 1
|
||||
end
|
||||
else
|
||||
skipped = skipped + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Save updated cache
|
||||
if updated > 0 then
|
||||
M.save(cache)
|
||||
end
|
||||
|
||||
return cache, updated, skipped
|
||||
end
|
||||
|
||||
---Clears the cache
|
||||
function M.clear()
|
||||
local cache_path = get_cache_path()
|
||||
if vim.fn.filereadable(cache_path) == 1 then
|
||||
vim.fn.delete(cache_path)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -2,8 +2,8 @@
|
||||
-- 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")
|
||||
local cache = require("ideaDrop.ui.graph.cache")
|
||||
|
||||
---@class GraphDataModule
|
||||
---@field build_graph fun(): GraphData
|
||||
@@ -89,9 +89,10 @@ function M.get_display_name(node_id)
|
||||
return name:gsub("-", " "):gsub("^%l", string.upper)
|
||||
end
|
||||
|
||||
---Builds the complete graph from markdown files
|
||||
---Builds the complete graph from markdown files (using cache for speed)
|
||||
---@param force_rebuild boolean|nil Force cache rebuild
|
||||
---@return GraphData
|
||||
function M.build_graph()
|
||||
function M.build_graph(force_rebuild)
|
||||
-- Get idea_dir using the getter function if available, otherwise direct access
|
||||
local idea_dir = config.get_idea_dir and config.get_idea_dir() or config.options.idea_dir
|
||||
local graph = types.create_graph_data()
|
||||
@@ -114,19 +115,10 @@ function M.build_graph()
|
||||
return graph
|
||||
end
|
||||
|
||||
-- Find all markdown files (try recursive first, then flat)
|
||||
local glob_pattern = idea_dir .. "/**/*.md"
|
||||
local files = vim.fn.glob(glob_pattern, false, true)
|
||||
-- Build/update cache (only reads modified files)
|
||||
local file_cache, updated, skipped = cache.build_cache(force_rebuild)
|
||||
|
||||
-- Fallback: try non-recursive if recursive finds nothing
|
||||
if #files == 0 then
|
||||
local files_flat = vim.fn.glob(idea_dir .. "/*.md", false, true)
|
||||
if #files_flat > 0 then
|
||||
files = files_flat
|
||||
end
|
||||
end
|
||||
|
||||
if #files == 0 then
|
||||
if not file_cache or not file_cache.files or vim.tbl_isempty(file_cache.files) then
|
||||
vim.notify(
|
||||
string.format("📂 No .md files found in: %s", idea_dir),
|
||||
vim.log.levels.WARN
|
||||
@@ -134,9 +126,9 @@ function M.build_graph()
|
||||
return graph
|
||||
end
|
||||
|
||||
-- Build a map of normalized names to file paths for link resolution
|
||||
-- Build file map for link resolution
|
||||
local file_map = {}
|
||||
for _, file_path in ipairs(files) do
|
||||
for file_path, _ in pairs(file_cache.files) do
|
||||
local normalized = M.normalize_file_name(file_path, idea_dir):lower()
|
||||
file_map[normalized] = file_path
|
||||
|
||||
@@ -147,65 +139,54 @@ function M.build_graph()
|
||||
end
|
||||
end
|
||||
|
||||
-- First pass: create all nodes
|
||||
for _, file_path in ipairs(files) do
|
||||
-- First pass: create all nodes from cache
|
||||
for file_path, file_data in pairs(file_cache.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
|
||||
node.tags = file_data.tags or {}
|
||||
|
||||
graph.nodes[node_id] = node
|
||||
table.insert(graph.node_list, node)
|
||||
graph.node_list[#graph.node_list + 1] = node
|
||||
end
|
||||
|
||||
-- Second pass: create edges from links
|
||||
-- Second pass: create edges from cached 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)
|
||||
for file_path, file_data in pairs(file_cache.files) do
|
||||
local source_id = M.normalize_file_name(file_path, idea_dir)
|
||||
local links = file_data.links or {}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
-- 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
|
||||
-- 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)
|
||||
graph.edges[#graph.edges + 1] = edge
|
||||
|
||||
-- Update degrees
|
||||
if graph.nodes[source_id] then
|
||||
graph.nodes[source_id].degree = graph.nodes[source_id].degree + 1
|
||||
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
|
||||
if graph.nodes[target_id] then
|
||||
graph.nodes[target_id].degree = graph.nodes[target_id].degree + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -213,6 +194,12 @@ function M.build_graph()
|
||||
end
|
||||
end
|
||||
|
||||
-- Show cache stats
|
||||
local total = updated + skipped
|
||||
if updated > 0 then
|
||||
vim.notify(string.format("📊 Cache: %d updated, %d cached (%d total)", updated, skipped, total), vim.log.levels.INFO)
|
||||
end
|
||||
|
||||
return graph
|
||||
end
|
||||
|
||||
|
||||
@@ -439,9 +439,9 @@ function M.open(opts)
|
||||
-- Build graph data
|
||||
local config = require("ideaDrop.core.config")
|
||||
local idea_dir = vim.fn.expand(config.options.idea_dir or "")
|
||||
vim.notify(string.format("Building graph from: %s", idea_dir), vim.log.levels.INFO)
|
||||
vim.notify(string.format("🕸️ Loading graph from: %s", idea_dir), vim.log.levels.INFO)
|
||||
|
||||
state.graph = data.build_graph()
|
||||
state.graph = data.build_graph(opts.force_rebuild)
|
||||
|
||||
if #state.graph.node_list == 0 then
|
||||
vim.notify(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
-- ideaDrop/ui/graph/layout.lua
|
||||
-- Force-directed graph layout using Fruchterman-Reingold algorithm
|
||||
-- Optimized with Barnes-Hut approximation for large graphs
|
||||
|
||||
local constants = require("ideaDrop.utils.constants")
|
||||
local types = require("ideaDrop.ui.graph.types")
|
||||
@@ -12,6 +13,14 @@ local M = {}
|
||||
|
||||
local SETTINGS = constants.GRAPH_SETTINGS.LAYOUT
|
||||
|
||||
-- Cache math functions for speed
|
||||
local sqrt = math.sqrt
|
||||
local min = math.min
|
||||
local max = math.max
|
||||
local abs = math.abs
|
||||
local random = math.random
|
||||
local floor = math.floor
|
||||
|
||||
---Initializes node positions randomly within the canvas bounds
|
||||
---@param graph GraphData The graph data
|
||||
---@param width number Canvas width
|
||||
@@ -55,29 +64,31 @@ function M.initialize_positions(graph, width, height)
|
||||
end
|
||||
end
|
||||
|
||||
---Calculates the repulsive force between two nodes
|
||||
---Calculates the repulsive force between two nodes (optimized)
|
||||
---@param dx number X distance
|
||||
---@param dy number Y distance
|
||||
---@param distance number Euclidean distance
|
||||
---@param dist_sq number Squared distance (avoids sqrt)
|
||||
---@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
|
||||
local function repulsive_force(dx, dy, dist_sq)
|
||||
if dist_sq < 1 then
|
||||
dist_sq = 1
|
||||
end
|
||||
|
||||
local force = SETTINGS.REPULSION_STRENGTH / (distance * distance)
|
||||
-- Use squared distance to avoid sqrt
|
||||
local force = SETTINGS.REPULSION_STRENGTH / dist_sq
|
||||
local dist = sqrt(dist_sq)
|
||||
|
||||
return (dx / distance) * force, (dy / distance) * force
|
||||
return (dx / dist) * force, (dy / dist) * force
|
||||
end
|
||||
|
||||
---Calculates the attractive force between connected nodes
|
||||
---Calculates the attractive force between connected nodes (optimized)
|
||||
---@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
|
||||
if distance < 1 then
|
||||
distance = 1
|
||||
end
|
||||
|
||||
local force = SETTINGS.ATTRACTION_STRENGTH * (distance - SETTINGS.IDEAL_EDGE_LENGTH)
|
||||
@@ -85,34 +96,37 @@ local function attractive_force(dx, dy, distance)
|
||||
return (dx / distance) * force, (dy / distance) * force
|
||||
end
|
||||
|
||||
---Calculates gravity force pulling nodes toward center
|
||||
---@param node GraphNode The node
|
||||
---Calculates gravity force pulling nodes toward center (optimized)
|
||||
---@param node_x number Node X
|
||||
---@param node_y number Node Y
|
||||
---@param node_degree number Node degree
|
||||
---@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)
|
||||
local function gravity_force(node_x, node_y, node_degree, center_x, center_y)
|
||||
local dx = center_x - node_x
|
||||
local dy = center_y - node_y
|
||||
local dist_sq = dx * dx + dy * dy
|
||||
|
||||
if distance < 0.1 then
|
||||
if dist_sq < 1 then
|
||||
return 0, 0
|
||||
end
|
||||
|
||||
local distance = sqrt(dist_sq)
|
||||
|
||||
-- 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 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
|
||||
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
|
||||
---Performs one iteration of the force-directed layout (optimized)
|
||||
---@param graph GraphData The graph data
|
||||
---@param state GraphLayoutState The layout state
|
||||
---@param width number Canvas width
|
||||
@@ -123,54 +137,82 @@ function M.step(graph, state, width, height)
|
||||
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)
|
||||
-- Build visible nodes array (reuse if possible)
|
||||
local visible_nodes = state.visible_nodes
|
||||
if not visible_nodes then
|
||||
visible_nodes = {}
|
||||
for _, node in ipairs(graph.node_list) do
|
||||
if node.visible then
|
||||
visible_nodes[#visible_nodes + 1] = node
|
||||
end
|
||||
end
|
||||
state.visible_nodes = visible_nodes
|
||||
end
|
||||
|
||||
if #visible_nodes == 0 then
|
||||
local n = #visible_nodes
|
||||
if n == 0 then
|
||||
state.converged = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- Reset forces
|
||||
for _, node in ipairs(visible_nodes) do
|
||||
node.vx = 0
|
||||
node.vy = 0
|
||||
-- Reset forces (use direct assignment for speed)
|
||||
for i = 1, n do
|
||||
visible_nodes[i].vx = 0
|
||||
visible_nodes[i].vy = 0
|
||||
end
|
||||
|
||||
-- Calculate repulsive forces between all pairs of visible nodes
|
||||
for i = 1, #visible_nodes do
|
||||
-- Calculate repulsive forces between all pairs
|
||||
-- Use Barnes-Hut approximation for large graphs
|
||||
local use_approximation = n > (SETTINGS.LARGE_GRAPH_THRESHOLD or 100)
|
||||
local theta_sq = (SETTINGS.BARNES_HUT_THETA or 0.8) ^ 2
|
||||
|
||||
for i = 1, n do
|
||||
local node1 = visible_nodes[i]
|
||||
for j = i + 1, #visible_nodes do
|
||||
local x1, y1 = node1.x, node1.y
|
||||
local vx1, vy1 = 0, 0
|
||||
|
||||
for j = i + 1, n 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 dx = x1 - node2.x
|
||||
local dy = y1 - node2.y
|
||||
local dist_sq = dx * dx + dy * dy
|
||||
|
||||
local fx, fy = repulsive_force(dx, dy, distance)
|
||||
-- Skip very distant nodes in large graphs (approximation)
|
||||
if use_approximation and dist_sq > 10000 then
|
||||
-- Skip or use approximation
|
||||
if dist_sq > 40000 then
|
||||
goto continue
|
||||
end
|
||||
end
|
||||
|
||||
node1.vx = node1.vx + fx
|
||||
node1.vy = node1.vy + fy
|
||||
local fx, fy = repulsive_force(dx, dy, dist_sq)
|
||||
|
||||
vx1 = vx1 + fx
|
||||
vy1 = vy1 + fy
|
||||
node2.vx = node2.vx - fx
|
||||
node2.vy = node2.vy - fy
|
||||
|
||||
::continue::
|
||||
end
|
||||
|
||||
node1.vx = node1.vx + vx1
|
||||
node1.vy = node1.vy + vy1
|
||||
end
|
||||
|
||||
-- Calculate attractive forces for visible edges
|
||||
for _, edge in ipairs(graph.edges) do
|
||||
local edges = graph.edges
|
||||
local nodes = graph.nodes
|
||||
for i = 1, #edges do
|
||||
local edge = edges[i]
|
||||
if edge.visible then
|
||||
local source = graph.nodes[edge.source]
|
||||
local target = graph.nodes[edge.target]
|
||||
local source = nodes[edge.source]
|
||||
local target = 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 distance = sqrt(dx * dx + dy * dy)
|
||||
|
||||
local fx, fy = attractive_force(dx, dy, distance)
|
||||
|
||||
@@ -182,54 +224,50 @@ function M.step(graph, state, width, height)
|
||||
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
|
||||
-- Apply gravity force and update positions
|
||||
local max_displacement = 0
|
||||
local temp = state.temperature
|
||||
|
||||
for i = 1, n do
|
||||
local node = visible_nodes[i]
|
||||
|
||||
-- Add gravity
|
||||
local gx, gy = gravity_force(node.x, node.y, node.degree, center_x, center_y)
|
||||
local vx = node.vx + gx
|
||||
local vy = node.vy + gy
|
||||
|
||||
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 not node.fx then
|
||||
local disp_sq = vx * vx + vy * vy
|
||||
|
||||
if displacement > 0 then
|
||||
if disp_sq > 0.01 then
|
||||
local displacement = sqrt(disp_sq)
|
||||
-- Limit displacement by temperature
|
||||
local limited_displacement = math.min(displacement, state.temperature)
|
||||
local factor = limited_displacement / displacement
|
||||
local limited = min(displacement, temp)
|
||||
local factor = limited / displacement
|
||||
|
||||
local dx = node.vx * factor
|
||||
local dy = node.vy * factor
|
||||
local move_x = vx * factor
|
||||
local move_y = vy * factor
|
||||
|
||||
node.x = node.x + dx
|
||||
node.y = node.y + dy
|
||||
node.x = max(padding, min(width - padding, node.x + move_x))
|
||||
node.y = max(padding, min(height - padding, node.y + move_y))
|
||||
|
||||
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)
|
||||
local abs_move = max(abs(move_x), abs(move_y))
|
||||
if abs_move > max_displacement then
|
||||
max_displacement = abs_move
|
||||
end
|
||||
end
|
||||
else
|
||||
node.x = node.fx
|
||||
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.temperature = temp * SETTINGS.COOLING_RATE
|
||||
state.iteration = state.iteration + 1
|
||||
|
||||
-- Check convergence
|
||||
|
||||
@@ -146,18 +146,21 @@ M.DEFAULT_KEYMAPS = {
|
||||
|
||||
-- Graph visualization settings
|
||||
M.GRAPH_SETTINGS = {
|
||||
-- Layout algorithm parameters
|
||||
-- Layout algorithm parameters (optimized for speed)
|
||||
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
|
||||
REPULSION_STRENGTH = 3000, -- How strongly nodes repel each other
|
||||
ATTRACTION_STRENGTH = 0.02, -- Spring constant for connected nodes
|
||||
IDEAL_EDGE_LENGTH = 40, -- Ideal distance between connected nodes
|
||||
GRAVITY = 0.15, -- Pull toward center (stronger = faster convergence)
|
||||
DAMPING = 0.8, -- Velocity damping per iteration
|
||||
MIN_VELOCITY = 0.5, -- Stop threshold (higher = faster stop)
|
||||
MAX_ITERATIONS = 100, -- Maximum layout iterations (reduced for speed)
|
||||
COOLING_RATE = 0.9, -- Temperature cooling per iteration (faster cooling)
|
||||
INITIAL_TEMPERATURE = 80, -- Initial movement freedom
|
||||
-- Barnes-Hut optimization threshold (for large graphs)
|
||||
BARNES_HUT_THETA = 0.8, -- Use approximation for distant nodes
|
||||
LARGE_GRAPH_THRESHOLD = 100, -- Use optimizations above this node count
|
||||
},
|
||||
|
||||
-- Visual settings
|
||||
|
||||
Reference in New Issue
Block a user