diff --git a/lua/ideaDrop/core/init.lua b/lua/ideaDrop/core/init.lua index a560c22..b2d82de 100644 --- a/lua/ideaDrop/core/init.lua +++ b/lua/ideaDrop/core/init.lua @@ -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 }) diff --git a/lua/ideaDrop/ui/graph/cache.lua b/lua/ideaDrop/ui/graph/cache.lua new file mode 100644 index 0000000..15ff498 --- /dev/null +++ b/lua/ideaDrop/ui/graph/cache.lua @@ -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 diff --git a/lua/ideaDrop/ui/graph/data.lua b/lua/ideaDrop/ui/graph/data.lua index 674244b..8f50abe 100644 --- a/lua/ideaDrop/ui/graph/data.lua +++ b/lua/ideaDrop/ui/graph/data.lua @@ -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 diff --git a/lua/ideaDrop/ui/graph/init.lua b/lua/ideaDrop/ui/graph/init.lua index 542dd3b..9fa2519 100644 --- a/lua/ideaDrop/ui/graph/init.lua +++ b/lua/ideaDrop/ui/graph/init.lua @@ -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( diff --git a/lua/ideaDrop/ui/graph/layout.lua b/lua/ideaDrop/ui/graph/layout.lua index 068d5fc..f795446 100644 --- a/lua/ideaDrop/ui/graph/layout.lua +++ b/lua/ideaDrop/ui/graph/layout.lua @@ -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 diff --git a/lua/ideaDrop/utils/constants.lua b/lua/ideaDrop/utils/constants.lua index 7e09822..f9cdd46 100644 --- a/lua/ideaDrop/utils/constants.lua +++ b/lua/ideaDrop/utils/constants.lua @@ -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