Files
ideadrop.nvim/lua/ideaDrop/ui/graph/renderer.lua
Carlos Gutierrez 937f20b892 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
2026-01-10 23:02:40 -05:00

425 lines
12 KiB
Lua

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