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:
2026-01-10 23:18:49 -05:00
parent c706e8ee4f
commit 0d1aa591e5
6 changed files with 459 additions and 148 deletions

View File

@@ -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 })

View 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

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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